深度解析 WinUI 3 ScrollPresenter / ScrollView 的 ScrollStarting 与 ZoomStarting 事件:在视口改变前抢占生成虚拟化 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 仓库)中ScrollPresenter与ScrollView的ScrollStarting/ZoomStarting事件为线索,完整梳理这两个事件的触发时机、事件参数语义、CorrelationId关联机制,以及它们被设计出来要解决的"虚拟化列表滚动空白帧"问题。读完本文,你将掌握如何监听非动画视口变化、如何利用预期视图(anticipated view)提前为ItemsRepeater生成虚拟化 UI,并理解从IScrollController请求到InteractionTracker交接再到 UI 线程事件回调的完整调用链。
背景:为什么要引入"Starting"事件?
视口(View)的定义
ScrollPresenter与ScrollView的"视口"由三个只读属性共同定义:
HorizontalOffsetVerticalOffsetZoomFactor
ScrollStarting事件在非动画滚动即将开始时触发,ZoomStarting事件在非动画缩放即将开始时触发。它们的事件参数携带"预期视口"(anticipated view),即即将到达的目标偏移与缩放。
要解决的痛点:虚拟化列表的空白内容
这两个事件的引入动机非常明确:解决虚拟化列表滚动时的空白内容(blank content)问题。以ScrollBar拖动长列表为例,问题的完整链路如下(见原规范文档的 Spec note):
- 用户拖动
ScrollBar的Thumb,产生一次大幅度的偏移跳转请求; - 请求通过
IScrollController接口(ScrollToRequested/ScrollByRequested)交给ScrollPresenter; ScrollPresenter将请求移交给底层的InteractionTracker;InteractionTracker在**合成器线程(compositor thread)**上异步更新Content的Visual位置,然后在 UI 线程上触发IInteractionTrackerOwner.ValuesChanged;- UI 线程据此更新
HorizontalOffset/VerticalOffset/ZoomFactor(注意:这三个属性不是依赖属性),触发EffectiveViewChanged,ItemsRepeater再为新区间生成 UI。
问题在于:当 UI 线程处理完毕时,独立移动的Visual早已进入尚未生成任何 UI 元素的区域,于是ScrollPresenter出现短暂的空白内容。通过scrollPresenter.ScrollTo(...)跳转到很远的位置时,也会产生同样的"单帧空白"。
ScrollStarting事件正是在把请求移交给 InteractionTracker 的那一刻触发,从而让ItemsRepeater得以在合成器更新之前,就为目标偏移区间生成 UI 元素,从根本上消除空白帧。
为什么不用其他方案?
规范文档记录了三个被否决的替代方案,理解它们有助于把握当前设计的边界:
- 新增
IScrollPreviewProvider接口:作为IScrollAnchorProvider的姊妹接口,暴露ScrollStarting/ZoomStarting。但ItemsRepeater与滚动器之间本就存在多处依赖(如ViewportManager中的实现),引入新接口并不能带来严格解耦,反而增加 API 面。 - 新增
EffectiveViewportChanging事件:让ItemsRepeater监听该事件替代监听ScrollPresenter.ScrollStarting。优点是无类型耦合、且不会为部分遮挡的视口生成过多 UI;但需要投入昂贵的 MUX 工作量。 - 单一
ViewChanging事件:曾考虑在ScrollStarting与ZoomStarting触发时统一抛出一个ViewChanging。但既有ViewChanged事件的语义是"每当偏移或缩放变化就触发",触发频次远高于ScrollStarting/ZoomStarting,若引入ViewChanging会造成"每次 ViewChanged 前都应先 ViewChanging"的误导性期望,因此被放弃。
核心用法示例:为偏移跳转提前生成虚拟化 UI
规范文档给出了一个典型用法:自定义Panel在Loaded时向上查找父级ScrollPresenter,订阅ScrollStarting,利用事件参数中的预期偏移与缩放提前生成 UI:
public class MyPanel : Panel { private ScrollPresenter _parentScrollPresenter; private void OnLoaded(object sender, RoutedEventArgs args) { _parentScrollPresenter = FindParentByType<ScrollPresenter>(); if (_parentScrollPresenter != null) { _parentScrollPresenter.ScrollStarting += ParentScrollPresenter_ScrollStarting; } } private void ParentScrollPresenter_ScrollStarting(ScrollPresenter sender, ScrollingScrollStartingEventArgs args) { GenerateItemsForView(args.HorizontalOffset, args.VerticalOffset, args.ZoomFactor); } }关键点在于:args中暴露的HorizontalOffset/VerticalOffset/ZoomFactor是预期视口(可能为近似值,见下文),此时真实视口尚未移动,正是生成虚拟化元素的最佳时机。
ScrollPresenter.ScrollStarting 事件
触发来源
当ScrollPresenter的视口(三个只读属性之一)即将受非动画滚动请求影响时触发。请求来源包括:
ScrollPresenter的ScrollTo/ScrollBy调用,且产生非动画视口变化;ScrollView的ScrollTo/ScrollBy调用转发到其控件模板内的ScrollPresenter;ScrollPresenter的HorizontalScrollController/VerticalScrollController(IScrollController实现)触发ScrollByRequested/ScrollToRequested事件——典型例子是用户拖动ScrollBar的Thumb产生的ScrollToRequested。
事件在请求移交给底层 InteractionTracker 时触发(而非等到合成器完成)。
动画模式的影响
ScrollTo/ScrollBy到底产生动画还是非动画变化,取决于两件事:
- 传入的
ScrollingAnimationMode参数(Disabled/Enabled等); - 系统设置中的"动画效果"(Animation effects)开关。
也就是说,即便显式传入ScrollingAnimationMode.Enabled,在系统关闭动画效果时仍可能退化为非动画变化。
预期视口的"近似性"
规范文档特别强调:当ScrollPresenter正处于动画进行中,且新请求是相对当前视口的(如ScrollBy),此时合成器线程领先于 UI 线程,UI 线程无法精确预知相对变化的结果,因此事件参数给出的预期视口是近似值。可能引发"动画进行中"的典型途径包括:
- 用户输入(触摸平移、鼠标滚轮);
- 带
ScrollingAnimationMode.Enabled的ScrollTo/ScrollBy/ZoomTo/ZoomBy; AddScrollVelocity/AddZoomVelocity;IScrollController实现触发ScrollToRequested/ScrollByRequested/AddScrollVelocityRequested。
规范中的例子很直观:当前视口为(100.0, 200.0, 2.0f)且正在动画中,此时执行ScrollBy(horizontalOffsetDelta: 10.0, verticalOffsetDelta: 50.0, new ScrollingScrollOptions(ScrollingAnimationMode.Disabled)),最终视口不会精确等于(110.0, 250.0, 2.0f),但ViewChanged事件会落在该邻域附近。
注意:一次
ScrollStarting(或ZoomStarting)通知到(x, y, z),并不保证随后有ViewChanged精确通知到(x, y, z)。若动画正在进行,ViewChanged通知到的很可能是(x, y, z)的近似值。
ScrollView 的转发机制:同一事件,同一参数实例
ScrollView.ScrollStarting在其内部的ScrollPresenter触发自己的ScrollStarting时同步触发,两个事件共用同一个ScrollingScrollStartingEventArgs实例。这种"透传"模式适用于ScrollView暴露的所有滚动事件。
源码证据:在 ScrollView.cpp 中,ScrollView在控件模板中的ScrollPresenter加载时订阅其事件(OnScrollPresenterScrollStarting/OnScrollPresenterZoomStarting),并在ScrollPresenter被替换或卸载时妥善解绑(对应 token 管理见 ScrollView.h)。ScrollView的事件声明位于 ScrollView.idl,使用TypedEventHandler<ScrollView, ScrollingScrollStartingEventArgs>与TypedEventHandler<ScrollView, ScrollingZoomStartingEventArgs>。
事件参数类:ScrollingScrollStartingEventArgs / ScrollingZoomStartingEventArgs
类定义
两个类均位于Microsoft.UI.Xaml.Controls命名空间,分别为ScrollStarting与ZoomStarting提供数据。在仓库的 ScrollPresenter.idl 中,它们被标记为[MUX_PREVIEW](预览 API),并且都暴露四个只读属性:
runtimeclass ScrollingScrollStartingEventArgs { Int32 CorrelationId { get; }; Double HorizontalOffset { get; }; Double VerticalOffset { get; }; Single ZoomFactor { get; }; } runtimeclass ScrollingZoomStartingEventArgs { Int32 CorrelationId { get; }; Double HorizontalOffset { get; }; Double VerticalOffset { get; }; Single ZoomFactor { get; }; }类名遵循既有的Scrolling*EventArgs命名惯例(如ScrollingScrollAnimationStartingEventArgs、ScrollingZoomAnimationStartingEventArgs、ScrollingScrollCompletedEventArgs、ScrollingBringingIntoViewEventArgs、ScrollingAnchorRequestedEventArgs等),这在 ScrollPresenterPrimitives.idl 中可以找到一系列同类事件参数类型。
CorrelationId:把事件与方法调用关联起来
CorrelationId用于标识一次视口变化请求:
- 若
ScrollStarting由ScrollTo/ScrollBy调用触发,则事件参数中的CorrelationId与该方法调用返回值一致; - 若事件源于
IScrollController请求,则CorrelationId与ScrollControllerScrollToRequestedEventArgs.CorrelationId或ScrollControllerScrollByRequestedEventArgs.CorrelationId一致; - 对于
ZoomStarting,CorrelationId与触发它的ZoomTo/ZoomBy调用返回值一致。
这使得开发者可以把"方法调用—事件触发—完成回调"关联成一条完整的时间线。
HorizontalOffset / VerticalOffset / ZoomFactor
HorizontalOffset(double):预期(或近似)的ScrollPresenter.HorizontalOffset;由ScrollView触发时表示ScrollView.HorizontalOffset的预期/近似值。VerticalOffset(double):语义同上,对应垂直偏移。ZoomFactor(float):预期(或近似)的ZoomFactor。
源码中的触发实现
在 ScrollPresenter.cpp 中,RaiseScrollStarting与RaiseZoomStarting负责构造事件参数并派发事件。它们接收correlationId与三个"预期视口"值,内部创建ScrollingScrollStartingEventArgs/ScrollingZoomStartingEventArgs实例,依次调用SetCorrelationId、SetHorizontalOffset、SetVerticalOffset、SetZoomFactor后触发事件源:
void ScrollPresenter::RaiseScrollStarting( int32_t offsetsChangeCorrelationId, double anticipatedHorizontalOffset, double anticipatedVerticalOffset, float anticipatedZoomFactor) { if (m_scrollStartingEventSource) { auto scrollStartingEventArgs = winrt::make_self<ScrollingScrollStartingEventArgs>(); scrollStartingEventArgs->SetCorrelationId(offsetsChangeCorrelationId); scrollStartingEventArgs->SetHorizontalOffset(anticipatedHorizontalOffset); scrollStartingEventArgs->SetVerticalOffset(anticipatedVerticalOffset); scrollStartingEventArgs->SetZoomFactor(anticipatedZoomFactor); m_scrollStartingEventSource(*this, *scrollStartingEventArgs); } }RaiseZoomStarting的实现与此对称。值得一提的是,ScrollPresenter内部维护了AnticipatedOffset等"预期值"状态(UpdateAnticipatedOffset、ResetAnticipatedView等辅助函数),并且会把预期偏移裁剪到[0, AnticipatedScrollableWidth/AnticipatedScrollableHeight]范围内,确保事件参数中给出的预期视口始终是合法值——这正体现了"预先计算预期视口并交给 UI 层"的设计思路。
ZoomStarting 事件
ZoomStarting与ScrollStarting完全对称,只是针对非动画缩放请求:
- 触发来源:
ScrollPresenter或ScrollView的ZoomTo/ZoomBy调用(非动画结果),ScrollView会转发给模板内的ScrollPresenter; ScrollView.ZoomStarting在内部ScrollPresenter触发ZoomStarting时同步触发,共用同一ScrollingZoomStartingEventArgs实例;ZoomTo/ZoomBy是否产生动画同样受ScrollingAnimationMode与系统"动画效果"设置影响;- 动画进行中时,事件参数中的预期视口同样是近似值,近似原因与
ScrollStarting一致(合成器线程领先于 UI 线程)。
API 详情:MIDL3 声明
规范文档给出了权威的 MIDL3(IDL)形态,与仓库源码 ScrollPresenter.idl 及 ScrollView.idl 一一对应:
namespace Microsoft.UI.Xaml.Controls { unsealed runtimeclass ScrollView : Control { event Windows.Foundation.TypedEventHandler<ScrollView, ScrollingScrollStartingEventArgs> ScrollStarting; event Windows.Foundation.TypedEventHandler<ScrollView, ScrollingZoomStartingEventArgs> ZoomStarting; } } namespace Microsoft.UI.Xaml.Controls.Primitives { unsealed runtimeclass ScrollPresenter : Microsoft.UI.Xaml.FrameworkElement, Microsoft.UI.Xaml.Controls.IScrollAnchorProvider { event Windows.Foundation.TypedEventHandler<ScrollPresenter, ScrollingScrollStartingEventArgs> ScrollStarting; event Windows.Foundation.TypedEventHandler<ScrollPresenter, ScrollingZoomStartingEventArgs> ZoomStarting; } }注意ScrollPresenter位于Microsoft.UI.Xaml.Controls.Primitives命名空间(声明于 ScrollPresenter.idl),而事件参数类位于Microsoft.UI.Xaml.Controls。此外,ScrollPresenter实现IScrollAnchorProvider,这也是规范文档在备选方案讨论中提到ItemsRepeater已通过该接口与滚动器交互的背景。
仓库中的验证与测试
该 API 在仓库中有完整的测试与示例支撑,可作为二次开发的参考:
- 单元/集成测试:ScrollPresenterViewChangeTests.cs 中大量用例同时订阅
ScrollStarting与ZoomStarting,通过Log.Comment输出HorizontalOffset/VerticalOffset/ZoomFactor,并统计事件触发次数以断言事件在预期的视口变化场景中被正确触发。 - 交互测试页面:ScrollViewBlankPage.xaml.cs 与 ScrollViewDynamicPage.xaml.cs 展示了在
ScrollPresenter与ScrollView两层分别挂接/卸载事件处理器,并记录CorrelationId与预期视口的完整调试方式。 - 功能测试页面:ScrollPresenterDynamicPage.xaml.cs 同样包含对这两个事件的订阅验证。
总结与实践建议
ScrollStarting/ZoomStarting是 WinUI 3 中面向"视口即将变化"的前置通知机制,与既有的ViewChanged、ScrollAnimationStarting/ZoomAnimationStarting、ScrollCompleted/ZoomCompleted共同构成完整的滚动生命周期事件体系。它的设计目标非常聚焦:让ItemsRepeater等虚拟化宿主在合成器真正移动内容之前,就为目标区间生成 UI,从而消除虚拟化列表大幅跳转时的空白帧。
实践要点回顾:
- 在
ScrollPresenter或外层ScrollView上订阅ScrollStarting/ZoomStarting,ScrollView只是透传内部ScrollPresenter的同名事件; - 在事件处理中使用
args.HorizontalOffset/args.VerticalOffset/args.ZoomFactor获取(可能近似的)预期视口,据此生成虚拟化 UI; - 用
args.CorrelationId将事件与ScrollTo/ScrollBy/ZoomTo/ZoomBy的调用返回值、以及IScrollController的请求事件关联起来; - 当动画进行中发生相对视口变化时,预期值是近似值,不应假设其精确等于最终视口;
- 这两个 API 在仓库中当前标记为
MUX_PREVIEW,使用时需关注 WinUI 3 的发布渠道与版本支持情况(相关发布说明可参考 release-channels.md)。
若需深入了解ScrollPresenter与ScrollView的整体设计,可继续阅读仓库内的两份关联设计文档:ScrollPresenter API 规范 与 ScrollView API 规范。
【免费下载链接】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),仅供参考