WinUI 3 布局 RTL(从右到左)支持全解析:FlowDirection、UI 镜像与坐标系统设计
2026/9/17 8:46:24 网站建设 项目流程

WinUI 3 布局 RTL(从右到左)支持全解析:FlowDirection、UI 镜像与坐标系统设计

【免费下载链接】microsoft-ui-xamlWinUI: a modern UI framework with a rich set of controls and styles to build dynamic and high-performing Windows applications.项目地址: https://gitcode.com/GitHub_Trending/mi/microsoft-ui-xaml

本文是 WinUI 3(microsoft-ui-xaml 仓库)关于从右到左(Right-to-Left,RTL)界面支持的深度技术指南。文章以仓库内设计笔记 docs/design-notes/rtl.md 为骨架,结合dxaml/xcp底层源码(CUIElementCMediaBaseCFontIconCTextBlock等)的实测实现,完整讲解 RTL 的触发机制、客户与 Xaml 各自的工作流程、坐标空间模型,以及 Canvas 布局、Flow Direction 翻转、DComp 可视化与辅助功能等环节面临的挑战。读完本文,你将理解 WinUI 3 中 RTL 是如何被定义、如何由宿主 HWND 触发、Xaml 内核如何通过翻转变换实现镜像,以及为什么"文本与图片不能镜像"这件事如此复杂。

1. 什么是 WinUI 3 的 RTL:由 FlowDirection 定义

RTL 的英文全称是Right-to-Left,指界面内容自右向左排列的布局方式,是阿拉伯语、希伯来语等从右往左书写语言的应用所必需的能力。在 WinUI 3 中,一个窗口内容的 RTL 状态完全由顶层元素的FlowDirection决定,而这个顶层元素就是XamlRoot.Content所引用的根元素。

FlowDirection属性只有两个取值:LeftToRight(默认值)与RightToLeft。设置该属性只影响该控件及其子元素内部的排列方式,即"UI 镜像(UI Mirroring)"效果。文档中特别强调:

  • RTL 状态与语言完全无关,它只由这一个属性定义;
  • FlowDirection是托管 Xaml 内容的"唯一事实来源(source of truth)",桌面应用与 Island(Xaml 岛)两种托管场景都遵循同一套规则。

文档把 UI 镜像定义为"窗口内容的镜像翻转",并引用 Win32 平台的WS_EX_LAYOUTRTL作为底层机制——这正是下一节要讲的触发入口。

2. 什么会触发 RTL 感知应用的 UI 镜像,作用范围是什么

镜像的触发责任在**宿主(host)**一方。宿主通过给 HWND 设置WS_EX_LAYOUTRTL扩展样式,将窗口切到 RTL 模式:

  • 这一操作会使该 HWND 上的 Xaml 内容(以及 IXP Content Island)切换到 RTL 模式;
  • 同时XamlRoot.ContentFlowDirection变为RightToLeft,镜像随之生效;
  • 子 HWND 默认继承顶层 HWND 的 RTL 样式,并遵循同样的逻辑流。

对于 WinUI 3 桌面应用,宿主就是使用 WinUI 3 框架的应用代码本身。这一变化是动态的——只要RTL样式被应用到 HWND 上,Xaml 就会即时响应。

2.1 客户工作流(客户看到什么)

  1. 客户决定何时切换顶层 HWND 到 RTL。文档给出的判定代码(判断当前线程 UI 语言是否为 RTL 语言,LOCALE_IREADINGLAYOUT返回 1 表示 RTL):
bool IsRTLLocale() { LCID lcid = MAKELCID(::GetThreadUILanguage(), SORT_DEFAULT); wchar_t localeName[LOCALE_NAME_MAX_LENGTH]; DWORD readingLayout = 0; ::LCIDToLocaleName(lcid, localeName, LOCALE_NAME_MAX_LENGTH, 0); ::GetLocaleInfoEx(localeName, LOCALE_IREADINGLAYOUT | LOCALE_RETURN_NUMBER, reinterpret_cast<wchar_t*>(&readingLayout), sizeof(readingLayout) / sizeof(wchar_t)); // Value of 1 indicates RTL language. See MSDN for LOCALE_IREADINGLAYOUT. return readingLayout == 1; }

也可以用GetUserPreferredUILanguages等替代 API。文档在此给 Xaml 团队留了一条备注:为了让用户得到正确的全球化体验,应提供良好的示例和指南,帮助用户构建全球化的应用。

  1. 客户将WS_EX_LAYOUTRTL应用到顶层 HWND,进入 RTL 模式:
LONG_PTR exStyle = ::GetWindowLongPtrW(hwnd, GWL_EXSTYLE); ::SetWindowLongPtr(hwnd, GWL_EXSTYLE, (exStyle | WS_EX_LAYOUTRTL));
  1. UI 镜像生效:整个 HWND 的内容——包括标题栏按钮、客户区里的 Xaml 控件——都会按从右到左的方向绘制。需要注意:镜像对图片(images)和图标(icons)没有任何影响,它们不会被水平翻转。

对客户而言,判断当前 Xaml 内容是否处于 RTL 模式的依据就是XamlRoot.Content.FlowDirection

上图展示了 RTL 模式下界面整体镜像后的效果(图片来自仓库 docs/images/RTL1.png)。

2.2 Xaml 工作流(Xaml 看到什么、做什么)

  1. 客户在 HWND 上设置 RTL 样式的瞬间,挂载在该 HWND 上的 Content Island 会:

    1. 把自己切换到 RTL 模式;
    2. 将自身的LayoutDirection属性设为 RTL;
    3. 触发StateChanged事件。(文档在此留给 IXP 一个问题:除LayoutDirection变化外,还有哪些情形会触发StateChanged?)
  2. Xaml 代码收到StateChanged事件后,把XamlRoot.ContentFlowDirection更新为RightToLeft,于是整个 UI 镜像到 RTL。

关键分层:ContentIsland.LayoutDirection是 Xaml 判断自己是否处于 RTL 世界的唯一事实来源;而XamlRoot.Content.FlowDirection严格跟随它的值,前者一变,后者就跟着变。

默认情况下,顶层 HWND 的 RTL 样式会被子 HWND 继承,同样流程随之重演。但如果子 HWND 显式设置了WS_EX_NOINHERITLAYOUT样式来阻断继承,那么该子 HWND 及其 Xaml 内容、Content Island 就会继续以 LTR 模式工作——反之亦然。

另外文档明确:Xaml Island 的行为与桌面托管没有任何差别,无论 Xaml 内容被托管在桌面窗口还是 Island 中,这套机制完全一致。

2.3 覆盖默认行为

有些场景需要反默认:例如宿主 HWND 处于 RTL,但你希望某个 Xaml Island 保持 LTR。文档给出两条路线,并强烈提醒"覆盖"只是特殊场景的兜底手段。

设计注记:绝大多数想要 RTL 的客户应该直接在 HWND 上使用 RTL 样式,而不是用覆盖(Override)手段。因为 RTL 的 HWND 能保证操作系统正确对待它:系统菜单布局会是 RTL、工具提示(tooltips)与辅助功能(accessibility)支持同样如此。用 LTR 应用去"伪装"成 RTL 应用,容易出现不可预期的行为。

Xaml 想覆盖布局:Xaml 代码可以直接设置ContentIsland.LayoutDirectionOverride为目标布局,它随后会改变ContentIsland.LayoutDirection(并沿上文描述的链路更新XamlRoot.Content.FlowDirection)。

客户想覆盖布局:典型例子是"Xaml Island 托管在一个不应跟随宿主 HWND 行为的 HWND 上"。但目前没有一个明确、无歧义的客户侧流程来做这件事——ContentIsland.LayoutDirectionOverride并不是公开 API。对于"弹窗里的 Xaml Island 不想跟随顶层 HWND 布局方向"这类需求,可以退而求其次地使用上文提到的WS_EX_NOINHERITLAYOUT样式阻断继承。

文档还记录了一个未解决的 Xaml 任务XamlRoot.Content.FlowDirection之所以不能用来覆盖默认的LayoutDirection,是因为该属性没有"未设置/默认"状态——它的默认值就是LeftToRight。一旦用它来做覆盖判定:

  • 一个 RTL HWND 用户只要把XamlRoot.Content换成新内容,就会因为默认值是LeftToRight而意外地把LayoutDirection覆盖掉——Xaml 根本无法区分这个值是"默认值"还是"用户显式设置";
  • 用户升级到开启 RTL 支持的更新版 WinUI 时,也会看到行为差异。

因此文档提出需要引入FlowDirectionOverride——要么给FlowDirection增加一个新值,要么提供一个独立的XamlRoot.Content专属属性。

3. Xaml(WinUI 3)中的坐标空间

RTL 模式下会牵涉多套同时存在的坐标系统,文档明确给出了 RTL 坐标模式的定义:原点切换到右上角,X 轴自右向左递增,Y 轴方向不变(自上而下)。下面是各坐标系统的 RTL 行为对照:

坐标系统RTL 行为
OS 坐标系统(client)RTL HWND 会切到 RTL 模式;屏幕坐标始终保持 LTR(原点在左上),但 OS 在需要 Screen → Client 转换时自动完成换算
Input(IXP)坐标系统应当是 RTL,PointerPoint也遵循该坐标。但当前是已知缺陷(IXP task):IXP 输入坐标系统尚未切到 RTL,导致 PointerPoint 坐标错误
DComp 输出(IXP)坐标系统始终保持 LTR 模式——这正是图片、图标在 RTL 模式下也不会镜像的原因;从 DComp visual 取点时需要做坐标转换。当前存在转换缺陷(IXP task):DComp 坐标空间中的点被错误地换算到屏幕坐标,引发大量 RTL ↔ LTR 相关的 bug
Xaml 坐标空间即 Xaml 控件内部使用的客户区坐标系统,会同步切换到 RTL 模式

4. Canvas 在 RTL 下的行为:原点在右上,X 越大越靠左

面向应用的公开 API 层面,Xaml 的 RTL 行为是符合直觉的:RTL 模式下原点(0, 0)在右上角,X 值增大方向是向左。文档给出了一个可直接运行的示例标记:

<Canvas Width="800" Height="600" Background="Red" FlowDirection="RightToLeft"> <Canvas Width="100" Height="100" Background="Green" Canvas.Left="200"> <TextBlock FontSize="24" Text="asdf" Foreground="White"></TextBlock> </Canvas> </Canvas>

上图为上述标记的实际渲染结果(来自仓库 docs/design-notes/images/rtl-sample-markup.png)。

需要记住的观察点:

  • 红色 800 宽的Canvas被标记为 RTL;绿色 100 宽的Canvas通过Canvas.Left="200"定位在其内部;绿色 Canvas 里有一个TextBlock
  • 绿色Canvas渲染在靠近右上角的位置——那里就是原点(0, 0)
  • Canvas.Left这个名字具有误导性,但它确实把绿色Canvas移到了右边——其行为印证了"增大 X 坐标就是向左移动";
  • 由于 RTL,TextBlock被对齐到其父绿色Canvas的右侧,而文字本身仍然正确渲染(没有被水平镜像)

图中黄色标记点的坐标含义值得细品:

  • 相对外层 Canvas:X = 200;
  • 相对内层 Canvas:X = 0;
  • 相对 TextBlock:X = 0——这一点很有意思,因为它遵循的是 RTL 坐标系;TextBlock自身仍是 RTL 元素,尽管其中的字形(glyphs)是按 LTR 渲染的。

文档还补充了一个结论:Popup元素(无论内联还是窗口化)在 RTL 上与其他任何UIElement行为一致——它可以继承树上层的FlowDirection,也可以拥有自己的FlowDirectionPopup下的元素可以像经过任何普通UIElement一样通过Popup继承 RTL。

5. Flow Direction 变换:Xaml 与 IXP 之间的"翻转补偿"

当 Xaml 与 IXP 打交道时,行为却完全不同:即使在 RTL 模式下,原点(0,0)仍在左上角,X 增大方向向右

"面向应用的公开 API 行为"与"面向 IXP 的内部记账行为"不一致,意味着 Xaml 必须写代码来补偿这个差异。

5.1 根级翻转变换

Xaml 会在其 RTL 子树的根部应用一个翻转变换(flip transform)。注意,这个翻转可以发生在任意元素上——因为每个FrameworkElement都有FlowDirection属性。这个翻转是:

  • X 方向缩放为 -1(X scale by -1);
  • 外加一个平移(translation),否则内容会被缩放到负空间里去。

两者合起来把 LTR 布局变成 RTL 布局。但仅仅做这个翻转还不够——如果只应用翻转,得到的结果是文本被镜像:

上图展示了只做根级翻转、不做叶片级补偿时的错误结果(来自仓库 docs/design-notes/images/rtl-sample-markup2.png)。注意文本被水平镜像了。

为此,TextBlock和图片还需要一次就地翻转(in-place flip)把内容再正过来。关键在于:这个就地翻转发生在叶(leaf)元素上,所有布局变换都位于这次就地翻转的上方。

5.2 叶片级翻转的两套机制

机制一:CDependencyObject::IsRightToLeft+CUIElement::GetLocalTransform

CDependencyObject::IsRightToLeft方法指明某个对象是否应处于 RTL 状态。CUIElement::GetLocalTransform(经由CUIElement::GetShouldFlipRTL)在决定元素渲染所用的局部变换时会读取该值,Xaml 据此自动按需添加就地翻转。

源码佐证(dxaml/xcp/core/core/elements/uielement.cpp):CUIElement::GetShouldFlipRTL的核心逻辑正是文档所描述的原则——只有当"自身 RTL 状态与父级不一致"时才会翻转

  • 先通过GetUIElementAdjustedParentInternal拿到父元素,检查父元素与自身的IsRightToLeft()是否一致(myRTLMatchesParentRTL);
  • flipRTL成立的条件是:当前元素不是"无父级 Popup",且(自身的 RTL 与父级不匹配,或是 RTL 无父级 Popup 的子元素);
  • 代码中还有针对 parentless Popup 的特殊处理:无父级 Popup 本身不参与布局、不做翻转,而是由它的子元素代为处理 RTL(注释里还留着一个 TODO:为什么无父级 Popup 自己不处理翻转、要由子元素来做?)。

这与设计笔记在 Popup 一节"Popup 像任何其他 UIElement 一样工作"的表述相互印证。

文档还列出了若干在源码中硬编码IsRightToLeft返回false的类型(这些类型拒绝镜像):

  • CMediaBase::IsRightToLeftCImage继承自它)——源码见 dxaml/xcp/core/core/elements/mediabase.cpp,注释明确写着 "Media objects do not inherit FlowDirection",即媒体对象不继承流方向;
  • CGlyphs::IsRightToLeft
  • CTextBoxView::IsRightToLeft
  • CRun::IsRightToLeft——注意CRunCDependencyObject而不是CUIElement,这个重写不期望被调用,断言为false

机制二:CTextBlock::GetContentRenderTransform

CTextBlock通过CTextBlock::GetContentRenderTransform做自己的就地翻转。该方法在渲染期由CTextBlock::HWRenderContent调用(相关实现分布在 dxaml/xcp/core/text/TextBlock/TextBlock.cpp 与TextBlockView.cpp中),需要时应用就地翻转,让文本保持 LTR 渲染。

5.3 第三种机制:CFontIcon 的可选 LTR 渲染

CFontIcon还有第三种可选机制,让图标始终以 LTR 渲染。它拥有MirroredWhenRightToLeft属性来指定图标是否需要镜像:如果不需要镜像,CFontIcon::ApplyScaleTransformForFlowDirection就会在 RTL 树中应用就地翻转。

源码佐证(dxaml/xcp/core/core/elements/icon.cpp):KnownPropertyIndex::FontIcon_MirroredWhenRightToLeft属性变化时会调用ApplyScaleTransformForFlowDirection(),该方法读取该属性并把结果写到元素上。与前面两种机制不同的是,CFontIcon没有直接插入渲染或命中测试管线,而是把变换设置在元素的RenderTransform属性上——渲染和命中测试随后像对待任何其他 RenderTransform 一样拾取它。

6. DComp 支撑的 Visual:Content Island 与 IXP 的 RTL 边界

Content Island 的 RTL 行为不会影响内容桥(content bridge)内部的渲染或输入——无论桥是 LTR 还是 RTL,Visual 始终假定(0,0)在左上角。这对 Xaml 来说基本是好消息:Xaml 可以继续做它已经在做的事——在内容桥内部按左上角原点假定做 RTL 翻转。

但这引出一系列待决问题(文档记录为 IXP task):IXP 需要评估这对 PopupWindowSiteBridges 的影响。该类有一个接收rectMoveAndResizeAPI——如果 site bridge 处于 RTL 模式,这个rect(0,0)在哪里?文档给出的参照是先例:在 RTL OS 中::CreateWindow创建 hwnd 时,(0,0)在何处?相应的 Xaml task 是:按需调整MoveAndResize调用。

更进一步,文档希望 IXP 侧也有与 Xaml 公开 API 一致的 RTL 行为:会有只基于 IXP visuals 与 content bridges(不含 Xaml)构建的应用,当桥处于 RTL 时它们希望(0,0)指内容桥的右上角。这给 Xaml 与 IXP 都带来挑战:

  • 对 Xaml 最大的影响是:Xaml 不能再假定(0,0)在左上角,而必须读取 visual 所挂载内容桥的设置并相应调整——这意味着要撤销一部分之前做的镜像。注意这影响的不仅是 RTL 场景,LTR 场景也一样:如果内容桥标记为 RTL,但应用声明了某个元素FlowDirection="LeftToRight",那么需要被应用翻转变换的反而是这段 LTR 内容。
  • 还有就地翻转的归属问题:文本与图片在 RTL 树中保持 LTR 渲染,这属于 Xaml 的内部机制;如果 IXP 侧的 RTL island 中叶片内容不该镜像,IXP 也需要一个"就地翻转"能力,Xaml 可以直接受益。

文档中的一条内嵌判断很有价值:Xaml 关心的其实不是"某个元素是 RTL",而是"某个元素的 RTL 与其父级不同"——每当出现这种情况,Xaml 就在树中放一个翻转变换(这正是上一节GetShouldFlipRTL源码所验证的!myRTLMatchesParentRTL逻辑)。照此思路,Xaml 只需更新其根 UIElement 来适应(0,0)有时在右侧这一事实,树上其他所有元素都能保持原有行为。对应的 Xaml task:这是需要为完整 RTL 支持做工作量评估的 Xaml 侧工作。

此外还有一个大问题:surface-backed 内容在 RTL 下如何处理。Xaml 的文本与图片正是这种内容。一个幼稚的 RTL 实现只是把翻转变换放在根部,结果会把所有 surface-backed 内容(例如文本,见前一张镜像图片)一并镜像——这看起来是不可取的行为,但最终由 IXP 拍板。假如不可取,Xaml 的变通做法就是:在不希望被镜像的元素(对 Xaml 而言是文本和图片)上多做一次就地翻转。Xaml 之所以敢这么做,是因为它知道文本与图片内容都处于树的最末端、下面没有子元素——但这个假设对 IXP并不安全:一个由SurfaceBrush支撑的SpriteVisual仍然是一个ContainerVisual,下面可以有子 Visual;对这些 SpriteVisual 做就地翻转,会顺带把其所有子级的 RTL 状态一并翻转。正确的做法是只对该 SpriteVisual 的 surface-backed 内容做翻转(记作 IXP task)。

文档的排期建议:这些 IXP 侧工作放到 1.2 wave 之后再做——改动量属于"中等到显著"级别,File Explorer 或其它 Shell 场景并不需要它;受益者是直接构建在 IXP API 上的客户,而 1.2 wave 中 IXP API 按作者理解还不会公开。

7. 辅助功能(Accessibility)与开放问题

辅助功能工具使用的是屏幕空间坐标,因此理论上在 RTL 下依然工作正常——但需要一个最终检查来确认工具上报的坐标是否正确:包括指针坐标、Xaml 控件坐标等。文档为此登记了 Xaml task:验证辅助功能工具能正常工作,尤其是在涉及屏幕空间坐标以及输入/输出等任何局部坐标时。

7.1 开放问题汇总

文档以"挑战与开放问题"章节收尾,这里汇总成表:

类别问题责任方
IXP InputIXP 输入坐标系统未切换到 RTL,PointerPoint坐标错误IXP task
IXP DCompDComp 坐标空间中的点被错误换算到屏幕坐标,引发大量 RTL ↔ LTR bugIXP task
IXP InputLayoutDirection外,还有哪些情形触发StateChanged事件IXP 待答
Xaml APIFlowDirection缺少"未设置/默认"状态,无法承载 override 语义,需新增FlowDirectionOverride值或独立属性Xaml task
IXP/XamlPopupWindowSiteBridges 的MoveAndResize在 RTL 模式下rect(0,0)定位语义;需按需调整调用IXP + Xaml task
IXP Visual 树IXP 是否要尊重其 Visual 树中的 RTL 属性、surface-backed 内容的镜像问题、SpriteVisual子级被连带翻转的问题IXP task(建议 1.2 wave 后)
Xaml 根元素Xaml 需更新根 UIElement 以适配(0,0)有时在右侧的情况Xaml task(需评估工作量)
Accessibility验证辅助功能工具在屏幕空间坐标与局部坐标下的正确性Xaml task

8. 延伸阅读

  • 完整设计笔记原文:docs/design-notes/rtl.md
  • 翻转判定核心实现:dxaml/xcp/core/core/elements/uielement.cpp(CUIElement::GetShouldFlipRTL
  • 媒体/图片不继承 FlowDirection 的实现:dxaml/xcp/core/core/elements/mediabase.cpp
  • FontIcon 的MirroredWhenRightToLeft与就地翻转:dxaml/xcp/core/core/elements/icon.cpp
  • TextBlock 渲染期的就地翻转:dxaml/xcp/core/text/TextBlock/TextBlock.cpp
  • 仓库内其他全球化/布局相关设计笔记:docs/design-notes/rtlEndToEnd.md、docs/design-notes/OneCoreTransforms.md

【免费下载链接】microsoft-ui-xamlWinUI: a modern UI framework with a rich set of controls and styles to build dynamic and high-performing Windows applications.项目地址: https://gitcode.com/GitHub_Trending/mi/microsoft-ui-xaml

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询