在之前的两篇文章中,我们探讨了 WPF 中实现平滑滚动的不同方案:
- WPF 如何流畅地滚动ScrollViewer 简单实现下:基于 DoubleAnimation 的动画方案。
- WPF 使用CompositionTarget.Rendering实现平滑流畅滚动的ScrollViewer:基于 CompositionTarget.Rendering 的每帧布局更新方案。
虽然第二版方案解决了触控板和物理惯性的问题,但它引入了一个新的性能瓶颈:每帧调用 ScrollToVerticalOffset。这会导致 WPF 在每一帧都进行布局计算,在高负载场景下会直接卡死整个UI线程,造成掉帧或其他UI组件无响应。
为了解决这个问题,我进行了第三版(v3)设计,核心思路是:视觉层与逻辑层分离。
三种方案对比
方案实现方式优点缺点v1 (动画版)DoubleAnimation 驱动 VerticalOffset实现简单,代码量少无法保留惯性速度(动画打断);触控板体验差;不支持触摸/笔。v2 (布局驱动)Rendering 事件每帧调用 ScrollToVerticalOffset物理模型更真实;支持多种输入设备性能差:每帧触发 Layout 计算,高负载下掉帧严重。v3 (视觉分离)RenderTransform 驱动视觉,低频同步逻辑位置高性能:视觉满帧运行,逻辑低频更新;物理模型完善。实现相对复杂,需要处理坐标系转换和帧同步。现在来看看v3的效果(gif帧率好低):
接下来,我们详细介绍 v3 版本的设计与实现原理。
一、视觉与逻辑分离
v3 的核心在于将“用户看到的滚动”(视觉层)和“控件实际的滚动”(逻辑层)分离。
- 视觉层:
- 使用 TranslateTransform 对 Content 进行位移。
- 在 CompositionTarget.Rendering 中以屏幕刷新率(如 60Hz 或 144Hz)更新 Transform.Y。
- 因为 RenderTransform 只影响渲染而不触发布局(Measure/Arrange),所以性能极高,完全由 GPU 加速。
- 逻辑层:
- 维护实际的 ScrollViewer.VerticalOffset。
- 降频更新:不再每帧调用 ScrollToVerticalOffset,而是以较低的频率(如 24Hz)同步逻辑位置。
- 这保证了滚动条的位置更新和虚拟化加载新内容,同时避免了频繁的布局计算。
渲染循环逻辑
只需遵守一条坐标系变换规则:逻辑位置 = 视觉位置 + 视觉偏差。
当用户滚动时,视觉层将以“插帧”方式在逻辑层低帧率更新之间平滑过渡。
在每一帧的渲染回调中:
- 计算物理模型的当前位置 _currentVisualOffset。
- 计算视觉偏差 _visualDelta = _currentVisualOffset - _logicalOffset。
- 应用 _transform.Y = -_visualDelta,实现视觉上的平滑移动,不会触发布局重置。
- 累加时间,如果超过 ScrollBarUpdateInterval (1/24s),则调用 ScrollToVerticalOffset 同步逻辑位置,触发布局更新,从而允许滚动条同步和虚拟化等功能生效。
二、物理模型设计
v3 版本的物理模型沿用了 v2 的设计,但做了一些改进以提升滚动体验。以下介绍完整的物理模型。
2.1 缓动模型
适用于鼠标滚轮。该模型包含两个核心部分:动态速度因子(决定滚多快)和物理衰减(决定滚多久)。
动态速度因子 (Dynamic Velocity Factor)
在 v2 版本中,我们发现简单的线性速度叠加无法平衡缓慢滚动和快速滚动的体验。因此,v3 引入了一个基于时间间隔的动态速度因子。当用户快速连续滚动时,速度因子会呈指数级增长,从而产生更大的加速度。
vf=(Vmax−Vmin)⋅e^(−Δt/20)+Vmin
- Vmax:最大速度倍率,固定为 2.5。
- Vmin:最小速度倍率 (MinVelocityFactor),默认为 1.2。
- Δt:两次滚动事件的时间间隔 (ms)。
这意味着:如果你慢慢滚动,每次滚动的距离约为原始值的 1.2 倍;如果你疯狂拨动滚轮,这个倍率会迅速逼近 2.5 倍,与真实的物理滚动手感更接近。
物理衰减
模拟物理摩擦力,使滚动速度随时间自然衰减。
- 速度衰减:vnew=vold⋅f^tf
- 位置更新:xnew=xold+vnew⋅(tf/24)
其中:
- f:速率衰减系数,默认为 0.92。数值越小,停得越快。
- tf:时间标准化因子,dt/TargetFrameTime (基准帧率为 144Hz),dt为绘制两帧之间的间隔时间。
- 常数 24 是一个经验值,用于调整速度到位移的映射比例。
2.2 精确模型
适用于触控板。触控板本身提供了高精度的 Delta 值,我们不需要模拟惯性(系统已处理),只需要平滑地过渡到目标位置,避免画面撕裂或抖动。
- 插值计算:xnew=xold+(xtarget−xold)⋅(1−(1−l)^tf)
其中:
- l:插值系数 (LerpFactor),默认为 0.5。数值越大,跟随越紧密。
- tf:时间标准化因子。
三、快速开始
在项目中引入FluentWpfCore包,然后使用:- <Window xmlns:fluent="clr-namespace:FluentWpf.Controls;assembly=FluentWpfCore"
- ...>
- <fluent:SmoothScrollViewer>
-
- <fluent:SmoothScrollViewer.Physics>
- <fluent:DefaultScrollPhysics MinVelocityFactor="1.2" />
- </fluent:SmoothScrollViewer.Physics>
- ...
- </fluent:SmoothScrollViewer>
- </Window>
复制代码 属性类型默认值说明IsEnableSmoothScrollingbooltrue启用或禁用平滑滚动动画(实际上会控制所有SmoothScrolling相关功能)PreferredScrollOrientationOrientationVertical首选滚动方向:Vertical 或 HorizontalAllowTogglePreferredScrollOrientationByShiftKeybooltrue允许通过按住 Shift 键切换滚动方向PhysicsIScrollPhysicsDefaultScrollPhysics控制滚动动画行为的物理模型默认模型(DefaultScrollPhysics)可选参数:
参数类型默认值说明MinVelocityFactordouble1.2鼠标滚轮的最小速度倍率Frictiondouble0.92鼠标滚轮的速度衰减系数LerpFactordouble0.5触控板滚动的插值系数四、了解更多
1) 为什么要“视觉满帧、逻辑低频”
在 WPF 里,ScrollToVerticalOffset/ScrollToHorizontalOffset 不是一个“只改数值”的轻量操作。它往往会驱动:
- 滚动条位置与 ScrollChanged 事件
- 布局与渲染链路(尤其是内容复杂时)
- 虚拟化容器的生成/回收(例如 VirtualizingStackPanel)
v2的实现把它放到 CompositionTarget.Rendering 的每一帧里调用,意味着UI线程必须在每帧都完成布局计算,这在内容复杂或CPU负载高时会直接卡死UI线程,导致掉帧或其他UI组件无响应。
v3 的分层策略是:
- 视觉层:用 TranslateTransform 做位移补偿,只影响渲染,不触发布局。
- 逻辑层:用 ScrollTo*Offset 推进真实偏移,但频率降低到 24Hz。
相当于把“高频的连续运动”交给 GPU(RenderTransform),把“低频但必要的状态推进”交给布局系统(ScrollTo)。或者理解为:视觉层做“动画”,逻辑层做“状态更新”,以低帧率推进布局计算,然后由视觉层平滑过渡,显著提升性能。
2) 关键状态:视觉差值(Visual Delta)
在 v3 里,始终存在两个 offset:
- 逻辑 offset:ScrollViewer 真正的 VerticalOffset/HorizontalOffset,决定滚动条与虚拟化。
- 视觉 offset:物理模型在每帧计算出的“应该看到的位置”。
两者的差值就是视觉补偿量:
Δ= visual offset − logical offset
视觉层每帧做的事情非常纯粹:把这个差值通过 Transform 反向抵消掉,让用户“看到”的内容位置跟随视觉 offset。
- 垂直滚动:Transform.Y = -Δ
- 水平滚动:Transform.X = -Δ
这样带来两个好处:
- 逻辑层什么时候同步(调用 ScrollTo*Offset)可以自由选择频率,不会影响视觉连续性。
- 当逻辑层因为外部原因突变(拖动滚动条、代码调用 ScrollTo...、键盘导航)时,只要立刻重算差值,视觉层就仍然能保持连续。
3) 为什么要用真实 dt(而不是假设固定帧率)
CompositionTarget.Rendering 的触发并不严格等间隔:后台负载、窗口被遮挡、显示器刷新率、系统节能策略都会让帧间隔波动。
因此实现中用 Stopwatch.GetTimestamp() 计算真实 dt,并把 dt 传给物理模型。这意味着:
- 低帧率时不会“走慢动作”或突然“加速冲刺”
- 高刷屏(120/144Hz)不会因为更高帧数而滚得更远
配合 DefaultScrollPhysics 中的 timeFactor = dt / TargetFrameTime,滚动手感可以在不同帧率下保持一致。
4) 关于逻辑同步频率
实现把逻辑同步频率设为 24Hz(ScrollBarUpdateInterval = 1/24s),这是一个折中:
- 频率更高:滚动条更“实时”,虚拟化更及时,但布局压力上升。
- 频率更低:性能更好,但滚动条会有视觉滞后,虚拟化加载可能会出现空白频闪。
一个可能的缓解思路是让虚拟化容器提前加载,会增加一点内存开销,但能减少空白频闪。
一般来说:
- 内容很重(大量图片、复杂控件、阴影/模糊多):可以把同步频率调低一点。
- 列表虚拟化强依赖“及时生成下一屏”(例如聊天列表/文件列表):可以适当提高,但要观察 CPU。
5) 注意 ScrollChanged 状态变更
只靠渲染循环还不够,因为用户可以通过滚动条拖动来改变 offset。实现里在 OnScrollChanged 中做了两件重要的事情:
- 更新逻辑 offset(垂直/水平各自维护)。
- 如果当前正在平滑滚动,并且变化来自“当前激活方向”,就立刻更新 Transform,让画面位置保持连续。
6) 横向滚动与 Shift 切换
v3 支持横向与纵向两种滚动方向,并且提供了按住 Shift 切换滚动方向的特性。
7) 为什么滚动时要临时关闭 HitTest
渲染循环开始时把 IsHitTestVisible 设为 false,结束时恢复。
高速滚动时,鼠标在大量元素上扫过会触发频繁的命中测试和状态变更(Hover、ToolTip、触发器)。 关闭命中测试能够显著降低这些开销,提升滚动性能。
当然,它也意味着滚动过程中无法点击内容。
8) 如何写你自己的物理模型
IScrollPhysics 的接口设计简单:
- OnScroll(...):只负责接收一次输入意图(delta + 是否精确 + 边界 + 时间间隔)。
- Update(...):每帧推进到新位置(帧率无关)。
- IsStable:告诉外部何时可以退出渲染循环。
写在最后
TwilightLemon/FluentWpfCore: A WPF library providing core Fluent Design controls, materials, and visual effects.相关组件均开源在 FluentWpfCore 仓库,欢迎 star 和 PR!仓库保持活跃更新。
感谢阅读,文章如有不妥之处,请各位大佬不吝指正!
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名TwilightLemon,不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |
|
|
|
|
|
相关推荐
|
|
|