之前的文章中用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 }

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

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

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

  1. 【WPF学习】第十八章 多点触控输入

    多点触控(multi-touch)是通过触摸屏幕与应用程序进行交互的一种方式.多点触控输入和更传统的基于笔(pen-based)的输入的区别是多点触控识别手势(gesture)——用户可移动多根手指以 ...

  2. [WPF]为旧版本的应用添加触控支持

    之前做WPF开发时曾经遇到这样一个需求:为一个基于 .NET Framework 3.5开发的老旧WPF程序添加触控支持,以便于大屏触控展示. 接手之后发现这是一个大坑. 项目最初的时候完全没考虑过软 ...

  3. 移动手机端H5无缝间歇平滑向上滚动js代码

    在没结合css3的transform实现平滑过渡前,我都是用的jquery的animate方法,此方法在PC端基本看不出来有稍微卡顿的现象,但是在性能不高的手机上使用该方法,就会有明显的卡顿现象,不够 ...

  4. JS平滑无缝滚动实现———实现首页广告自动滚动效果(附实例)

    本文我们实现纯JS方式的滚动广告效果. 先show一下成品: 首先是网页样式: 1. #demo { 2. background: #FFF; 3. overflow:hidden; 4. borde ...

  5. MSDN 杂志:UI 前沿技术 - WPF 中的多点触控操作事件

    原文  MSDN 杂志:UI 前沿技术 - WPF 中的多点触控操作事件 UI 前沿技术 WPF 中的多点触控操作事件 Charles Petzold 下载代码示例 就在过去几年,多点触控还只是科幻电 ...

  6. WPF进阶技巧和实战09-事件(2-多点触控)

    多点触控输入 多点触控输入和传统的基于比的输入的区别是多点触控识别手势,用户可以移动多根手指以执行常见的操作,放大,旋转,拖动等. 多点触控的输入层次 WPF允许使用键盘和鼠标的高层次输入(例如单击和 ...

  7. WPF之坑——surface触控失灵之谜

    本次又遇到了WPF编写触控程序的一个问题,虽然已解决,但原因确搞不太明白,希望有大神看到这篇文章帮我解答. 在项目中实现了自己定义的icommandsource,因为需要对触控有特殊需求,控件对鼠标与 ...

  8. WPF触控程序开发(三)——类似IPhone相册的反弹效果

    用过IPhone的都知道,IPhone相册里,当图片放大到一定程度后,手指一放,会自动缩回,移动图片超出边框后手指一放,图片也会自动缩回,整个过程非常和谐.自然.精确,那么WPF能否做到呢,答案是肯定 ...

  9. WPF触控程序开发(二)——整理的一些问题

    上一篇(WPF触控程序开发)介绍了几个比较不错的资源,比较基础.等到自己真正使用它们时,问题就来了,现把我遇到的几个问题罗列下,大家如有遇到其他问题或者有什么好的方法还望赐教. 问题1.如何获取触控点 ...

  10. WPF触控程序的开发(一)——有用的资源

    迟来的一篇博文,每次都要撞到月末,这个月实在太忙了,除了在公司上班,还接了个单子,用wpf做一个触屏软件,类似iphone的相册功能.先说搭建开发环境吧,我是不可能去买个平板来的,再说基于win7的程 ...

随机推荐

  1. 【Matlab】判断点和多面体位置关系的两种方法实现

    分别是向量判别法(算法来自他人论文).体积判别法(code 是我从网上找的). 方法一: 向量判别法 方法来自一会议论文:<判断点与多面体空间位置关系的一个新算法_石露>2008年,知网. ...

  2. AI大模型的崛起:从技术突破到行业变革

    在人工智能技术飞速发展的今天,AI大模型作为新一代的智能工具,正逐步渗透到各行各业,引领着数字化转型的新浪潮.前瞻产业研究院发布的一份关于AI大模型场景应用的报告显示,2023年,我国AI大模型行业规 ...

  3. 超级详细的mysql数据库安装指南

    https://zhuanlan.zhihu.com/p/37152572 2,073 人赞同了该文章 如果你的电脑是mac,参考社群会员 @奔跑的土豆 的分享: mac下mysql的安装步骤 227 ...

  4. Windows下安装使用OpenLDAP

    LDAP:(轻量级目录访问协议,Lightweight Directory Access Protocol)它是基于 X.500标准的,但是简单多了并且可以根据需要定制.与X.500不同,LDAP支持 ...

  5. AOT编译Avalonia应用:StarBlog Publisher项目实践与挑战

    前言 最近我使用 Avalonia 开发了一个文章发布工具,StarBlog Publisher. Avalonia 是一个跨平台的 UI 框架,它可以在 Windows.Linux 和 macOS ...

  6. 【Web】Servlet基本概念

    Servlet(Server Applet)是Java Servlet的简称,称为小服务程序或服务连接器,用Java编写的服务器端程序,具有独立于平台和协议的特性,主要功能在于交互式地浏览和生成数据, ...

  7. 流式计算(四)-Flink Stream API 篇二

    个人原创文章,禁止任何形式转载,否则追究法律责任! 本文只发表在"公众号"和"博客园",其他均属复制粘贴!如果觉得排版不清晰,请查看公众号文章. 话说看图看核心 ...

  8. leetcode每日一题:最大或值

    题目 2680. 最大或值 给你一个下标从 0 开始长度为 n 的整数数组 nums 和一个整数 k .每一次操作中,你可以选择一个数并将它乘 2 . 你最多可以进行 k 次操作,请你返回 nums[ ...

  9. 关于TFDMemtable的使用场景【3】处理数据

    原因很多: 1.通过TFDMemtable处理数据时,避免影响数据感知 2.处理速度很快. ------------------------------ 从Tdataset读取数据: Procedur ...

  10. 使用Python解析求解拉普拉斯方程

    引言 大家好!今天我们将探讨一个经典的偏微分方程-拉普拉斯方程,并使用 Python 进行求解.拉普拉斯方程广泛应用于物理学中,尤其是在电磁学.流体力学和热传导等领域.通过这篇文章,你将了解什么是拉普 ...