WinUI 参考: 完整的属性表面与设计建议,参见 Text Controls。
文本与媒体控件是 Microsoft.UI.Reactor(Reactor)目录里负责展示、富文本和媒体的那一半:呈现用户正在阅读、观看或检视的内容,外加一个用于富文本编辑的 RichEditBox。它们大多是 WinUI 文本与媒体表面之上的薄包装,因此修饰符名称和无障碍行为与 WinUI 开发者所熟悉的完全一致。两个例外是 Markdown(string) —— 一个 Reactor 原创渲染器,把 GFM 风格的 Markdown 解析成与其余 UI 相同的元素树,无需绕道 WebView —— 以及语义化文本变体 Heading / SubHeading,它们预设了排版,使无障碍工具无需手工设置 AutomationProperties 就能推断文档大纲。目录在这里做的取舍偏向组合:丰富版式来自 VStack 里许多小的文本元素,而不是一个靠手工调校 Inline 记录的巨型 RichTextBlock。先扫一遍修饰符表格再看正文,然后跳到你需要的控件。
文本与媒体¶
本页覆盖 Reactor 中的展示文本、内联富文本、富文本编辑和媒体控件。单行输入控件(TextBox、PasswordBox)见表单。数据绑定集合见集合。
文本变体¶
Title(string) // WinUI title 文本样式,约 28pt
Heading(string) // 语义化标题,约 28pt
SubHeading(string) // 子分区标题头,约 20pt
Subtitle(string) // 标题下方的辅助行
BodyLarge(string) // 引导段落
TextBlock(string) // 正文散文
BodyStrong(string) // 正文散文,半粗
Caption(string) // 约 12pt 的元信息文本
class TextVariantsDemo : Component
{
public override Element Render() => VStack(8,
Title("Title — the largest variant"),
Heading("Heading — page or section title"),
SubHeading("SubHeading — region header"),
Subtitle("Subtitle — supporting line under a heading"),
BodyLarge("BodyLarge — lead paragraph."),
TextBlock("Body text. The default size and weight for prose."),
BodyStrong("BodyStrong — emphasis within body copy."),
Caption("Caption — secondary metadata, dates, labels.")
).Padding(24);
}

Heading、SubHeading 和 Caption 返回带预设字号的 TextBlockElement;Heading 和 SubHeading 还设置了标题的无障碍级别。它们是建立文档大纲的正确工具 —— 屏幕阅读器和无障碍扫描器把它们当作层级地标,而一个用 .FontSize(24).Bold() 装扮的裸 TextBlock 只是视觉上大而已。优先用变体;只有当大纲中的位置与你想要的视觉重量不匹配时,才去用 TextBlock 上的修饰符。
| 工厂方法 | 默认字号 | 何时使用 |
|---|---|---|
Title |
约 28pt,半粗 | 需要 WinUI title 级字号但不需要标题语义时。 |
Heading |
约 28pt,粗 | 每页一个 —— 语义化的文档标题。 |
SubHeading |
约 20pt,半粗 | 长页面内的分区标题头。 |
Subtitle |
约 20pt,常规 | 紧贴在标题下方的辅助行。 |
BodyLarge |
约 18pt | 引导段落或强调性散文。 |
TextBlock |
正文 | 段落、标签、行内帮助。 |
BodyStrong |
正文,半粗 | 正文中的强调,且不跳字号。 |
Caption |
约 12pt | 时间戳、元信息、字段下方的辅助文本。 |
WinUI 设计页:Typography in Windows 11。
TextBlock 修饰符¶
class TextBlockModifiersDemo : Component
{
public override Element Render() => VStack(8,
TextBlock("Bold + sized").Bold().FontSize(18),
TextBlock("Selectable so the user can copy.").IsTextSelectionEnabled(),
TextBlock(
"A long paragraph that demonstrates wrapping behavior. " +
"Without TextWrapping, content stays on one line and is " +
"clipped or scrolls. With TextWrapping.Wrap, the block " +
"flows across multiple lines inside its width.")
.TextWrapping()
.MaxLines(2)
.TextTrimming(Microsoft.UI.Xaml.TextTrimming.WordEllipsis)
.Width(320)
).Padding(24);
}

最常够到的流畅方法:
| 流畅方法 | 效果 |
|---|---|
.Bold() / .SemiBold() |
设置 FontWeight。如果是分区标题头,改用 Heading。 |
.FontSize(double) |
覆盖变体默认值。 |
.FontFamily(string) |
字体族名或 Microsoft.UI.Xaml.Media.FontFamily。 |
.TextWrapping() |
默认不换行;调用它才会跨行流动。 |
.MaxLines(int) |
限制可见行数 —— 与 TextTrimming 搭配。 |
.TextTrimming(mode) |
CharacterEllipsis / WordEllipsis / Clip / None。 |
.TextAlignment(alignment) |
Left / Right / Center / Justify。 |
.LineHeight(double) |
覆盖行盒高度(对密集列表很有用)。 |
.IsTextSelectionEnabled() |
让用户可选中并复制文本。 |
.CharacterSpacing(int) |
以 em 的百分之一为单位 —— 30 ≈ 0.3em 字距。 |
注意:
TextBlock有一条快路径渲染器,只有在你通过工厂方法的字符串重载设置Text时(TextBlock("…"))才会启用。一旦你改用RichTextBlock走内联、把CharacterSpacing改成非零值,或把TextTrimming设为Clip,布局就会回落到慢路径,每次测量的 CPU 开销大约翻倍。上千行的列表上这很要紧;静态散文则无所谓。WinUI 的调试属性IsTextPerformanceVisualizationEnabled会把快路径文本高亮成绿色 —— 剖析一个滚动密集的界面时把它打开,把凡是没变绿的控件降级为带修饰符的普通TextBlock。
RichTextBlock¶
class RichTextDemo : Component
{
public override Element Render() => VStack(8,
SubHeading("Inline-formatted prose"),
RichTextBlock([
Paragraph(
Run("Tap the "),
Hyperlink("docs",
new Uri("https://learn.microsoft.com/windows/apps/")),
Run(" to keep reading.")),
Paragraph(
Run("Reactor builds the paragraph tree from value-typed " +
"records. No XAML inlines, no DataTemplate."))
]).LineHeight(22).Width(420)
).Padding(24);
}

RichTextBlock 用于混合 run 与内联元素的段落 —— 超链接、句中的粗体、颜色切换。请节制使用:一组字段放在表单里更合适,一篇跨屏的文章放在 Markdown(string) 里更合适。当结构是动态时(一条带 @提及的聊天消息、一条把查询词加粗的搜索结果),RichTextBlock 才是对的工具 —— 因为你每次渲染都从数据构建 Paragraph[]。
| 辅助器 | 用途 |
|---|---|
Paragraph(params RichTextInline[]) |
一个段落;每个视觉断行一个。 |
Run(string text) |
一段纯文本片段。 |
Hyperlink(string text, Uri target) |
带导航目标的内联链接。 |
Hyperlink(string text, Action onClick) |
可点击的内联片段 —— 触发委托并抑制平台导航。用于让单个富文本 run 变得可交互(打开编辑器、派发命令),而无须逃逸到托管的原生子树。 |
InlineUI(Element child) |
通过 InlineUIContainer 在段落中间嵌入一个活的控件(图表、滑块、按钮)。 |
修饰符 —— .MaxLines、.LineHeight、.TextAlignment、.TextTrimming、.CharacterSpacing —— 与 TextBlock 一致。内联 run 不走快路径渲染器,因此静态文本优先用普通 TextBlock。
注意: 用
InlineUI(...)嵌入活控件、然后再修改同一段落里的某个Run,曾经会把外围的ScrollViewer/ScrollView滚到顶部:WinUI 的文本引擎会从头重新测量该段落,而嵌入元素在其中一次布局传递里贡献零高度,于是滚动宿主从容地把偏移悄悄夹到 0,且再不恢复(issue #487)。Reactor 现在已经替你处理了 ——ScrollViewer(RichTextBlock(...))会自动在内联 UI 变更之间保持用户的滚动偏移,不需要额外 API,也不需要每个应用各写一套绕法。像往常一样把RichTextBlock包进滚动宿主即可。
WinUI 设计页:Rich text block。
RichEditBox¶
class RichEditDemo : Component
{
public override Element Render()
{
var (text, setText) = UseState(
"Edit me. RichEditBox supports paste-with-formatting, " +
"spell-check, and Enter for new paragraphs.");
return VStack(8,
SubHeading("RichEditBox"),
RichEditBox(text, setText)
.AcceptsReturn()
.IsSpellCheckEnabled()
.TextWrapping()
.Height(160).Width(420)
).Padding(24);
}
}

RichEditBox 是 RichTextBlock 的可编辑对应物 —— 多行文本,支持从带格式来源粘贴、拼写检查、输入法组合和选区。变更处理器带的是纯文本内容;要拿带格式的输出,你得通过 .Set(...) 读 RichEditBox.Document,自己序列化那个文本区间。单行文本输入属于 TextBox,不在这里。
| 流畅方法 | 效果 |
|---|---|
.AcceptsReturn(bool) |
回车开启新段落,而不是提交。 |
.TextWrapping() |
换行(默认)或不换行。 |
.MaxLength(int) |
限制输入长度。 |
.IsSpellCheckEnabled(bool) |
开关波浪下划线。 |
.SelectionHighlightColor(brush) |
覆盖选区背景色。 |
.TextChanged(Action<string>) |
在构造参数之外订阅。 |
WinUI 设计页:Rich edit box。
Markdown(Reactor 原创)¶
包说明。
Markdown(...)由可选的Microsoft.UI.Reactor.Advanced包提供(spec 062 §7)。加上<PackageReference Include="Microsoft.UI.Reactor.Advanced" Version="0.1.0-preview.15" />,并把它与核心工厂一并导入:using static Microsoft.UI.Reactor.Advanced.Factories;。两个重载都返回基类型Element,而MarkdownOptions仍在Microsoft.UI.Reactor.Markdown里 —— 所以用 options 重载时还需要using Microsoft.UI.Reactor.Markdown;。
class MarkdownDemo : Component
{
public override Element Render()
{
const string source =
"# Release notes\n\n" +
"Reactor **0.42** ships:\n\n" +
"- Compositor animations via `UseAnimation`.\n" +
"- A new [Markdown](https://example.com) renderer.\n" +
"- Bug fixes for `LazyVStack` keyed reorder.\n\n" +
"> Migration guide lives in the spec.\n";
return VStack(8,
SubHeading("Markdown"),
Markdown(source)
).Padding(24).Width(440);
}
}

Markdown 是本页最大的 Reactor 原创控件。它用内嵌的 md4c 解析器解析 GitHub 风格的 Markdown,并产出一棵 Reactor 元素树:标题变成带标题变体的 TextBlock,列表项变成两列 Grid(Auto 标记 / * 内容),链接变成内联超链接,代码段变成等宽 TextBlock。没有 WebView,没有 HTML 往返 —— 产出物与本页其他所有修饰符(.Padding、.Width、.TextWrapping)都能组合。
与 WebView2 相比的取舍是保真度:Markdown 不支持任意 HTML、内嵌 <script> 或 CSS。它支持段落、标题(h1–h6)、列表(无序、有序、嵌套)、行内代码、可选语言标记的围栏代码块、链接、图像、粗体、斜体、删除线、引用块、硬换行和表格。超出这些的 —— 内嵌视频、自定义布局、带主题的语法高亮代码 —— 就得退回 WebView2 加一份 HTML 渲染的兜底,或另做一个查看器。
它适用于发布说明、应用内帮助、对话式 AI 输出、用户创作的长文(提交说明、知识库文章),或任何源文本是纯字符串、读者只需要一层薄薄格式的表面。
| 用 Markdown 的时机 | 用 RichTextBlock 的时机 |
|---|---|
| 源内容以字符串形式创作(LLM 输出、README、用户评论) | 内联结构由带类型的数据算出 |
| 你需要列表、代码块、引用块 | 带行内格式的纯段落就够了 |
| 完整的 GFM 子集可以接受 | 你需要任意类型的 Inline 元素 |
从 .md 文件往返 |
从 RichTextParagraph[] 模式往返 |
没有 WinUI 对应物:WinUI 在 Community Toolkit 里提供了一个 MarkdownTextBlock,但 API 表面和行为都不同。本控件以 Reactor 工厂方法为规范参考。
Image¶
class ImageDemo : Component
{
public override Element Render() => VStack(8,
SubHeading("Image"),
// 资源 Uri —— 打包资源用 ms-appx://,磁盘用 file://,远程用 https://。
Image("ms-appx:///Assets/StoreLogo.png")
.AutomationName("Reactor app logo")
.Set(img => img.Stretch = Microsoft.UI.Xaml.Media.Stretch.UniformToFill)
.Width(96).Height(96),
TextBlock("Stretch.UniformToFill for cover art; " +
"ImageFailed to detect missing assets.").Opacity(0.6)
).Padding(24);
}

Image 接受标准的 WinUI URI 方案 —— 随应用打包的资源用 ms-appx:///,应用数据文件用 ms-appdata:///,任意磁盘路径用 file:///,远程来源用 http(s)://。工厂方法把源的解码交给 WinUI BitmapImage —— 同样的缓存、同样的 DPI 感知。
| 流畅方法 | 效果 |
|---|---|
.Width(double) / .Height(double) |
布局尺寸。两者都不设时,图像取自然像素尺寸。 |
.NineGrid(Thickness) |
拉伸中间、保留边框(chrome 式背景)。 |
.Set(img => img.Stretch = ...) |
拉伸模式:None / Uniform(默认) / UniformToFill / Fill。 |
.ImageOpened(Action) |
解码成功。 |
.ImageFailed(Action<string>) |
解码失败 —— 触发它来换上兜底图。 |
不要: 在紧凑的列表项渲染里把
Source设成全尺寸的http://URL,从而在 UI 线程上解码大型远程图像。WinUI 解码器确实会把工作甩到 UI 线程之外,但网络获取依然会拦着可视化树,在字节到齐之前画不出那一行。虚拟化列表里要懒加载媒体,请走一个UseResource,由它去取一份降采样的数据,等Pending解决后再渲染Image。
WinUI 设计页:Images and image brushes。
MediaPlayerElement¶
class MediaPlayerDemo : Component
{
public override Element Render() => VStack(8,
SubHeading("MediaPlayerElement"),
MediaPlayerElement(
"https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4")
.Width(420).Height(240)
.Set(m =>
{
m.AreTransportControlsEnabled = true;
m.AutoPlay = false;
}),
TextBlock("Use AreTransportControlsEnabled for play/pause UI.")
.Opacity(0.6)
).Padding(24);
}

MediaPlayerElement 包装了 WinUI 的 MediaPlayerElement,后者本身又托管一个驱动音视频播放的 MediaPlayer。工厂方法在常见场景下接受字符串 URL;对于流来源,通过 .Set(m => m.MediaPlayer.Source = ...) 设置播放器。
| 开关 | 设置方式 |
|---|---|
AreTransportControlsEnabled |
.Set(m => m.AreTransportControlsEnabled = true) |
AutoPlay |
.Set(m => m.AutoPlay = false) |
IsFullWindow |
.Set(m => m.IsFullWindow = true) |
MediaOpened / MediaEnded / MediaFailed |
专用流畅方法重载 |
不要: 为了驱动播放而在每次渲染时挂载/卸载
MediaPlayerElement。这个 WinUI 控件持有一个底层MediaPlayer,每次重挂载都会重新初始化硬件解码器;反复折腾会毁掉播放流畅度。让元素在树里以稳定位置只渲染一次,通过 Hook(用UseRef持有播放器)驱动Source/ 播放状态,而不是靠卸载重挂。
WinUI 设计页:Media player。
WebView2¶
class WebViewDemo : Component
{
public override Element Render()
{
var (loaded, setLoaded) = UseState(false);
return VStack(8,
SubHeading("WebView2"),
WebView2(new Uri("about:blank"))
.NavigationCompleted(_ => setLoaded(true))
.Width(420).Height(240),
TextBlock(loaded ? "Loaded." : "Loading…").Opacity(0.6)
).Padding(24);
}
}

WebView2 嵌入一个基于 Chromium 的浏览器。把它用于真正需要 HTML/CSS/JavaScript 的内容 —— 一个 markdown 页面不需要,一个内嵌的 Office 查看器需要。该控件为导航提供生命周期事件,并暴露一个 WebMessageReceived 通道用于 postMessage 互操作。
| 事件流畅方法 | 触发时机 |
|---|---|
.NavigationStarting(Action<Uri>) |
导航开始 —— 可通过 .Set 取消。 |
.NavigationCompleted(Action<Uri>) |
页面加载完成。 |
.WebMessageReceived(Action<string>) |
页面调用了 window.chrome.webview.postMessage(...)。 |
.CoreWebView2Initialized(Action) |
底层 CoreWebView2 就绪 —— 通过 .Set(w => w.CoreWebView2....) 配置它。 |
不要: 把
WebView2放进HStack这类尺寸不确定的父容器却不给显式尺寸。WebView2 按其内容测量,而真实网页的内容就是视口 —— 没有边界时它会膨胀填满可用空间,并在页面重排时引发布局振荡。始终钉住.Width和.Height(或把控件放进固定尺寸的Grid单元格)。
WinUI 设计页:WebView2。
MapControl¶
class MapControlDemo : Component
{
public override Element Render() => VStack(8,
SubHeading("MapControl"),
// 令牌留空 —— 请替换为真实的 Bing Maps 密钥以获取瓦片。
// 没有令牌时控件只渲染网格背景。
MapControl(mapServiceToken: null, zoomLevel: 4)
.Width(420).Height(240)
).Padding(24);
}

MapControl 包装 Microsoft.UI.Xaml.Controls.Maps.MapControl。服务令牌来自 Bing Maps 开发者门户 —— 没有它,网格背景会渲染但拿不到瓦片。要做完整定制(图钉、覆盖层、场景),通过 .Set(m => ...) 够到底层控件;Reactor 目前暴露的是工厂参数加直接直通。
不要: 把
MapControl发到一个需要容忍离线的应用里却不给兜底。瓦片获取失败不抛异常 —— 控件只是显示网格。通过.Set订阅底层的MapControl.MapServiceErrorOccurred,离线时换上一张静态地图的Image(或一个"地图不可用"面板)。
WinUI 设计页:Map control(Windows App SDK)。
参考¶
| 控件 | 工厂方法 | Reactor 原创? | WinUI 文档 |
|---|---|---|---|
TextBlock |
TextBlock(string) |
否 | Text block |
Title / Heading / SubHeading / Subtitle / BodyLarge / BodyStrong / Caption |
Heading(string) 等 |
变体预设 | — |
RichTextBlock |
RichTextBlock(string) 或 RichTextBlock(RichTextParagraph[]) |
否 | Rich text block |
RichEditBox |
RichEditBox(text, onChanged) |
否 | Rich edit box |
Markdown |
Markdown(string) —— Microsoft.UI.Reactor.Advanced |
是 | — |
Image |
Image(string) |
否 | Images and image brushes |
MediaPlayerElement |
MediaPlayerElement(string?) |
否 | Media player |
WebView2 |
WebView2(Uri?) |
否 | WebView2 |
MapControl |
MapControl(token, zoom) |
否 | Map control |
InkCanvas |
未包装 —— 参见差距分析 | — | Ink controls |
模式¶
用 Markdown 呈现长文¶
发布说明 / 变更日志模式:把一段 GFM 字符串解析成元素树,然后给容器做样式,而不是给内部元素。加内边距、把宽度约束到约 640 逻辑像素以保证可读性,剩下的层级交给 Markdown 渲染器。当渲染器位于一个频繁渲染的父级内时,用 UseMemo 以源字符串为键把调用包起来;解析器便宜但会分配内存,记忆化能让 GC 压力保持平稳:
class LongFormProseDemo : Component
{
const string Source = "# Release notes\n\nShipped **today**.\n";
public override Element Render()
{
// 解析器便宜但会分配;以源字符串为键做记忆化,这样一个频繁渲染的
// 父级不会反复解析未变动的散文。
var rendered = UseMemo(() => Markdown(Source), Source);
return Border(rendered).Padding(20).Width(640);
}
}
这与 recipes/master-detail 的内容面板、以及 chat 示例 的消息气泡是同一个形状。
用 RichTextBlock 做行内数据展示¶
当某个列表行需要 Name (status) — 3 hours ago(状态加粗、时间戳变暗)时,用带类型的数据为每行构建一个 RichTextBlock。把段落构造放在行 Component 里内联完成,这样它只在该行数据变化时才重跑;中等行数下 RichTextBlock 不是瓶颈,但当 LazyVStack 以 60fps 滚动上千行时,内联元素的分配会显现在剖析结果里。
常见错误¶
什么都用 TextBlock¶
// 不要这样:
VStack(8,
TextBlock("Settings").FontSize(28).Bold(),
TextBlock("Display").FontSize(20).Bold(),
TextBlock("Adjust resolution and orientation.")
)
class TextVariantsDemo : Component
{
public override Element Render() => VStack(8,
Title("Title — the largest variant"),
Heading("Heading — page or section title"),
SubHeading("SubHeading — region header"),
Subtitle("Subtitle — supporting line under a heading"),
BodyLarge("BodyLarge — lead paragraph."),
TextBlock("Body text. The default size and weight for prose."),
BodyStrong("BodyStrong — emphasis within body copy."),
Caption("Caption — secondary metadata, dates, labels.")
).Padding(24);
}
"不要"的写法交付了视觉层级却没有语义层级 —— 无障碍扫描器报告没有任何标题,屏幕阅读器用户拿不到文档大纲。正确的形式是用 Heading / SubHeading / TextBlock 工厂方法,好让 accessibility.md 的地标检测生效。
在紧凑滚动循环里渲染 Markdown 而不记忆化¶
class MarkdownRowsDemo : Component
{
public override Element Render()
{
var messages = new List<Message>
{
new("m1", "**First** message."),
new("m2", "Second message with `code`."),
};
// Memo(key, factory) —— 跨回收的行缓存。一次滚动回收会
// 再次请求该键并拿回同一个元素实例,于是解析器对未变动的行
// 再也不会运行第二次。
//
// 键要同时包含行的身份 *和* 正文,不能只有正文:这是逐行缓存,
// 否则两条恰好文本相同的消息会撞在同一条缓存条目上。
// 把 Id 也放进去,还能在单条消息正文被编辑时正确重新解析。
return LazyVStack<Message>(messages, m => m.Id, (m, i) =>
Memo((m.Id, m.Body), () => Markdown(m.Body))).Height(200);
}
}
Markdown 解析很快但不是免费的 —— 在 60fps × 数百个可见行 × 每次状态变化之下,解析器的分配会出现在 GC 里。这里要用带键的 Memo(key, factory) 重载,而不是 Memo(ctx => …, deps)。两者同名,但编译器按参数形状选择,只有带键的那一版拥有跨回收的行缓存:一次滚动回收会再次请求该键并拿回同一个元素实例,于是解析器对未变动的行再也不会运行。Memo(ctx => …, deps) 只在父级重新渲染时跳过工作,而纯滚动并不发生这种事。
提示¶
挑能完成任务的最小控件。 按布局开销递增:TextBlock > RichTextBlock > Markdown > WebView2。只有当低一档表达不了那个结构时才升级。
凡是用户可能想复制的文本,都加 .IsTextSelectionEnabled()。 错误消息、ID、路径、日志行、命令输出 —— 能拖拽选中再 Ctrl+C 都会有用得多。代价只是每个 TextBlock 一个修饰符。
给媒体控件钉住尺寸。 Image、MediaPlayerElement、WebView2、MapControl 默认都是"填满可用空间"或"取内容自然尺寸",这与 VStack/HStack 的自动布局配合得很糟。设置 .Width 和 .Height(或把它们放进固定尺寸的 Grid 单元格)。
Markdown 是渲染 LLM 输出的正确选择。 把 AI 生成的文本当作一个 markdown 流 —— 解析它、渲染它、给容器套主题。渲染器能与目录其余部分组合,并且保持无障碍。
下一步¶
- 表单与输入 —— 上一篇:可编辑输入控件与校验系统。
- 状态与信息 —— 下一篇:
Progress、InfoBar、徽标与非交互反馈。 - 样式 —— 应用到每个文本元素上的主题令牌与字体流畅方法。
- 无障碍 ——
Heading/SubHeading如何映射到文档地标。 - Markdown 示例 —— chat 示例中的端到端 LLM 输出渲染。