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 }
复制代码

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 namespace FluentScrollViewer;
 10 
 11 public class MyScrollViewer : ScrollViewer
 12 {
 13     /// <summary>
 14     /// 精确滚动模型,指定目标偏移
 15     /// </summary>
 16     private double _targetOffset = 0;
 17     /// <summary>
 18     /// 缓动滚动模型,指定目标速度
 19     /// </summary>
 20     private double _targetVelocity = 0;
 21 
 22     /// <summary>
 23     /// 缓动模型的叠加速度力度
 24     /// </summary>
 25     private const double VelocityFactor = 1.2;
 26     /// <summary>
 27     /// 缓动模型的速度衰减系数,数值越小,滚动越慢
 28     /// </summary>
 29     private const double Friction = 0.96;
 30 
 31     /// <summary>
 32     /// 精确模型的插值系数,数值越大,滚动越快接近目标
 33     /// </summary>
 34     private const double LerpFactor = 0.35;
 35 
 36     public MyScrollViewer()
 37     {
 38         _currentOffset = VerticalOffset;
 39 
 40         this.IsManipulationEnabled = true;
 41         this.PanningMode = PanningMode.VerticalOnly;
 42         this.PanningDeceleration = 0; // 禁用默认惯性
 43 
 44         StylusTouchDevice.SetSimulate(this, true);
 45 
 46         DependencyPropertyDescriptor
 47                 .FromProperty(VerticalOffsetProperty, typeof(ScrollViewer))
 48                 .AddValueChanged(this, HandleExternalScrollChanged);
 49 
 50         Unloaded += ScrollViewer_Unloaded;
 51     }
 52     //记录参数
 53     private int _lastScrollingTick = 0, _lastScrollDelta = 0;
 54     private double _lastTouchVelocity = 0;
 55     private double _currentOffset = 0;
 56     //标志位
 57     private bool _isRenderingHooked = false;
 58     private bool _isAccuracyControl = false;
 59     private bool _isInternalScrollChange = false;
 60 
 61     private void ScrollViewer_Unloaded(object sender, RoutedEventArgs e)
 62     {
 63         DependencyPropertyDescriptor
 64             .FromProperty(VerticalOffsetProperty, typeof(ScrollViewer))
 65             .RemoveValueChanged(this, HandleExternalScrollChanged);
 66 
 67         if (_isRenderingHooked)
 68         {
 69             CompositionTarget.Rendering -= OnRendering;
 70             _isRenderingHooked = false;
 71         }
 72     }
 73 
 74     /// <summary>
 75     /// 处理外部滚动事件,更新当前偏移量
 76     /// </summary>
 77     /// <param name="sender"></param>
 78     /// <param name="e"></param>
 79     private void HandleExternalScrollChanged(object? sender, EventArgs e)
 80     {
 81         if (!_isInternalScrollChange)
 82             _currentOffset = VerticalOffset;
 83     }
 84 
 85     protected override void OnManipulationDelta(ManipulationDeltaEventArgs e)
 86     {
 87         base.OnManipulationDelta(e);    //如果没有这一行则不会触发ManipulationCompleted事件??
 88         e.Handled = true;
 89         //手还在屏幕上,使用精确滚动
 90         _isAccuracyControl = true;
 91         double deltaY = -e.DeltaManipulation.Translation.Y;
 92         _targetOffset = Math.Clamp(_targetOffset + deltaY, 0, ScrollableHeight);
 93         // 记录最后一次速度
 94         _lastTouchVelocity = -e.Velocities.LinearVelocity.Y;
 95 
 96         if (!_isRenderingHooked)
 97         {
 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             CompositionTarget.Rendering += OnRendering;
114             _isRenderingHooked = true;
115         }
116     }
117 
118     /// <summary>
119     /// 判断MouseWheel事件由鼠标触发还是由触控板触发
120     /// </summary>
121     /// <param name="e"></param>
122     /// <returns></returns>
123     private bool IsTouchpadScroll(MouseWheelEventArgs e)
124     {
125         var tickCount = Environment.TickCount;
126         var isTouchpadScrolling =
127                 e.Delta % Mouse.MouseWheelDeltaForOneLine != 0 ||
128                 (tickCount - _lastScrollingTick < 100 && _lastScrollDelta % Mouse.MouseWheelDeltaForOneLine != 0);
129         _lastScrollDelta = e.Delta;
130         _lastScrollingTick = e.Timestamp;
131         return isTouchpadScrolling;
132     }
133 
134     protected override void OnMouseWheel(MouseWheelEventArgs e)
135     {
136         e.Handled = true;
137 
138         //触摸板使用精确滚动模型
139         _isAccuracyControl = IsTouchpadScroll(e);
140 
141         if (_isAccuracyControl)
142             _targetOffset = Math.Clamp(_currentOffset - e.Delta, 0, ScrollableHeight);
143         else
144             _targetVelocity += -e.Delta * VelocityFactor;// 鼠标滚动,叠加速度(惯性滚动)
145 
146         if (!_isRenderingHooked)
147         {
148             CompositionTarget.Rendering += OnRendering;
149             _isRenderingHooked = true;
150         }
151     }
152 
153     private void OnRendering(object? sender, EventArgs e)
154     {
155         if (_isAccuracyControl)
156         {
157             // 精确滚动:Lerp 逼近目标
158             _currentOffset += (_targetOffset - _currentOffset) * LerpFactor;
159 
160             // 如果已经接近目标,就停止
161             if (Math.Abs(_targetOffset - _currentOffset) < 0.5)
162             {
163                 _currentOffset = _targetOffset;
164                 StopRendering();
165             }
166         }
167         else
168         {
169             // 缓动滚动:速度衰减模拟
170             if (Math.Abs(_targetVelocity) < 0.1)
171             {
172                 _targetVelocity = 0;
173                 StopRendering();
174                 return;
175             }
176 
177             _targetVelocity *= Friction;
178             _currentOffset = Math.Clamp(_currentOffset + _targetVelocity * (1.0 / 60), 0, ScrollableHeight);
179         }
180 
181         InternalScrollToVerticalOffset(_currentOffset);
182     }
183 
184     private void InternalScrollToVerticalOffset(double offset)
185     {
186         _isInternalScrollChange = true;
187         ScrollToVerticalOffset(offset);
188         _isInternalScrollChange = false;
189     }
190 
191     private void StopRendering()
192     {
193         CompositionTarget.Rendering -= OnRendering;
194         _isRenderingHooked = false;
195     }
196 }
View Code

 

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

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

原创作者: TwilightLemon 转载于: https://www.cnblogs.com/TwilightLemon/p/18909374
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值