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代码,包含了上述所有实现细节。

复制代码
  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,支持滚轮、触控板、触摸屏和笔 - Twlm's Blog

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

相关推荐
碎碎念的安静5 小时前
WPF可拖拽ListView
c#·wpf
界面开发小八哥12 小时前
界面组件DevExpress WPF中文教程:Grid - 如何识别行和卡片?
.net·wpf·界面控件·devexpress·ui开发
Vae_Mars3 天前
WPF中自定义消息弹窗
wpf
Magnum Lehar3 天前
GameEngine游戏引擎前端界面wpf页面实现
前端·游戏引擎·wpf
TA远方4 天前
【C#】一个简单的http服务器项目开发过程详解
服务器·http·c#·wpf·web·winform·console
陈奕昆4 天前
2.1HarmonyOS NEXT开发工具链进阶:DevEco Studio深度实践
华为·wpf·harmonyos
Dr.多喝热水4 天前
WPF prism
windows·wpf
Hare_bai5 天前
WPF响应式UI的基础:INotifyPropertyChanged
ui·c#·wpf·xaml
上元星如雨5 天前
WPF 全局加载界面、多界面实现渐变过渡效果
wpf