Skip to content

WinUI 参考: 完整的属性面与设计指导,参见 Colors

Microsoft.UI.Reactor(Reactor)的主题令牌是给颜色起的一个名字,由系统在渲染 时解析。同一个令牌(Theme.Accent)在应用处于浅色模式、深色模式或高 对比度时会发出不同的 Brush —— 而且在用户翻转 主题时无需重渲染即可切换。另一种做法 —— 把 "#0066CC" 这样的字面量写死在 修饰符里 —— 会把值锁死在单一视觉模式上,用户一打开设置就静默 破坏深色主题。令牌存在的意义,是让 样式层能把画笔交给控件,而不必关心当前是哪个主题。最常见的错误是为了 「我就把这一次写死吧」而去用字面量; REACTOR_THEME_001 分析器会在构建时捕获它,使它无法 交付出去。

主题令牌

Reactor 在静态的 Theme 类上暴露了 36 个具名主题令牌,另加一个 Theme.Ref(string) 逃生通道, 用于任意 XAML 资源键。令牌通过 WinUI 的资源 系统(XamlControlsResources + 应用的 ThemeDictionaries)解析,因此 它们产生的值与 Microsoft 在 WinUI Fluent 颜色中交付的设计系统一致。

引导片段

class SwatchGrid : Component
{
    public override Element Render() => ScrollView(
        VStack(16,
            Heading("Theme tokens"),
            SwatchSection("Accent", new[] {
                ("Accent", Theme.Accent),
                ("AccentSecondary", Theme.AccentSecondary),
                ("AccentTertiary", Theme.AccentTertiary),
                ("AccentDisabled", Theme.AccentDisabled),
            }),
            SwatchSection("Text", new[] {
                ("PrimaryText", Theme.PrimaryText),
                ("SecondaryText", Theme.SecondaryText),
                ("TertiaryText", Theme.TertiaryText),
                ("DisabledText", Theme.DisabledText),
                ("AccentText", Theme.AccentText),
            }),
            SwatchSection("Surfaces", new[] {
                ("SolidBackground", Theme.SolidBackground),
                ("CardBackground", Theme.CardBackground),
                ("SmokeFill", Theme.SmokeFill),
                ("SubtleFill", Theme.SubtleFill),
                ("LayerFill", Theme.LayerFill),
            }),
            SwatchSection("Control fill", new[] {
                ("ControlFill", Theme.ControlFill),
                ("ControlFillSecondary", Theme.ControlFillSecondary),
                ("ControlFillTertiary", Theme.ControlFillTertiary),
                ("ControlFillDisabled", Theme.ControlFillDisabled),
                ("ControlFillInputActive", Theme.ControlFillInputActive),
            }),
            SwatchSection("Stroke", new[] {
                ("CardStroke", Theme.CardStroke),
                ("SurfaceStroke", Theme.SurfaceStroke),
                ("DividerStroke", Theme.DividerStroke),
                ("ControlStroke", Theme.ControlStroke),
                ("ControlStrokeSecondary", Theme.ControlStrokeSecondary),
            }),
            SwatchSection("Signal", new[] {
                ("SystemAttention", Theme.SystemAttention),
                ("SystemSuccess", Theme.SystemSuccess),
                ("SystemCaution", Theme.SystemCaution),
                ("SystemCritical", Theme.SystemCritical),
                ("SystemNeutral", Theme.SystemNeutral),
                ("SystemSolidNeutral", Theme.SystemSolidNeutral),
                ("SystemSolidAttention", Theme.SystemSolidAttention),
                ("SystemAttentionBackground", Theme.SystemAttentionBackground),
                ("SystemSuccessBackground", Theme.SystemSuccessBackground),
                ("SystemCautionBackground", Theme.SystemCautionBackground),
                ("SystemCriticalBackground", Theme.SystemCriticalBackground),
                ("SystemNeutralBackground", Theme.SystemNeutralBackground),
            })
        ).Padding(20)
    );

    private static Element SwatchSection(string title, (string Name, ThemeRef Ref)[] tokens) =>
        VStack(8,
            SubHeading(title),
            VStack(4, tokens.Select(t => Row(t.Name, t.Ref).WithKey(t.Name)).ToArray())
        );

    private static Element Row(string name, ThemeRef token) => HStack(12,
        new BorderElement(Empty())
            .Background(token)
            .Size(40, 24)
            .WithBorder(Theme.ControlStroke),
        TextBlock(name).Width(220),
        TextBlock(token.ResourceKey).Opacity(0.6)
    );
}

令牌色样 —— 浅色主题 令牌色样 —— 深色主题

同一个网格,两种主题。每个格子都是 ThemeRef 针对当前生效的 ThemeDictionary 解析出的结果;切换主题是对宿主的 一次属性更新,而不是组件的重渲染。

注意:.Background("#0066CC") 里写死颜色字符串会彻底跳过 主题切换。用户翻到 深色模式时按钮依然是蓝色,而「仍然偏蓝的背景上的白字」在浅色的中性色之下 还能满足 WCAG —— 但它背后的深色模式中性色让页面看起来是坏的。 请在 .Background / .Foreground / .WithBorder 上使用 Theme.Accent(或匹配的 Theme.Ref(key)); REACTOR_THEME_001 会在构建时标记出 字面量,让这个回归永远到不了评审环节。

解析过程

ThemeRef 是一个只读 record struct,持有 资源键。当 .Background(token) 这样的修饰符到达 协调器时,框架会从已落实的 FrameworkElement 沿可视化树向上查找最接近的 RequestedTheme,回退到 ActualTheme,再 回退到 Application.RequestedTheme。所选主题名对应的画刷 来自 Application.Resources.ThemeDictionaries[name](或 最近的合并字典)。

class ThemeRefGood : Component
{
    public override Element Render() =>
        Button("Click me", () => { }).Background(Theme.Accent);
}

当用户翻转系统主题时,WinUI 会引发 ActualThemeChanged;Reactor 订阅它,并更新 所有引用了 ThemeRef 的元素上的画刷。需要按配色方案分支的组件 使用 UseColorScheme

// UseColorScheme 以响应式方式读取当前配色方案(Light / Dark),
// 组件可以直接按该值分支,而不必重新实现解析器。
class SchemeAwareBadge : Component
{
    public override Element Render()
    {
        var scheme = UseColorScheme();
        var label = scheme == ColorScheme.Dark ? "Dark mode" : "Light mode";
        return TextBlock(label).Foreground(Theme.PrimaryText);
    }
}

UseColorScheme() 返回应用级的 ColorScheme(Light 或 Dark),并在 该值变化时重跑渲染。仅当分支无法用令牌表达时 (例如「渲染哪个图标」这类与颜色无关的决定)才动用它。它不报告强制 颜色模式 —— 那要用 UseHighContrast()

令牌目录

6 组共 36 个具名令牌,另加 Theme.Ref(string) 逃生 通道。下面的列表与 src/Reactor/Core/Theme.cs 完全一致。

强调色(4 个令牌)

令牌 WinUI 键 用于何处
Theme.Accent AccentFillColorDefaultBrush 主要操作按钮的背景。
Theme.AccentSecondary AccentFillColorSecondaryBrush 按下/悬停的强调色变体。
Theme.AccentTertiary AccentFillColorTertiaryBrush 细微的强调色表面。
Theme.AccentDisabled AccentFillColorDisabledBrush 强调色控件的禁用状态。

文本(5 个令牌)

令牌 WinUI 键 用于何处
Theme.PrimaryText TextFillColorPrimaryBrush 标题与正文。
Theme.SecondaryText TextFillColorSecondaryBrush 辅助文案。
Theme.TertiaryText TextFillColorTertiaryBrush 图注与时间戳。
Theme.DisabledText TextFillColorDisabledBrush 禁用标签。
Theme.AccentText AccentTextFillColorPrimaryBrush 行内强调文本(链接、徽章)。

表面(5 个令牌)

令牌 WinUI 键 用于何处
Theme.SolidBackground SolidBackgroundFillColorBaseBrush 页面背景。
Theme.CardBackground CardBackgroundFillColorDefaultBrush 卡片/面板容器。
Theme.SmokeFill SmokeFillColorDefaultBrush 模态遮罩。
Theme.SubtleFill SubtleFillColorSecondaryBrush 列表行的悬停底色。
Theme.LayerFill LayerFillColorDefaultBrush 堆叠卡片层。

控件填充(5 个令牌)

令牌 WinUI 键 用于何处
Theme.ControlFill ControlFillColorDefaultBrush 默认的中性控件表面。
Theme.ControlFillSecondary ControlFillColorSecondaryBrush 悬停状态。
Theme.ControlFillTertiary ControlFillColorTertiaryBrush 按下状态。
Theme.ControlFillDisabled ControlFillColorDisabledBrush 禁用。
Theme.ControlFillInputActive ControlFillColorInputActiveBrush 处于活动状态的 TextBox / NumberBox 填充。

描边(5 个令牌)

令牌 WinUI 键 用于何处
Theme.CardStroke CardStrokeColorDefaultBrush 卡片轮廓。
Theme.SurfaceStroke SurfaceStrokeColorDefaultBrush 页面级分隔线。
Theme.DividerStroke DividerStrokeColorDefaultBrush 行内列表分隔线。
Theme.ControlStroke ControlStrokeColorDefaultBrush 默认控件边框。
Theme.ControlStrokeSecondary ControlStrokeColorSecondaryBrush 焦点/按下时的边框。

信号色(12 个令牌)

令牌 WinUI 键 用于何处
Theme.SystemAttention SystemFillColorAttentionBrush 需要注意的提示。
Theme.SystemSuccess SystemFillColorSuccessBrush 成功状态。
Theme.SystemCaution SystemFillColorCautionBrush 警告状态。
Theme.SystemCritical SystemFillColorCriticalBrush 错误状态。
Theme.SystemNeutral SystemFillColorNeutralBrush 信息/中性。
Theme.SystemSolidNeutral SystemFillColorSolidNeutralBrush 实心中性表面。
Theme.SystemSolidAttention SystemFillColorSolidAttentionBackgroundBrush 实心注意表面。
Theme.SystemAttentionBackground SystemFillColorAttentionBackgroundBrush 注意横幅的背景。
Theme.SystemSuccessBackground SystemFillColorSuccessBackgroundBrush 成功横幅的背景。
Theme.SystemCautionBackground SystemFillColorCautionBackgroundBrush 警告横幅的背景。
Theme.SystemCriticalBackground SystemFillColorCriticalBackgroundBrush 错误横幅的背景。
Theme.SystemNeutralBackground SystemFillColorNeutralBackgroundBrush 信息横幅的背景。

这就是完整集合:4 + 5 + 5 + 5 + 5 + 12 = 36 个有类型的令牌, 另加 Theme.Ref(key) 用于资源树中的其他任何东西。

自定义键

// 通过字符串键引用任意 XAML 资源 —— 覆盖应用级重写
// 以及 Reactor 没有以有类型访问器暴露的任何令牌。
Button("Custom", () => { })
    .Background(Theme.Ref("MyAppTitleBarBackground"));

模式

按严重级别为主题化 InfoBar

信号背景令牌与前景信号令牌配对, 可以产出在两种主题下都正确可读的行内状态横幅。 判别式请使用 WinUI 的 InfoBarSeverity —— Reactor 自己的 Severity 枚举(Microsoft.UI.Reactor.Controls.Validation)是给 表单校验消息用的,成员也不同 (Info / Warning / Error)。

// 把一个严重级别映射到 Signal 令牌对上(前景 + 匹配的
// 背景),让横幅在浅色与深色下都跟随主题。
class StatusBannerDemo : Component
{
    public override Element Render() => VStack(8,
        StatusBanner("Saved", InfoBarSeverity.Success),
        StatusBanner("Disk almost full", InfoBarSeverity.Warning),
        StatusBanner("Upload failed", InfoBarSeverity.Error),
        StatusBanner("Sync scheduled", InfoBarSeverity.Informational)
    ).Padding(16);

    internal static Element StatusBanner(string text, InfoBarSeverity severity) => HStack(8,
        TextBlock(text).Foreground(severity switch
        {
            InfoBarSeverity.Success => Theme.SystemSuccess,
            InfoBarSeverity.Warning => Theme.SystemCaution,
            InfoBarSeverity.Error => Theme.SystemCritical,
            _ => Theme.SystemNeutral,
        })
    ).Background(severity switch
    {
        InfoBarSeverity.Success => Theme.SystemSuccessBackground,
        InfoBarSeverity.Warning => Theme.SystemCautionBackground,
        InfoBarSeverity.Error => Theme.SystemCriticalBackground,
        _ => Theme.SystemNeutralBackground,
    }).Padding(12).CornerRadius(4);
}

把它与 命令 结合,就能在 Command<T> 成功/失败时 渲染同样的横幅。

应用级令牌重写

当设计系统需要一个不在 WinUI 调色板里的品牌色时, 在 Application.Resources(或一个合并字典)里定义一次,然后 通过 Theme.Ref 取用:

<!-- App.xaml -->
<Application.Resources>
  <ResourceDictionary>
    <ResourceDictionary.ThemeDictionaries>
      <ResourceDictionary x:Key="Light">
        <SolidColorBrush x:Key="BrandPrimaryBrush" Color="#005FB8" />
      </ResourceDictionary>
      <ResourceDictionary x:Key="Dark">
        <SolidColorBrush x:Key="BrandPrimaryBrush" Color="#60AAFA" />
      </ResourceDictionary>
    </ResourceDictionary.ThemeDictionaries>
  </ResourceDictionary>
</Application.Resources>
Button("Buy now", BuyAction)
    .Background(Theme.Ref("BrandPrimaryBrush"));

这个品牌色能参与主题切换,是因为该重写具有 浅色与深色两个变体。不带 ThemeDictionaries 地定义它 会把它冻结在单一主题上 —— 那正是 Theme.Ref 被造出来 要防止的陷阱。

逐元素主题重写

单个子树可以通过 RequestedTheme 退出应用主题 —— 对于应当始终看起来是浅色的视频播放器表面或 打印预览窗格很有用:

return ScrollView(content).RequestedTheme(ElementTheme.Light);

Theme.Resolve 的向上查找会优先采纳局部重写,因此 该子树内的每个 ThemeRef 都会针对浅色 字典解析,即使应用其余部分是深色。

常见错误

硬编码颜色字面量

// Don't:
Button("Save", () => { }).Background("#0066CC");
//                                    ^^^^^^^^^^
// REACTOR_THEME_001 — 主题感知修饰符上的硬编码颜色字面量。
// 用户翻到深色模式时该按钮仍然是蓝色。
class ThemeRefGood : Component
{
    public override Element Render() =>
        Button("Click me", () => { }).Background(Theme.Accent);
}

字面量把值锁死在一个主题上;令牌会参与 切换。分析器在构建时就把这一点暴露出来,让回归 无法交付;请在 CI 中把该警告当作错误。

只测浅色而漏掉深色回归

一个常见的评审失误:PR 里的截图是浅色模式,评审者 签发通过,然后深色模式的破坏就落到了 main 上。

陷阱在于想用一个无头测试来覆盖它。下面这个测试看起来 像是检查了两种主题,其实并不能:

[Theory]
[InlineData(ElementTheme.Light)]
[InlineData(ElementTheme.Dark)]
public void StatusBanner_renders_in_both_themes(ElementTheme theme)
{
    var tree = StatusBanner("Saved", InfoBarSeverity.Success)
        .RequestedTheme(theme);

    // 空洞无力:扫描器检查的是无障碍元数据 —— 名称、
    // 角色、标签 —— 从不解析画刷。两种情形返回
    // 相同的结果,因此一个硬编码为仅浅色可读的横幅
    // 与一个正确的横幅一样容易通过。`theme` 参数
    // 根本没有到达这项断言所读取的任何东西。
    Assert.Empty(AccessibilityScanner.Scan(tree));
}

颜色回归只有在意法树被落实、资源查找针对生效主题运行时 才可观察,因此它需要一个针对真实 WinUI 控件的 自测 —— 无头测试根本无法构造这些控件。请针对每种 主题断言解析后的画刷,或对渲染出的控件做快照;对元素 记录做断言只能证明你构建了你构建的那个元素。

把非主题化的键当作 ThemeRef 解析

Application.Resources 中才存在 (而不在任何 ThemeDictionaries 条目中)的资源使用 Theme.Ref("MyAppHeaderBackground") 时,无论主题如何都会返回同一个画刷。 页面能渲染,但颜色不会切换。如果目标是主题感知,该资源必须位于 ThemeDictionaries 中;否则请在应用代码里把颜色写死, 并用注释标记它是有意为之。

提示

先找有类型的令牌,其次才是 Theme.Ref 逃生通道。 有类型的令牌有 IntelliSense 与文档注释;字符串键没有。 如果某个颜色在有类型的面上缺失,而它是稳定的 WinUI 画刷, 那就把它加到 Theme.cs,而不是让 Theme.Ref("…") 调用 四处增生。

在 CI 中把 REACTOR_THEME_001 与 REACTOR_THEME_004 当作错误。 修一个 硬编码颜色的代价是一行代码;深色主题回归落到 main 上的 代价是一份客户报告。

不要浪费轮次在每次渲染时重新解析。 ThemeRef 是一个 record struct —— 栈分配。解析器每次应用修饰符时调用一次; UseColorScheme 只在配色方案真正变化时才重跑渲染。

参考

概念 API 说明
令牌 Theme.<Name> src/Reactor/Core/Theme.cs 中的 36 个有类型访问器。
逃生通道 Theme.Ref(key) 任意 XAML 资源键,包括应用级重写。
响应式读取 UseColorScheme() 返回 ColorScheme;切换时触发重渲染。
逐元素重写 .RequestedTheme(ElementTheme) 遍历可视化树,解析最近的重写。
分析器 REACTOR_THEME_001REACTOR_THEME_004 标记主题感知修饰符上的硬编码字面量与 Brush/Color 对象。
WinUI 面 XamlControlsResources 持有每个具名令牌的 ThemeDictionary。

后续阅读

  • 样式 —— 样式链中的上一篇: 消费 ThemeRef 值的修饰符面。
  • 动画 —— 下一篇:在主题化值之间做动画; 画刷如何在主题切换中过渡。
  • 无障碍 —— 决定每个表面该取用哪个令牌的 对比度预算。
  • DevTools 内部机制 —— 开发菜单里的 主题切换开关如何驱动本页所记录的同一个解析器。
  • Reactor 的规则 —— REACTOR_THEME_001 分析器 与其他被强制执行的惯用法并列出现的地方。