WPF 使用CompositionTarget.Rendering实现平滑流畅滚动的ScrollViewer,支持滚轮、触控板、触摸屏和笔
之前的文章中用WPF自带的动画库实现了一个简陋的平滑滚动ScrollViewer,它在只使用鼠标滚轮的情况下表现良好,但仍然有明显的设计缺陷和不足:
- 没有实现真正的动画衔接,只是单纯结束掉上一个动画,而不是继承其滚动速率;
- 使用触控板的体验极差
- 对触控屏和笔设备无效
为了解决以上问题,本文提出一种新的方案来实现平滑滚动ScrollViewer。该方案在OnMouseWheel、OnManipulationDelta和OnManipulationCompleted中直接处理(禁用)了系统的滚动效果,使用CompositionTarget.Rendering事件来驱动滚动动画。并针对滚轮方式和触控“跟手”分别进行优化,使用缓动滚动模型和精确滚动模型来实现平滑滚动。笔的支持得益于EleCho.WpfSuite库提供的StylusTouchDevice模拟,将笔输入映射为触摸输入。
为了最直观和最简单地解决问题,我们将应用场景设置为垂直滚动,水平滚动可以通过类似的方式实现。 在github中查看最小可运行代码:
TwilightLemon/FluentScrollViewer: WPF 实现平滑流畅滚动的ScrollViewer,支持滚轮、触控板、触摸屏和笔
一、一些先验事实和设计思路
1.1 OnMouseWheel的触发逻辑
OnMouseWheel(MouseWheelEventArgs e)事件由WPF触发,e.Delta指示鼠标单次滚动的偏移值,通常为120或-120,这个值可以通过Mouse.MouseWheelDeltaForOneLine获得。这一逻辑在传统鼠标滚轮上顺理成章,但是在精准滚动设备(如触控板)上,滚动偏移量变得非常小,事件在高频率低偏移地触发,导致基于动画触发的滚动体验不佳。
测试发现,在以下两种场景中,OnMouseWheel事件具有特定的行为:
| 设备 | 缓慢滚动 | 快速滚动 |
|---|---|---|
| 鼠标滚轮 | 单个触发、一次一个事件 | 可能多个合并触发,e.Delta 是滚动值的倍数 |
| 触控板 | 持续滚动,间隔触发,e.Delta 值很小 | Delta 快速增长,最后变为很小的值 |
因为触控板、触摸屏等精准滚动的使用场景,设备与人交互,意味着其数据本身就遵循物理规律。但是滚动的速率和距离被离散地传递给WPF,导致了滚动的生硬和不自然。
那么有没有一种思路,我们只需先接收这些滚动数据,然后在每一帧中根据这些数据来计算滚动位置?相当于把离散的滚动数据重新平滑化。
1.2 CompositionTarget.Rendering
CompositionTarget.Rendering是WPF渲染管线的一个事件,它在每一帧渲染之前触发。我们可以利用这个事件来实现自定义的滚动逻辑:先收集滚动参数,然后在OnRender事件中计算实际偏移值,并应用到ScrollViewer上。
1.3 两种场景、两种模型
我们将滚动分为两种场景:滚轮和触控,分别对应缓动滚动模型和精确滚动模型。
1.3.1 缓动滚动模型
类似于鞭挞陀螺使其旋转,每打一次都会给陀螺附加新的加速度,然后在接下来的时间中由于摩擦的存在而缓慢减速。我们基于这个思路来实现简易的缓动滚动模型:
- 先定义几个常量:衰减系数
f=0.92、叠加速率力度系数n=2.0、目标帧时间frameTime=1.0/144以及行进常量p=24 - 每次
OnMouseWheel事件触发时,叠加速率:v += e.Delta * n - 在
CompositionTarget.Rendering事件中:- 计算刷新间隔时间
Δt,以按照间隔时间计算滚动增量 t= Δt / frameTimev *= f^t,模拟摩擦力的影响(确保在目标帧时间内衰减幅度一致)offset += v *(t/p),计算滚动增量
- 计算刷新间隔时间
- 将新的位置应用到ScrollViewer上
调整以上参数会带来相应的变化:
- 衰减系数f越小,滚动越快停下来
- 叠加速率力度系数n越大,每次滚动的速度越快
- 行进常量p越大,滚动越慢
- 通常你无需调整frameTime,它只是用来标准化滚动速度的
1.3.2 精确滚动模型
对于一个指定的滚动距离,我们希望能够精确地滚动到目标位置,而不是依赖于速率和衰减。模型只需要对离散距离补帧即可。具体而言,定义一个插值系数l,指示接近目标位置的速率,则offset=_targetOffset - _currentOffset) *l.
2025/11/08 更新
因为CompositionTarget.Rendering事件的触发频率并不固定,可能会因为系统负载等原因而变化较大,因此在计算滚动增量时需要考虑实际的时间增量。
二、实现
现在我们已经有思路了:先捕获OnMouseWheel等事件->判断使用哪个模型->挂载OnRender事件->在每一帧中计算新的滚动位置->应用到ScrollViewer上。以下实现通过继承ScrollViewer创建新的控件来实现。
2.1 先从鼠标滚轮与触控板开始
从OnMouseWheel中收集数据并判断模型:
1 protected override void OnMouseWheel(MouseWheelEventArgs e) 2 { 3 e.Handled = true; 4 5 //触摸板使用精确滚动模型 6 _isAccuracyControl = IsTouchpadScroll(e); 7 8 if (_isAccuracyControl) 9 { 10 _targetVelocity = 0; // 防止下一次触发缓动模型时继承没有消除的速度,造成滚动异常 11 _targetOffset = Math.Clamp(_currentOffset - e.Delta, 0, ScrollableHeight); 12 } 13 else 14 _targetVelocity += -e.Delta * VelocityFactor;// 鼠标滚动,叠加速度(惯性滚动) 15 16 if (!_isRenderingHooked) 17 { 18 CompositionTarget.Rendering += OnRendering; 19 _isRenderingHooked = true; 20 } 21 }
WPF似乎并没有提供直接判断触发设备的方法,这里使用了一个启发式判断逻辑:判断触发间隔时间和偏移值是否为滚轮偏移值的倍数。这一代码在诺尔大佬的EleCho.WpfSuite中亦有记载。
1 private bool IsTouchpadScroll(MouseWheelEventArgs e) 2 { 3 var tickCount = Environment.TickCount; 4 var isTouchpadScrolling = 5 e.Delta % Mouse.MouseWheelDeltaForOneLine != 0 || 6 (tickCount - _lastScrollingTick < 100 && _lastScrollDelta % Mouse,MouseWheelDeltaForOneLine != 0); 7 _lastScrollDelta = e.Delta; 8 _lastScrollingTick = e.Timestamp; 9 return isTouchpadScrolling; 10 }
2.2 适配触摸屏和笔(更新:不建议使用)
触摸屏的输入可以通过ManipulationDelta和ManipulationCompleted事件来处理。我们将触摸输入映射为滚动偏移量,并使用精确滚动模型,在结束滚动时,可能还有由于快速滑动造成的惯性速率,我们在ManipulationCompleted中交给惯性滚动模型处理。
1 protected override void OnManipulationDelta(ManipulationDeltaEventArgs e) 2 { 3 base.OnManipulationDelta(e); //如果没有这一行则不会触发ManipulationCompleted事件?? 4 e.Handled = true; 5 6 //手还在屏幕上,使用精确滚动 7 _isAccuracyControl = true; 8 double deltaY = -e.DeltaManipulation.Translation.Y; 9 _targetOffset = Math.Clamp(_targetOffset + deltaY, 0, ScrollableHeight); 10 // 记录最后一次速度 11 _lastTouchVelocity = -e.Velocities.LinearVelocity.Y; 12 13 if (!_isRenderingHooked) 14 { 15 CompositionTarget.Rendering += OnRendering; 16 _isRenderingHooked = true; 17 } 18 } 19 20 protected override void OnManipulationCompleted(ManipulationCompletedEventArgs e) 21 { 22 base.OnManipulationCompleted(e); 23 e.Handled = true; 24 Debug.WriteLine("vel: "+ _lastTouchVelocity); 25 _targetVelocity = _lastTouchVelocity; // 用系统识别的速度继续滚动 26 _isAccuracyControl = false; 27 28 if (!_isRenderingHooked) 29 { 30 CompositionTarget.Rendering += OnRendering; 31 _isRenderingHooked = true; 32 } 33 }
适配笔只需要把笔设备映射为触摸设备即可。这里使用了EleCho.WpfSuite库中的StylusTouchDevice来模拟触摸输入,最小可用代码在仓库中给出。
1 public MyScrollViewer() 2 { 3 //... 4 StylusTouchDevice.SetSimulate(this, true); 5 }
2.3 OnRender事件
在CompositionTarget.Rendering事件中,我们根据当前模型计算新的滚动位置,并应用到ScrollViewer上。
1 private void OnRendering(object? sender, EventArgs e) 2 { 3 // 计算时间增量 4 long currentTimestamp = Stopwatch.GetTimestamp(); 5 double deltaTime = (double)(currentTimestamp - _lastTimestamp) / Stopwatch.Frequency; 6 _lastTimestamp = currentTimestamp; 7 8 double timeFactor = deltaTime / TargetFrameTime; 9 10 if (_isAccuracyControl) 11 { 12 // 精确滚动:Lerp 逼近目标(使用时间因子调整) 13 double lerpAmount = 1.0 - Math.Pow(1.0 - LerpFactor, timeFactor); 14 _currentOffset += (_targetOffset - _currentOffset) * lerpAmount; 15 16 // 如果已经接近目标,就停止 17 if (Math.Abs(_targetOffset - _currentOffset) < 0.5) 18 { 19 _currentOffset = _targetOffset; 20 StopRendering(); 21 } 22 } 23 else 24 { 25 // 缓动滚动:速度衰减模拟(使用时间因子调整) 26 if (Math.Abs(_targetVelocity) < 0.1) 27 { 28 _targetVelocity = 0; 29 StopRendering(); 30 return; 31 } 32 33 // 使用时间因子调整摩擦力衰减 34 _targetVelocity *= Math.Pow(Friction, timeFactor); 35 36 // 根据实际时间计算偏移量 37 _currentOffset = Math.Clamp(_currentOffset + _targetVelocity * (timeFactor / 24), 0, ScrollableHeight); 38 } 39 40 InternalScrollToVerticalOffset(_currentOffset); 41 }
2.4 处理来自外部的滚动
我们的模型在计算时独立于ScrollViewer的实际滚动位置,当用户通过直接滑动滚动条或者使用ListBox.ScrollIntoView等方法时,我们需要同步滚动位置。这里采用的方法是使用DependencyPropertyDescriptor监听ScrollViewer.VerticalOffset(只读依赖属性)的变化,并在变化时判断是否更新内部滚动位置。
//注册监听 DependencyPropertyDescriptor .FromProperty(VerticalOffsetProperty, typeof(ScrollViewer)) .AddValueChanged(this, HandleExternalScrollChanged); //Unload中取消注册 DependencyPropertyDescriptor .FromProperty(VerticalOffsetProperty, typeof(ScrollViewer)) .RemoveValueChanged(this, HandleExternalScrollChanged); //... private bool _isInternalScrollChange = false; private void HandleExternalScrollChanged(object? sender, EventArgs e) { if (!_isInternalScrollChange) _currentOffset = VerticalOffset; }
我们使用一个标志位来判断是否是内部滚动导致的变化,以避免循环调用。则在处理模型时需要设置该标志位为true,在滚动结束后再将其重置为false,在OnRender中调用内部滚动方法:
1 private void InternalScrollToVerticalOffset(double offset) 2 { 3 _isInternalScrollChange = true; 4 ScrollToVerticalOffset(offset); 5 _isInternalScrollChange = false; 6 }
三、已知问题
- 使用触摸屏时可能会造成闪烁,因为并没有完全禁用系统的滚动实现。但是如果禁用
base.OnManipulationDelta(e),则无法触发ManipulationCompleted事件,导致无法处理惯性滚动。 - 尚未测试与ListBox等控件的兼容性。
四、完整代码
以下是完整的MyScrollViewer代码,包含了上述所有实现细节。
1 using EleCho.WpfSuite; 2 using System.ComponentModel; 3 using System.Diagnostics; 4 using System.Windows; 5 using System.Windows.Controls; 6 using System.Windows.Input; 7 using System.Windows.Media; 8 9 public class MyScrollViewer : System.Windows.Controls.ScrollViewer 10 { 11 #region 模型参数 12 /// <summary> 13 /// 缓动模型的叠加速度力度,数值越大,滚动起始速率越快,滚得越远 14 /// </summary> 15 private const double VelocityFactor = 2.0; 16 /// <summary> 17 /// 缓动模型的速度衰减系数,数值越小,越快停下来 18 /// </summary> 19 private const double Friction = 0.92; 20 21 /// <summary> 22 /// 精确模型的插值系数,数值越大,滚动越快接近目标 23 /// </summary> 24 private const double LerpFactor = 0.5; 25 26 /// <summary> 27 /// 目标帧时间 28 /// </summary> 29 private const double TargetFrameTime =1.0d/144; 30 #endregion 31 32 public MyScrollViewer() 33 { 34 _currentOffset = VerticalOffset; 35 36 this.PanningMode = PanningMode.VerticalOnly; 37 //使用此触屏滚动会导致闪屏,先不用了.. 38 // this.IsManipulationEnabled = true; 39 // this.PanningDeceleration = 0; // 禁用默认惯性 40 //StylusTouchDevice.SetSimulate(this, true); 41 42 DependencyPropertyDescriptor 43 .FromProperty(VerticalOffsetProperty, typeof(System.Windows.Controls.ScrollViewer)) 44 .AddValueChanged(this, HandleExternalScrollChanged); 45 46 Unloaded += ScrollViewer_Unloaded; 47 } 48 //记录参数 49 private int _lastScrollingTick = 0, _lastScrollDelta = 0; 50 //private double _lastTouchVelocity = 0; 51 private double _currentOffset = 0; 52 private double _targetOffset = 0; 53 private double _targetVelocity = 0; 54 private long _lastTimestamp = 0; 55 //标志位 56 private bool _isRenderingHooked = false; 57 private bool _isAccuracyControl = false; 58 private bool _isInternalScrollChange = false; 59 60 private void ScrollViewer_Unloaded(object sender, RoutedEventArgs e) 61 { 62 DependencyPropertyDescriptor 63 .FromProperty(VerticalOffsetProperty, typeof(System.Windows.Controls.ScrollViewer)) 64 .RemoveValueChanged(this, HandleExternalScrollChanged); 65 66 if (_isRenderingHooked) 67 { 68 CompositionTarget.Rendering -= OnRendering; 69 _isRenderingHooked = false; 70 } 71 } 72 73 /// <summary> 74 /// 处理外部滚动事件,更新当前偏移量 75 /// </summary> 76 /// <param name="sender"></param> 77 /// <param name="e"></param> 78 private void HandleExternalScrollChanged(object? sender, EventArgs e) 79 { 80 if (!_isInternalScrollChange) 81 _currentOffset = VerticalOffset; 82 } 83 84 /* protected override void OnManipulationDelta(ManipulationDeltaEventArgs e) 85 { 86 base.OnManipulationDelta(e); //如果没有这一行则不会触发ManipulationCompleted事件?? 87 e.Handled = true; 88 //手还在屏幕上,使用精确滚动 89 _isAccuracyControl = true; 90 double deltaY = -e.DeltaManipulation.Translation.Y; 91 _targetOffset = Math.Clamp(_currentOffset + deltaY, 0, ScrollableHeight); 92 // 记录最后一次速度 93 _lastTouchVelocity = -e.Velocities.LinearVelocity.Y; 94 95 if (!_isRenderingHooked) 96 { 97 _lastTimestamp = Stopwatch.GetTimestamp(); 98 CompositionTarget.Rendering += OnRendering; 99 _isRenderingHooked = true; 100 } 101 } 102 103 protected override void OnManipulationCompleted(ManipulationCompletedEventArgs e) 104 { 105 base.OnManipulationCompleted(e); 106 e.Handled = true; 107 Debug.WriteLine("vel: " + _lastTouchVelocity); 108 _targetVelocity = _lastTouchVelocity; // 用系统识别的速度继续滚动 109 _isAccuracyControl = false; 110 111 if (!_isRenderingHooked) 112 { 113 _lastTimestamp = Stopwatch.GetTimestamp(); 114 CompositionTarget.Rendering += OnRendering; 115 _isRenderingHooked = true; 116 } 117 }*/ 118 119 /// <summary> 120 /// 判断MouseWheel事件由鼠标触发还是由触控板触发 121 /// </summary> 122 /// <param name="e"></param> 123 /// <returns></returns> 124 private bool IsTouchpadScroll(MouseWheelEventArgs e) 125 { 126 var tickCount = Environment.TickCount; 127 var isTouchpadScrolling = 128 e.Delta % Mouse.MouseWheelDeltaForOneLine != 0 || 129 (tickCount - _lastScrollingTick < 100 && _lastScrollDelta % Mouse.MouseWheelDeltaForOneLine != 0); 130 //Debug.WriteLine(e.Delta + " " + e.Timestamp + " ==>" + isTouchpadScrolling); 131 _lastScrollDelta = e.Delta; 132 _lastScrollingTick = e.Timestamp; 133 return isTouchpadScrolling; 134 } 135 136 protected override void OnMouseWheel(MouseWheelEventArgs e) 137 { 138 e.Handled = true; 139 140 //触摸板使用精确滚动模型 141 _isAccuracyControl = IsTouchpadScroll(e); 142 143 if (_isAccuracyControl) 144 { 145 _targetVelocity = 0; // 防止下一次触发缓动模型时继承没有消除的速度,造成滚动异常 146 _targetOffset = Math.Clamp(_currentOffset - e.Delta, 0, ScrollableHeight); 147 } 148 else 149 _targetVelocity += -e.Delta * VelocityFactor;// 鼠标滚动,叠加速度(惯性滚动) 150 151 if (!_isRenderingHooked) 152 { 153 _lastTimestamp = Stopwatch.GetTimestamp(); 154 CompositionTarget.Rendering += OnRendering; 155 _isRenderingHooked = true; 156 } 157 } 158 159 private void OnRendering(object? sender, EventArgs e) 160 { 161 // 计算时间增量 162 long currentTimestamp = Stopwatch.GetTimestamp(); 163 double deltaTime = (double)(currentTimestamp - _lastTimestamp) / Stopwatch.Frequency; 164 _lastTimestamp = currentTimestamp; 165 166 double timeFactor = deltaTime / TargetFrameTime; 167 168 if (_isAccuracyControl) 169 { 170 // 精确滚动:Lerp 逼近目标(使用时间因子调整) 171 double lerpAmount = 1.0 - Math.Pow(1.0 - LerpFactor, timeFactor); 172 _currentOffset += (_targetOffset - _currentOffset) * lerpAmount; 173 174 // 如果已经接近目标,就停止 175 if (Math.Abs(_targetOffset - _currentOffset) < 0.5) 176 { 177 _currentOffset = _targetOffset; 178 StopRendering(); 179 } 180 } 181 else 182 { 183 // 缓动滚动:速度衰减模拟(使用时间因子调整) 184 if (Math.Abs(_targetVelocity) < 0.1) 185 { 186 _targetVelocity = 0; 187 StopRendering(); 188 return; 189 } 190 191 // 使用时间因子调整摩擦力衰减 192 _targetVelocity *= Math.Pow(Friction, timeFactor); 193 194 // 根据实际时间计算偏移量 195 _currentOffset = Math.Clamp(_currentOffset + _targetVelocity * (timeFactor / 24), 0, ScrollableHeight); 196 } 197 198 InternalScrollToVerticalOffset(_currentOffset); 199 } 200 201 private void InternalScrollToVerticalOffset(double offset) 202 { 203 _isInternalScrollChange = true; 204 ScrollToVerticalOffset(offset); 205 _isInternalScrollChange = false; 206 } 207 208 209 private void StopRendering() 210 { 211 CompositionTarget.Rendering -= OnRendering; 212 _isRenderingHooked = false; 213 } 214 }

本文可能会不定期更新,请关注原文:WPF 使用CompositionTarget.Rendering实现平滑流畅滚动的ScrollViewer,支持滚轮、触控板、触摸屏和笔 - Twlm's Blog
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名TwilightLemon,不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

浙公网安备 33010602011771号