WPF 使用CompositionTarget.Rendering实现平滑流畅滚动的ScrollViewer,支持滚轮、触控板、触摸屏和笔

之前的文章中用WPF自带的动画库实现了一个简陋的平滑滚动ScrollViewer,它在只使用鼠标滚轮的情况下表现良好,但仍然有明显的设计缺陷和不足:

  1. 没有实现真正的动画衔接,只是单纯结束掉上一个动画,而不是继承其滚动速率;
  2. 使用触控板的体验极差
  3. 对触控屏和笔设备无效

为了解决以上问题,本文提出一种新的方案来实现平滑滚动ScrollViewer。该方案在OnMouseWheelOnManipulationDeltaOnManipulationCompleted中直接处理(禁用)了系统的滚动效果,使用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 缓动滚动模型

类似于鞭挞陀螺使其旋转,每打一次都会给陀螺附加新的加速度,然后在接下来的时间中由于摩擦的存在而缓慢减速。我们基于这个思路来实现简易的缓动滚动模型:

  1. 先定义几个常量:衰减系数f=0.92、叠加速率力度系数n=2.0、目标帧时间frameTime=1.0/144以及行进常量p=24
  2. 每次OnMouseWheel事件触发时,叠加速率:v += e.Delta * n
  3. CompositionTarget.Rendering事件中:
    • 计算刷新间隔时间Δt,以按照间隔时间计算滚动增量
    • t= Δt / frameTime
    • v *= f^t,模拟摩擦力的影响(确保在目标帧时间内衰减幅度一致)
    • offset += v *(t/p),计算滚动增量
  4. 将新的位置应用到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 适配触摸屏和笔(更新:不建议使用)

触摸屏的输入可以通过ManipulationDeltaManipulationCompleted事件来处理。我们将触摸输入映射为滚动偏移量,并使用精确滚动模型,在结束滚动时,可能还有由于快速滑动造成的惯性速率,我们在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 }

三、已知问题

  1. 使用触摸屏时可能会造成闪烁,因为并没有完全禁用系统的滚动实现。但是如果禁用base.OnManipulationDelta(e),则无法触发ManipulationCompleted事件,导致无法处理惯性滚动。
  2. 尚未测试与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,不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

posted @ 2025-06-04 00:47  TwilightLemon  阅读(661)  评论(8)    收藏  举报