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. 先定义几个参数:速率v、衰减系数f、叠加速率力度系数n,假设刷新率是60Hz,则每帧的时间间隔deltaTime = 1/60s(因为只是模拟数据,实际上并不会影响滚动的流畅度)
  2. 每次OnMouseWheel事件触发时,计算新的速率:v += e.Delta * n
  3. 更新速率:v *= f,模拟摩擦力的影响
  4. CompositionTarget.Rendering事件中,计算新的位置:offset += v * deltaTime
  5. 将新的位置应用到ScrollViewer上

1.3.2 精确滚动模型

对于一个指定的滚动距离,我们希望能够精确地滚动到目标位置,而不是依赖于速率和衰减。模型只需要对离散距离补帧即可。具体而言,定义一个插值系数l,指示接近目标位置的速率,则offset=_targetOffset - _currentOffset) *l.

二、实现

现在我们已经有思路了:先捕获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          _targetOffset = Math.Clamp(_currentOffset - e.Delta, 0, ScrollableHeight); 10      else 11          _targetVelocity += -e.Delta * VelocityFactor;// 鼠标滚动,叠加速度(惯性滚动) 12  13      if (!_isRenderingHooked) 14      { 15          CompositionTarget.Rendering += OnRendering; 16          _isRenderingHooked = true; 17      } 18  }

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     if (_isAccuracyControl)  4     {  5         // 精确滚动:Lerp 逼近目标  6         _currentOffset += (_targetOffset - _currentOffset) * LerpFactor;  7   8         // 如果已经接近目标,就停止  9         if (Math.Abs(_targetOffset - _currentOffset) < 0.5) 10         { 11             _currentOffset = _targetOffset; 12             StopRendering(); 13         } 14     } 15     else 16     { 17         // 缓动滚动:速度衰减模拟 18         if (Math.Abs(_targetVelocity) < 0.1) 19         { 20             _targetVelocity = 0; 21             StopRendering(); 22             return; 23         } 24  25         _targetVelocity *= Friction; 26         _currentOffset = Math.Clamp(_currentOffset + _targetVelocity * (1.0 / 60), 0, ScrollableHeight); 27     } 28  29     ScrollToVerticalOffset(_currentOffset); 30 } 31  32 private void StopRendering() 33 { 34     CompositionTarget.Rendering -= OnRendering; 35     _isRenderingHooked = false; 36 }

三、已知问题

  1. 使用触摸屏时可能会造成闪烁,因为并没有完全禁用系统的滚动实现。如果禁用base.OnManipulationDelta(e),则无法触发ManipulationCompleted事件,导致无法处理惯性滚动。
  2. 尚未测试与ListBox等控件的兼容性。

四、完整代码

以下是完整的MyScrollViewer代码,包含了上述所有实现细节。

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

  1 using EleCho.WpfSuite;   2 using System.Diagnostics;   3 using System.Windows.Controls;   4 using System.Windows.Input;   5 using System.Windows.Media;   6    7 namespace FluentScrollViewer;   8    9 public class MyScrollViewer : ScrollViewer  10 {  11     /// <summary>  12     /// 精确滚动模型,指定目标偏移  13     /// </summary>  14     private double _targetOffset = 0;  15     /// <summary>  16     /// 缓动滚动模型,指定目标速度  17     /// </summary>  18     private double _targetVelocity = 0;  19   20     /// <summary>  21     /// 缓动模型的叠加速度力度  22     /// </summary>  23     private const double VelocityFactor = 1.2;  24     /// <summary>  25     /// 缓动模型的速度衰减系数,数值越小,滚动越慢  26     /// </summary>  27     private const double Friction = 0.96;  28   29     /// <summary>  30     /// 精确模型的插值系数,数值越大,滚动越快接近目标  31     /// </summary>  32     private const double LerpFactor = 0.35;  33   34     public MyScrollViewer()  35     {  36         _currentOffset = VerticalOffset;  37   38         this.IsManipulationEnabled = true;  39         this.PanningMode = PanningMode.VerticalOnly;  40         this.PanningDeceleration = 0; // 禁用默认惯性  41   42         StylusTouchDevice.SetSimulate(this, true);  43     }  44     //记录参数  45     private int _lastScrollingTick = 0, _lastScrollDelta = 0;  46     private double _lastTouchVelocity = 0;  47     private double _currentOffset = 0;  48     //标志位  49     private bool _isRenderingHooked = false;  50     private bool _isAccuracyControl = false;  51     protected override void OnManipulationDelta(ManipulationDeltaEventArgs e)  52     {  53         base.OnManipulationDelta(e);    //如果没有这一行则不会触发ManipulationCompleted事件??  54         e.Handled = true;  55         //手还在屏幕上,使用精确滚动  56         _isAccuracyControl = true;  57         double deltaY = -e.DeltaManipulation.Translation.Y;  58         _targetOffset = Math.Clamp(_targetOffset + deltaY, 0, ScrollableHeight);  59         // 记录最后一次速度  60         _lastTouchVelocity = -e.Velocities.LinearVelocity.Y;  61   62         if (!_isRenderingHooked)  63         {  64             CompositionTarget.Rendering += OnRendering;  65             _isRenderingHooked = true;  66         }  67     }  68   69     protected override void OnManipulationCompleted(ManipulationCompletedEventArgs e)  70     {  71         base.OnManipulationCompleted(e);  72         e.Handled = true;  73         Debug.WriteLine("vel: "+ _lastTouchVelocity);  74         _targetVelocity = _lastTouchVelocity; // 用系统识别的速度继续滚动  75         _isAccuracyControl = false;  76   77         if (!_isRenderingHooked)  78         {  79             CompositionTarget.Rendering += OnRendering;  80             _isRenderingHooked = true;  81         }  82     }  83   84     /// <summary>  85     /// 判断MouseWheel事件由鼠标触发还是由触控板触发  86     /// </summary>  87     /// <param name="e"></param>  88     /// <returns></returns>  89     private bool IsTouchpadScroll(MouseWheelEventArgs e)  90     {  91         var tickCount = Environment.TickCount;  92         var isTouchpadScrolling =  93                 e.Delta % Mouse.MouseWheelDeltaForOneLine != 0 ||  94                 (tickCount - _lastScrollingTick < 100 && _lastScrollDelta % Mouse.MouseWheelDeltaForOneLine != 0);  95         _lastScrollDelta = e.Delta;  96         _lastScrollingTick = e.Timestamp;  97         return isTouchpadScrolling;  98     }  99  100     protected override void OnMouseWheel(MouseWheelEventArgs e) 101     { 102         e.Handled = true; 103  104         //触摸板使用精确滚动模型 105         _isAccuracyControl = IsTouchpadScroll(e); 106  107         if (_isAccuracyControl) 108             _targetOffset = Math.Clamp(_currentOffset - e.Delta, 0, ScrollableHeight); 109         else 110             _targetVelocity += -e.Delta * VelocityFactor;// 鼠标滚动,叠加速度(惯性滚动) 111  112         if (!_isRenderingHooked) 113         { 114             CompositionTarget.Rendering += OnRendering; 115             _isRenderingHooked = true; 116         } 117     } 118  119     private void OnRendering(object? sender, EventArgs e) 120     { 121         if (_isAccuracyControl) 122         { 123             // 精确滚动:Lerp 逼近目标 124             _currentOffset += (_targetOffset - _currentOffset) * LerpFactor; 125  126             // 如果已经接近目标,就停止 127             if (Math.Abs(_targetOffset - _currentOffset) < 0.5) 128             { 129                 _currentOffset = _targetOffset; 130                 StopRendering(); 131             } 132         } 133         else 134         { 135             // 缓动滚动:速度衰减模拟 136             if (Math.Abs(_targetVelocity) < 0.1) 137             { 138                 _targetVelocity = 0; 139                 StopRendering(); 140                 return; 141             } 142  143             _targetVelocity *= Friction; 144             _currentOffset = Math.Clamp(_currentOffset + _targetVelocity * (1.0 / 60), 0, ScrollableHeight); 145         } 146  147         ScrollToVerticalOffset(_currentOffset); 148     } 149  150     private void StopRendering() 151     { 152         CompositionTarget.Rendering -= OnRendering; 153         _isRenderingHooked = false; 154     } 155 }

View Code

 

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

  本文可能会不定期更新,请关注原文:WPF 使用CompositionTarget.Rendering实现平滑流畅滚动的ScrollViewer,支持滚轮、触控板、触摸屏和笔 - Twlm's Blog

  本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名TwilightLemon,不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

发表评论

评论已关闭。

相关文章