docs/advanced/notifications
应用能力 · Notifier

应用内通知

用 Notifier 在窗口内弹出不打断当前任务的通知:成功、错误、进度、撤销、人员消息,堆叠、悬停展开、八个停靠位置都由它管理。

预计阅读 9 分钟 · 含真实窗口录像

Notifier 是什么

ui.widgets.Notifier 是一个窗口级服务:初始化一次,之后在任何地方调用 show。通知卡挂在窗口浮层 portal 的 toast 层(z 2000,高于对话框、低于 tooltip),不参与页面布局;每张卡有自己固定的节点,删除其中一张不会让其他卡片重建或重放入场动画。它取代了早期版本的 ToastManager。

真实 Storybook:依次弹出成功、错误、警告、进度、需要操作、人员消息、撤销、静默与汇总,再连发 4 条形成堆叠,悬停展开后关闭中间一条。

每种组件的逐项 API(Notification 字段、NotifierOptions、全部方法签名)见组件站的 Notification 页面。

创建 Notifier

在根挂载函数里创建一次,把指针交给需要发通知的代码(例如放进 cx.bindState 的应用状态)。Notifier 由传入的 Scope 持有,Scope 释放时一并移除。

app.zig
const notifier = try ui.widgets.Notifier.init(scope, cx, .{
    .position = .bottom_center,
    // Keep the stack clear of a 56px toolbar.
    .content_insets = .{ .top = 56 },
    .max_visible = 4,
});
NotifierOptions 字段作用
position八个停靠位置之一,默认 .bottom_center;运行时可用 setPosition 切换
max_visible展开时最多显示的条数(2–6,默认 4)
expand_on_hoverfalse 时悬停只暂停计时、不展开
content_insets相对窗口的内缩(标题栏、工具栏、状态栏),22px 停靠边距从这里起算
width卡片宽度,默认 392
strings界面文案("刚刚"、"撤销"、"回复…"、折叠角标等),做本地化时替换
listener用户操作与到期事件的回调,也可用 setListener 设置

弹出一条通知

show 接收一个 ui.widgets.Notification 值并返回它的 id;字符串会被 Notifier 复制持有,调用方的缓冲区可以立即复用。同屏超过 6 条时,最旧的一条会走正常的退场动画。

notify.zig
// Success: auto-dismisses after 3.6 s.
_ = try notifier.show(.{
    .tone = .success,
    .title = "Saved to iCloud",
    .body = "3 files synced · 2.1 MB",
});

// Error with two actions: errors stay until the user acts.
const failed = try notifier.show(.{
    .tone = .@"error",
    .title = "Upload failed",
    .body = "network timeout · dist/app.zip",
    .actions = &.{
        .{ .label = "Retry", .primary = true, .tag = "retry" },
        .{ .label = "View log", .tag = "log" },
    },
});

// Undo: a 7 s countdown ring and a default "Undo" button.
_ = try notifier.show(.{
    .kind = .undo,
    .title = "Moved 4 items to the Trash",
    .body = "You can undo this for 7 seconds.",
});

// A person's message with an inline reply box.
_ = try notifier.show(.{
    .tone = .violet,
    .avatar = "L",
    .title = "Lin",
    .body = "I finished the table widths in section three.",
    .reply = true,
});

类型、色调与停留时长

外观由 kind(版式)和 tone(语义色)两个维度决定;行首图标默认从两者推导,也可以用 lead 或 glyph 覆盖。

kind版式
.default图标 + 标题 + 正文,可选按钮、回复框、头像
.progress旋转指示器 + 确定进度条 + n / N 计数,不显示生命条
.undo倒计时环(中心显示剩余秒数)+ 默认的「撤销」按钮
tone用途默认停留
.success操作完成3.6 s
.@"error"失败,需要用户处理常驻
.warning需要注意但不阻塞5.6 s
.info一般信息(默认)4.2 s
.violet人员、邀请等社交类消息4.2 s;带按钮时常驻
.quiet低打扰的状态提示(小圆点图标)2.8 s
  • ✓

    自动常驻:progress 卡、带回复框的卡、error 色调、带按钮的 violet 卡默认不会自动消失。

  • ✓

    显式覆盖:sticky = true 强制常驻;duration_ms 覆盖默认时长;life_bar 控制底部生命条是否显示。

  • ✓

    undo 的时长默认 7 秒,倒计时环与计时同步。

原地转换与进度

update(id, …) 改写同一张卡的类型、色调、标题与正文,并重置生命周期;卡片不销毁、不重建,也不重放入场。进度卡用 setProgress 逐帧推进,只更新进度层。用同一个 id 再次调用 show 也会走原地转换。

upload.zig
const id = try notifier.show(.{
    .kind = .progress,
    .title = "Uploading attachment",
    .body = "assets/hero-shot.png",
    .progress = .{ .value = 0, .label = "Uploading · about 5 s", .count = "0 / 5" },
});

// Each tick: only the progress layer is touched, the card isn't rebuilt.
notifier.setProgress(id, 0.6, "Uploading · about 2 s", "3 / 5");

// Done: the same card turns into a success card in place —
// no new entrance, and it now dismisses itself after 3.4 s.
try notifier.update(id, .{
    .tone = .success,
    .title = "Upload complete",
    .body = "assets/hero-shot.png",
    .duration_ms = 3400,
});

处理用户操作

Notifier 通过一个 ui.widgets.NotificationListener 回报事件。NotificationEvent 带有卡片 id、事件类型 kind,以及按钮的 tag / index 或回复内容 text。

EventKind何时触发
.action点击了按钮(用 tag 区分)
.undo点击了撤销;倒计时环已原地换成 ✓
.reply回复框提交(Enter 或「发送」),text 为内容
.dismissed用户关闭(✕ 或拖拽甩出)
.expired到期自动收起
uploads.zig
const Uploads = struct {
    fn onEvent(ctx: ?*anyopaque, n: *ui.widgets.Notifier, e: ui.widgets.NotificationEvent) void {
        const self: *Uploads = @ptrCast(@alignCast(ctx orelse return));
        switch (e.kind) {
            .action => if (std.mem.eql(u8, e.tag, "retry")) {
                // Turn the sticky error card into a progress card in place.
                n.update(e.id, .{
                    .kind = .progress,
                    .title = "Retrying upload",
                    .progress = .{ .value = 0, .label = "Uploading", .count = "0 / 5" },
                }) catch return;
                self.restart(e.id);
            } else n.dismiss(e.id),
            .reply => self.send(e.text),
            .undo => self.restoreTrash(),
            .dismissed, .expired => {},
        }
    }
    // …restart / send / restoreTrash
};

const uploads = try cx.bindState(Uploads, .{});
notifier.setListener(.{ .context = @ptrCast(uploads), .callback = Uploads.onEvent });

堆叠与悬停

多条通知折叠成一叠:最新的一条离锚点最近、z 序最高,后面的每层露边 9px 并缩小 3.8%,最多露出三层,另有一个「还有 N 条 · 悬停展开」角标。指针进入整组外接矩形(外扩 18px)时展开,间距 9px,同时暂停所有计时;移开后折叠并继续计时。

STACK
数值来自 notification/model.zig 的 Spec:露边 9px、每层缩放 3.8%、展开间距 9px。
01
全部清除

角标旁有一个「全部清除」圆钮:第一下展开出文字,第二下才真正清除,移开即收回。代码里用 dismissAll()。

02
拖拽甩出

单张卡可以被拖出去关闭,会产生 dismissed 事件。

03
窗口缩放

窗口尺寸变化时堆叠刚性跟随锚点,不会出现补间拖尾。

停靠位置

setPosition 只改锚点、增长方向和入场位移方向,卡片不重建;正在退场的卡片沿新方向继续完成退场。

POSITIONS
同一组 4 条通知依次停靠到八个位置;整窗画面,因为左侧停靠位贴着窗口左缘。

通知中心属于应用

历史记录、勿扰模式、铃铛入口这些「通知中心」能力是业务逻辑,不在 zenit 里:在你调用 show 的地方记录历史、决定是否弹出,再通过 Listener 得知关闭、到期与按钮操作。Storybook 里的「汇总」卡片就是这种模式——勿扰结束后用一条 quiet 通知概括期间收到的内容。

zenit · 双授权开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。可联系作者:zongyi.xzy#gmail.com(# 换成 @)zenit 5f9add5+wip 2026-09-30