在之前的两篇文章中,我们探讨了 WPF 中实现平滑滚动的不同方案:
- WPF 如何流畅地滚动ScrollViewer 简单实现下:基于
DoubleAnimation的动画方案。 - WPF 使用CompositionTarget.Rendering实现平滑流畅滚动的ScrollViewer:基于
CompositionTarget.Rendering的每帧布局更新方案。
虽然第二版方案解决了触控板和物理惯性的问题,但它引入了一个新的性能瓶颈:每帧调用 ScrollToVerticalOffset。这会导致 WPF 在每一帧都进行布局计算,在高负载场景下会直接卡死整个UI线程,造成掉帧或其他UI组件无响应。
为了解决这个问题,我进行了第三版(v3)设计,核心思路是:视觉层与逻辑层分离。
三种方案对比 方案 实现方式 优点 缺点 v1 (动画版)DoubleAnimation 驱动 VerticalOffset 实现简单,代码量少 无法保留惯性速度(动画打断);触控板体验差;不支持触摸/笔。 v2 (布局驱动) Rendering 事件每帧调用 ScrollToVerticalOffset 物理模型更真实;支持多种输入设备 性能差:每帧触发 Layout Pass,高负载下掉帧严重。 v3 (视觉分离) RenderTransform 驱动视觉,低频同步逻辑位置 高性能:视觉满帧运行,逻辑低频更新;物理模型完善。 实现相对复杂,需要处理坐标系转换和帧同步。
接下来,我们详细介绍 v3 版本的设计与实现原理。
一、视觉与逻辑分离v3 的核心在于将“用户看到的滚动”(视觉层)和“控件实际的滚动”(逻辑层)分离。
-
视觉层:
- 使用
TranslateTransform对Content进行位移。 - 在
CompositionTarget.Rendering中以屏幕刷新率(如 60Hz 或 144Hz)更新Transform.Y。 - 因为
RenderTransform只影响渲染而不触发布局(Measure/Arrange),所以性能极高,完全由 GPU 加速。
- 使用
-
逻辑层:
- 维护实际的
ScrollViewer.VerticalOffset。 - 降频更新:不再每帧调用
ScrollToVerticalOffset,而是以较低的频率(如 24Hz)同步逻辑位置。 - 这保证了滚动条的位置更新和虚拟化加载新内容,同时避免了频繁的布局计算。
- 维护实际的
只需遵守一条坐标系变换规则:逻辑位置 = 视觉位置 + 视觉偏差。
当用户滚动时,视觉层将以“插帧”方式在逻辑层低帧率更新之间平滑过渡。
在每一帧的渲染回调中:
- 计算物理模型的当前位置
_currentVisualOffset。 - 计算视觉偏差
_visualDelta = _currentVisualOffset - _logicalOffset。 - 应用
_transform.Y = -_visualDelta,实现视觉上的平滑移动,不会触发布局重置。 - 累加时间,如果超过
ScrollBarUpdateInterval(1/24s),则调用ScrollToVerticalOffset同步逻辑位置,触发布局更新,从而允许滚动条同步和虚拟化等功能生效。
v3 版本的物理模型沿用了 v2 的设计,但做了一些改进以提升滚动体验。以下介绍完整的物理模型。
2.1 缓动模型适用于鼠标滚轮。该模型包含两个核心部分:动态速度因子(决定滚多快)和物理衰减(决定滚多久)。
动态速度因子 (Dynamic Velocity Factor)在 v2 版本中,我们发现简单的线性速度叠加无法平衡缓慢滚动和快速滚动的体验。因此,v3 引入了一个基于时间间隔的动态速度因子。当用户快速连续滚动时,速度因子会呈指数级增长,从而产生更大的加速度。
$$ v_{factor} = (V_{max} - V_{min}) \cdot e^{-\frac{\Delta t}{20}} + V_{min} $$
- $V_{max}$:最大速度倍率,固定为 2.5。
- $V_{min}$:最小速度倍率 (
MinVelocityFactor),默认为 1.2。 - $\Delta t$:两次滚动事件的时间间隔 (ms)。
这意味着:如果你慢慢滚动,每次滚动的距离约为原始值的 1.2 倍;如果你疯狂拨动滚轮,这个倍率会迅速逼近 2.5 倍,与真实的物理滚动手感更接近。
物理衰减模拟物理摩擦力,使滚动速度随时间自然衰减。
- 速度衰减:$v_{new} = v_{old} \cdot f^{t_{factor}}$
- 位置更新:$x_{new} = x_{old} + v_{new} \cdot \frac{t_{factor}}{24}$
其中:
- $f$:速率衰减系数,默认为 0.92。数值越小,停得越快。
- $t_{factor}$:时间标准化因子,$\frac{dt}{TargetFrameTime}$ (基准帧率为 144Hz)。
- 常数 24 是一个经验值,用于调整速度到位移的映射比例。
适用于触控板。触控板本身提供了高精度的 Delta 值,我们不需要模拟惯性(系统已处理),只需要平滑地过渡到目标位置,避免画面撕裂或抖动。
- 插值计算:$x_{new} = x_{old} + (x_{target} - x_{old}) \cdot (1 - (1 - l)^{t_{factor}})$
其中:
- $l$:插值系数 (
LerpFactor),默认为 0.5。数值越大,跟随越紧密。 - $t_{factor}$:时间标准化因子,$\frac{dt}{TargetFrameTime}$。
在项目中引入FluentWpfCore包,然后使用:
<Window xmlns:fluent="clr-namespace:FluentWpf.Controls;assembly=FluentWpfCore"
...>
<fluent:SmoothScrollViewer>
<!--可选 自定义模型及其参数-->
<fluent:SmoothScrollViewer.Physics>
<fluent:DefaultScrollPhysics MinVelocityFactor="1.2" />
</fluent:SmoothScrollViewer.Physics>
...
</fluent:SmoothScrollViewer>
</Window>
属性 类型 默认值 说明 IsEnableSmoothScrolling bool true 启用或禁用平滑滚动动画(实际上会控制所有SmoothScrolling相关功能) PreferredScrollOrientation Orientation Vertical 首选滚动方向:Vertical 或 Horizontal AllowTogglePreferredScrollOrientationByShiftKey bool true 允许通过按住 Shift 键切换滚动方向 Physics IScrollPhysics DefaultScrollPhysics 控制滚动动画行为的物理模型
默认模型(DefaultScrollPhysics)可选参数:
MinVelocityFactor double 1.2 鼠标滚轮的最小速度倍率 Friction double 0.92 鼠标滚轮的速度衰减系数 LerpFactor double 0.5 触控板滚动的插值系数 四、了解更多 1) 为什么要“视觉满帧、逻辑低频”
在 WPF 里,ScrollToVerticalOffset/ScrollToHorizontalOffset 不是一个“只改数值”的轻量操作。它往往会驱动:
- 滚动条位置与
ScrollChanged事件 - 布局与渲染链路(尤其是内容复杂时)
- 虚拟化容器的生成/回收(例如
VirtualizingStackPanel)
v2的实现把它放到 CompositionTarget.Rendering 的每一帧里调用,意味着UI线程必须在每帧都完成布局计算,这在内容复杂或CPU负载高时会直接卡死UI线程,导致掉帧或其他UI组件无响应。
v3 的分层策略是:
- 视觉层:用
TranslateTransform做位移补偿,只影响渲染,不触发布局。 - 逻辑层:用
ScrollTo*Offset推进真实偏移,但频率降低到 24Hz。
相当于把“高频的连续运动”交给 GPU(RenderTransform),把“低频但必要的状态推进”交给布局系统(ScrollTo)。或者理解为:视觉层做“动画”,逻辑层做“状态更新”,以低帧率推进布局计算,然后由视觉层平滑过渡,显著提升性能。
2) 关键状态:视觉差值(Visual Delta)在 v3 里,始终存在两个 offset:
- 逻辑 offset:
ScrollViewer真正的VerticalOffset/HorizontalOffset,决定滚动条与虚拟化。 - 视觉 offset:物理模型在每帧计算出的“应该看到的位置”。
两者的差值就是视觉补偿量:
$$\Delta = offset_{visual} - offset_{logical}$$
视觉层每帧做的事情非常纯粹:把这个差值通过 Transform 反向抵消掉,让用户“看到”的内容位置跟随视觉 offset。
- 垂直滚动:
Transform.Y = -$\Delta$ - 水平滚动:
Transform.X = -$\Delta$
这样带来两个好处:
- 逻辑层什么时候同步(调用
ScrollTo*Offset)可以自由选择频率,不会影响视觉连续性。 - 当逻辑层因为外部原因突变(拖动滚动条、代码调用
ScrollTo...、键盘导航)时,只要立刻重算差值,视觉层就仍然能保持连续。
dt(而不是假设固定帧率)
CompositionTarget.Rendering 的触发并不严格等间隔:后台负载、窗口被遮挡、显示器刷新率、系统节能策略都会让帧间隔波动。
因此实现中用 Stopwatch.GetTimestamp() 计算真实 dt,并把 dt 传给物理模型。这意味着:
- 低帧率时不会“走慢动作”或突然“加速冲刺”
- 高刷屏(120/144Hz)不会因为更高帧数而滚得更远
配合 DefaultScrollPhysics 中的 timeFactor = dt / TargetFrameTime,滚动手感可以在不同帧率下保持一致。
实现把逻辑同步频率设为 24Hz(ScrollBarUpdateInterval = 1/24s),这是一个折中:
- 频率更高:滚动条更“实时”,虚拟化更及时,但布局压力上升。
- 频率更低:性能更好,但滚动条会有视觉滞后,虚拟化加载可能会出现空白频闪。
一个可能的缓解思路是让虚拟化容器提前加载,会增加一点内存开销,但能减少空白频闪。
一般来说:
- 内容很重(大量图片、复杂控件、阴影/模糊多):可以把同步频率调低一点。
- 列表虚拟化强依赖“及时生成下一屏”(例如聊天列表/文件列表):可以适当提高,但要观察 CPU。
只靠渲染循环还不够,因为用户可以通过滚动条拖动来改变 offset。实现里在 OnScrollChanged 中做了两件重要的事情:
- 更新逻辑 offset(垂直/水平各自维护)。
- 如果当前正在平滑滚动,并且变化来自“当前激活方向”,就立刻更新 Transform,让画面位置保持连续。
v3 支持横向与纵向两种滚动方向,并且提供了按住 Shift 切换滚动方向的特性。
7) 为什么滚动时要临时关闭 HitTest渲染循环开始时把 IsHitTestVisible 设为 false,结束时恢复。
高速滚动时,鼠标在大量元素上扫过会触发频繁的命中测试和状态变更(Hover、ToolTip、触发器)。 关闭命中测试能够显著降低这些开销,提升滚动性能。
当然,它也意味着滚动过程中无法点击内容。
8) 如何写你自己的物理模型IScrollPhysics 的接口设计刻意简单:
OnScroll(...):只负责接收一次输入意图(delta + 是否精确 + 边界 + 时间间隔)。Update(...):每帧推进到新位置(帧率无关)。IsStable:告诉外部何时可以退出渲染循环。
::github{repo="TwilightLemon/FluentWpfCore"} 相关组件均开源在 FluentWpfCore 仓库,欢迎 star 和 PR!仓库保持活跃更新。
感谢阅读,文章如有不妥之处,请各位大佬不吝指正!
网站启用星度主题,页面做了很大的改变,基本已调整完毕。
书接上回,我们的Fluent WPF的版图已经完成了:
- Fluent Window: WPF 模拟UWP原生窗口样式——亚克力|云母材质、自定义标题栏样式、原生DWM动画 (附我封装好的类)
- Fluent Popup & ToolTip: WPF中为Popup和ToolTip使用WindowMaterial特效 win10/win11
- Fluent ScrollViewer: WPF 使用CompositionTarget.Rendering实现平滑流畅滚动的ScrollViewer,支持滚轮、触控板、触摸屏和笔
先来看看效果图(win11):
有以下xaml代码:
<TextBlock.ContextMenu>
<ContextMenu>
<MenuItem Header="Menu Item 1" Icon="Cd" InputGestureText="aa?" />
<MenuItem Header="Menu Item 2" >
<MenuItem Header="Child Item 1" Icon="Ab" />
<MenuItem Header="Child Item 2" Icon="Ad" />
<MenuItem Header="Child Item 3" IsCheckable="True" IsChecked="True"/>
</MenuItem>
<MenuItem Header="Menu Item 3" Icon="cd" />
</ContextMenu>
</TextBlock.ContextMenu>
(由于我的win10虚拟机坏了,暂时没有测试)
前面的工作已经解决了让任意窗口支持Acrylic材质的问题,本文重点介绍ContentMenu和MenuItem的样式适配和实现。
本文的Demo:
::github{repo="TwilightLemon/WindowEffectTest"}
如果你直接给ContextMenu应用WindowMaterial特效,可能会出现以下丑陋的效果:
或者:
原因在于古老的ContextMenu和MenuItem样式并不能通过简单修改Background实现我们想要的布局和交互效果。
ContextMenu直观上看是一个Popup,但它的控件模板并不包含Popup,而是直接指定内部元素(包含一个ScrollViewer和StackPanel)。因此不能从控件模板中替换Popup为自定义的FluentPopup,需要使用其他手段让其内部Popup也支持Acrylic材质(见下文)。
在示例代码仓库中展示了较为完整的Menu相关的结构:
MenuItem根据其在菜单树中的位置,通过Role属性分为四种形态。我们在Style.Triggers中分别为它们指定了不同的模板:
- TopLevelHeader: 顶级菜单项,且包含子菜单
Popup(例如菜单栏上的"File")。 - TopLevelItem: 顶级菜单项,不含子菜单(例如菜单栏上的"Help")。
- SubmenuHeader: 子菜单项,且包含下一级子菜单
Popup。 - SubmenuItem: 子菜单项,不含子菜单(叶子节点)。其模板主要处理图标、文字、快捷键的布局。
ContextMenu的样式主要参考了.NET 9自带的Fluent样式,并作了一些调整以适配Acrylic材质。主要目的是覆盖原始模板的Icon部分白框和分割线: 因为我们的WindowMaterial已经为窗口自动附加上圆角、阴影和亚克力材质的DWM效果,所以我们只需要将ContextMenu的背景设置为透明,并移除不必要的边框和分割线即可。
此外,还添加了弹出动画(是在Popup内部做的,并非对window,效果可能不会很理想)。
MenuItem的样式主要处理了图标、文字、快捷键的布局,并根据不同的角色(TopLevelHeader、TopLevelItem、SubmenuHeader、SubmenuItem)应用不同的模板。
- TopLevelHeader: 只包含Icon和Header,以及弹出的Popup,只需要替换为自定义的FluentPopup即可。
- TopLevelItem: 只包含Icon和Header,无Popup。
- SubmenuHeader: 包含Icon、Header和Chevron图标(这里就是一个展开的箭头图标,但是官方叫做雪佛龙..?),以及弹出的Popup,同样替换为FluentPopup。
- SubmenuItem: 叶子节点,包含Icon、Header和InputGestureText。 其模板主要处理图标、文字、快捷键的布局。
菜单项主要分为三个部分:Icon图标、Header文字和最右侧的提示文字或展开箭头图标。只需要保持三个部分的布局对其即可。如果IsCheckable为True,则Icon部分被自定义图标占据(√, 当IsChecked为True时)。
以下是完整的资源字典,包含了完整的注释。
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WindowEffectTest"
xmlns:sys="clr-namespace:System;assembly=mscorlib">
<!-- 菜单边框内边距 -->
<Thickness x:Key="MenuBorderPadding">0,3,0,3</Thickness>
<!-- 菜单项外边距 -->
<Thickness x:Key="MenuItemMargin">4,1</Thickness>
<!-- 菜单项内容内边距 -->
<Thickness x:Key="MenuItemContentPadding">10,6</Thickness>
<!-- 顶级菜单项外边距 -->
<Thickness x:Key="TopLevelItemMargin">4</Thickness>
<!-- 顶级菜单项内容边距 -->
<Thickness x:Key="TopLevelContentMargin">10</Thickness>
<!-- 菜单圆角半径 -->
<CornerRadius x:Key="MenuCornerRadius">4</CornerRadius>
<!-- 顶级菜单圆角半径 -->
<CornerRadius x:Key="TopLevelCornerRadius">6</CornerRadius>
<!-- 菜单动画持续时间 -->
<Duration x:Key="MenuAnimationDuration">0:0:0.167</Duration>
<!-- 复选标记图标路径数据 -->
<PathGeometry x:Key="CheckGraph">
M392.533333 806.4L85.333333 503.466667l59.733334-59.733334 247.466666 247.466667L866.133333 213.333333l59.733334 59.733334L392.533333 806.4z
</PathGeometry>
<!-- 前进箭头图标路径数据(用于子菜单指示器) -->
<PathGeometry x:Key="ForwardGraph">
M283.648 174.081l57.225-59.008 399.479 396.929-399.476 396.924-57.228-59.004 335.872-337.92z
</PathGeometry>
<!-- 菜单项ScrollViewer样式 -->
<Style
x:Key="MenuItemScrollViewerStyle"
BasedOn="{StaticResource {x:Type ScrollViewer}}"
TargetType="{x:Type ScrollViewer}">
<Setter Property="HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="VerticalScrollBarVisibility" Value="Auto" />
</Style>
<!-- 默认集合焦点视觉样式 得到键盘焦点时显示 -->
<Style x:Key="DefaultCollectionFocusVisualStyle">
<Setter Property="Control.Template">
<Setter.Value>
<ControlTemplate>
<Rectangle
Margin="4,0"
RadiusX="4"
RadiusY="4"
SnapsToDevicePixels="True"
Stroke="{DynamicResource AccentColor}"
StrokeThickness="2" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 默认上下文菜单样式 -->
<Style x:Key="DefaultContextMenuStyle" TargetType="{x:Type ContextMenu}">
<Setter Property="MinWidth" Value="140" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HasDropShadow" Value="False" />
<Setter Property="Grid.IsSharedSizeScope" Value="True" />
<Setter Property="Popup.PopupAnimation" Value="None" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="OverridesDefaultStyle" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ContextMenu}">
<Border
x:Name="Border"
Padding="{StaticResource MenuBorderPadding}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<!-- 用于动画的转换变换 -->
<Border.RenderTransform>
<TranslateTransform />
</Border.RenderTransform>
<ScrollViewer CanContentScroll="True" Style="{StaticResource MenuItemScrollViewerStyle}">
<!-- 菜单项容器 -->
<StackPanel
ClipToBounds="True"
IsItemsHost="True"
KeyboardNavigation.DirectionalNavigation="Cycle"
Orientation="Vertical" />
</ScrollViewer>
</Border>
<ControlTemplate.Triggers>
<!-- 菜单打开时的动画效果 -->
<Trigger Property="IsOpen" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<!-- Y轴平移动画:从-45向下滑入到0 -->
<DoubleAnimation
Storyboard.TargetName="Border"
Storyboard.TargetProperty="(Border.RenderTransform).(TranslateTransform.Y)"
From="-45"
To="0"
Duration="{StaticResource MenuAnimationDuration}">
<DoubleAnimation.EasingFunction>
<CircleEase EasingMode="EaseOut" />
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 顶级菜单项头部模板(带子菜单) -->
<ControlTemplate x:Key="{x:Static MenuItem.TopLevelHeaderTemplateKey}" TargetType="{x:Type MenuItem}">
<Border
x:Name="Border"
Margin="{StaticResource TopLevelItemMargin}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource TopLevelCornerRadius}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- 菜单项内容区域 -->
<Grid Margin="{StaticResource TopLevelContentMargin}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- 菜单项图标 -->
<ContentPresenter
x:Name="Icon"
Grid.Column="0"
Margin="0,0,6,0"
VerticalAlignment="Center"
Content="{TemplateBinding Icon}"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
<!-- 菜单项标题 -->
<ContentPresenter
x:Name="HeaderPresenter"
Grid.Column="1"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"
ContentSource="Header"
RecognizesAccessKey="True"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
TextElement.Foreground="{TemplateBinding Foreground}" />
</Grid>
<!-- 子菜单弹出窗口(使用自定义FluentPopup) -->
<local:FluentPopup
x:Name="PART_Popup"
Grid.Row="1"
Grid.Column="0"
Focusable="False"
HorizontalOffset="-12"
IsOpen="{TemplateBinding IsSubmenuOpen}"
Placement="Bottom"
PlacementTarget="{Binding ElementName=Border}"
PopupAnimation="None"
VerticalOffset="1">
<Grid
x:Name="SubmenuBorder"
Background="{DynamicResource PopupWindowBackground}"
SnapsToDevicePixels="True">
<!-- 子菜单动画变换 -->
<Grid.RenderTransform>
<TranslateTransform />
</Grid.RenderTransform>
<ScrollViewer
Padding="{StaticResource MenuBorderPadding}"
CanContentScroll="True"
Style="{StaticResource MenuItemScrollViewerStyle}">
<Grid>
<!-- 子菜单项呈现器 -->
<ItemsPresenter
x:Name="ItemsPresenter"
Grid.IsSharedSizeScope="True"
KeyboardNavigation.DirectionalNavigation="Cycle"
KeyboardNavigation.TabNavigation="Cycle"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
</Grid>
</ScrollViewer>
</Grid>
</local:FluentPopup>
</Grid>
</Border>
<ControlTemplate.Triggers>
<!-- 无图标时隐藏图标区域 -->
<Trigger Property="Icon" Value="{x:Null}">
<Setter TargetName="Icon" Property="Visibility" Value="Collapsed" />
</Trigger>
<!-- 无标题时隐藏标题并移除图标边距 -->
<Trigger Property="Header" Value="{x:Null}">
<Setter TargetName="Icon" Property="Margin" Value="0" />
<Setter TargetName="HeaderPresenter" Property="Visibility" Value="Collapsed" />
</Trigger>
<!-- 鼠标悬停高亮效果 -->
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Border" Property="Background" Value="{DynamicResource MaskColor}" />
</Trigger>
<!-- 子菜单打开时的动画 -->
<Trigger Property="IsSubmenuOpen" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="SubmenuBorder"
Storyboard.TargetProperty="(Border.RenderTransform).(TranslateTransform.Y)"
From="-45"
To="0"
Duration="{StaticResource MenuAnimationDuration}">
<DoubleAnimation.EasingFunction>
<CircleEase EasingMode="EaseOut" />
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<!-- 顶级菜单项模板(无子菜单) -->
<ControlTemplate x:Key="{x:Static MenuItem.TopLevelItemTemplateKey}" TargetType="{x:Type MenuItem}">
<Border
x:Name="Border"
Margin="{StaticResource TopLevelItemMargin}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource TopLevelCornerRadius}">
<Grid Margin="{StaticResource TopLevelContentMargin}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- 图标区域 -->
<ContentPresenter
x:Name="Icon"
Grid.Column="0"
Margin="0,0,6,0"
VerticalAlignment="Center"
Content="{TemplateBinding Icon}"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
<!-- 标题区域 -->
<ContentPresenter
x:Name="HeaderPresenter"
Grid.Column="1"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"
ContentSource="Header"
RecognizesAccessKey="True"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
TextElement.Foreground="{TemplateBinding Foreground}" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<!-- 鼠标悬停效果 -->
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Border" Property="Background" Value="{DynamicResource MaskColor}" />
</Trigger>
<Trigger Property="Icon" Value="{x:Null}">
<Setter TargetName="Icon" Property="Visibility" Value="Collapsed" />
</Trigger>
<Trigger Property="Header" Value="{x:Null}">
<Setter TargetName="Icon" Property="Margin" Value="0" />
<Setter TargetName="HeaderPresenter" Property="Visibility" Value="Collapsed" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<!-- 子菜单项模板(无子级) -->
<ControlTemplate x:Key="{x:Static MenuItem.SubmenuItemTemplateKey}" TargetType="{x:Type MenuItem}">
<Border
x:Name="Border"
Margin="{StaticResource MenuItemMargin}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{StaticResource MenuCornerRadius}">
<Grid Margin="{StaticResource MenuItemContentPadding}">
<Grid.ColumnDefinitions>
<!-- 图标/复选框列,使用共享大小组确保对齐 -->
<ColumnDefinition Width="Auto" SharedSizeGroup="MenuItemIconCol" />
<!-- 标题内容列 -->
<ColumnDefinition Width="*" />
<!-- 快捷键提示列,使用共享大小组确保对齐 -->
<ColumnDefinition Width="Auto" SharedSizeGroup="MenuItemRightPartCol" />
</Grid.ColumnDefinitions>
<!-- 复选框图标容器 -->
<Border
x:Name="CheckBoxIconBorder"
Grid.Column="0"
VerticalAlignment="Center"
Visibility="Collapsed">
<Path
x:Name="CheckBoxIcon"
Width="10"
Height="10"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Fill="{TemplateBinding Foreground}"
Stretch="Uniform" />
</Border>
<!-- 自定义图标 -->
<ContentPresenter
x:Name="Icon"
Grid.Column="0"
Margin="0,0,6,0"
VerticalAlignment="Center"
Content="{TemplateBinding Icon}"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
<!-- 菜单项标题内容 -->
<ContentPresenter
Grid.Column="1"
Margin="{TemplateBinding Padding}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
ContentSource="Header"
RecognizesAccessKey="True"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
TextElement.Foreground="{TemplateBinding Foreground}" />
<!-- 快捷键提示文本 -->
<TextBlock
x:Name="InputGestureText"
Grid.Column="2"
Margin="25,0,0,0"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
DockPanel.Dock="Right"
FontSize="11"
Opacity="0.67"
Text="{TemplateBinding InputGestureText}" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<!-- 高亮状态(鼠标悬停) -->
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Border" Property="Background" Value="{DynamicResource MaskColor}" />
</Trigger>
<!-- 无自定义图标时隐藏图标区域 -->
<Trigger Property="Icon" Value="{x:Null}">
<Setter TargetName="Icon" Property="Visibility" Value="Collapsed" />
</Trigger>
<!-- 可复选时显示复选框图标容器 -->
<Trigger Property="IsCheckable" Value="True">
<Setter TargetName="CheckBoxIconBorder" Property="Visibility" Value="Visible" />
</Trigger>
<!-- 已选中时显示复选标记 -->
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="CheckBoxIcon" Property="Data" Value="{StaticResource CheckGraph}" />
</Trigger>
<!-- 无快捷键时隐藏快捷键提示 -->
<Trigger Property="InputGestureText" Value="">
<Setter TargetName="InputGestureText" Property="Visibility" Value="Collapsed" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<!-- 子菜单头部模板(带下级子菜单) -->
<ControlTemplate x:Key="{x:Static MenuItem.SubmenuHeaderTemplateKey}" TargetType="{x:Type MenuItem}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- 菜单项外观 -->
<Border
x:Name="Border"
Grid.Row="1"
Height="{TemplateBinding Height}"
Margin="{StaticResource MenuItemMargin}"
Background="Transparent"
CornerRadius="{StaticResource MenuCornerRadius}">
<Grid x:Name="MenuItemContent" Margin="{StaticResource MenuItemContentPadding}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="MenuItemIconCol" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" SharedSizeGroup="MenuItemRightPartCol" />
</Grid.ColumnDefinitions>
<!-- 图标 -->
<ContentPresenter
x:Name="Icon"
Grid.Column="0"
Margin="0,0,6,0"
VerticalAlignment="Center"
Content="{TemplateBinding Icon}"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
<!-- 标题 -->
<ContentPresenter
x:Name="HeaderHost"
Grid.Column="1"
Margin="{TemplateBinding Padding}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
ContentSource="Header"
RecognizesAccessKey="True"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
<!-- 右箭头指示器(表示有子菜单) -->
<Path
Grid.Column="2"
Width="10"
Height="10"
Margin="0,0,4,0"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Data="{StaticResource ForwardGraph}"
Fill="{TemplateBinding Foreground}"
Opacity="0.67"
Stretch="Uniform" />
</Grid>
</Border>
<!-- 子菜单弹出窗口(向右展开) -->
<local:FluentPopup
x:Name="PART_Popup"
Grid.Row="1"
Focusable="False"
IsOpen="{TemplateBinding IsSubmenuOpen}"
Placement="Right"
PlacementTarget="{Binding ElementName=MenuItemContent}"
PopupAnimation="None">
<Grid x:Name="PopupRoot" Background="{DynamicResource PopupWindowBackground}">
<!-- 子菜单动画变换 -->
<Grid.RenderTransform>
<TranslateTransform />
</Grid.RenderTransform>
<ScrollViewer
Padding="{StaticResource MenuBorderPadding}"
CanContentScroll="True"
Style="{StaticResource MenuItemScrollViewerStyle}">
<!-- 子菜单项容器 -->
<ItemsPresenter
x:Name="ItemsPresenter"
Grid.IsSharedSizeScope="True"
KeyboardNavigation.DirectionalNavigation="Cycle"
KeyboardNavigation.TabNavigation="Cycle"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
</ScrollViewer>
</Grid>
</local:FluentPopup>
</Grid>
<ControlTemplate.Triggers>
<!-- 无图标时优化布局 -->
<Trigger Property="Icon" Value="{x:Null}">
<Setter TargetName="Icon" Property="Visibility" Value="Collapsed" />
<Setter TargetName="Icon" Property="Margin" Value="0" />
</Trigger>
<!-- 高亮效果 -->
<Trigger Property="IsHighlighted" Value="true">
<Setter TargetName="Border" Property="Background" Value="{DynamicResource MaskColor}" />
</Trigger>
<!-- 子菜单打开动画 -->
<Trigger Property="IsSubmenuOpen" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<!-- Y轴平移动画:从-45向下滑入到0 -->
<DoubleAnimation
Storyboard.TargetName="PopupRoot"
Storyboard.TargetProperty="(Grid.RenderTransform).(TranslateTransform.Y)"
From="-45"
To="0"
Duration="{StaticResource MenuAnimationDuration}">
<DoubleAnimation.EasingFunction>
<CircleEase EasingMode="EaseOut" />
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<!-- 默认菜单项样式 -->
<Style x:Key="DefaultMenuItemStyle" TargetType="{x:Type MenuItem}">
<Setter Property="FocusVisualStyle" Value="{DynamicResource DefaultCollectionFocusVisualStyle}" />
<Setter Property="KeyboardNavigation.IsTabStop" Value="True" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Focusable" Value="True" />
<Setter Property="OverridesDefaultStyle" Value="True" />
<Style.Triggers>
<!-- 根据菜单项角色应用不同模板 -->
<!-- 顶级菜单项(带子菜单) -->
<Trigger Property="Role" Value="TopLevelHeader">
<Setter Property="Template" Value="{StaticResource {x:Static MenuItem.TopLevelHeaderTemplateKey}}" />
<Setter Property="Grid.IsSharedSizeScope" Value="True" />
<Setter Property="Height" Value="{x:Static sys:Double.NaN}" />
</Trigger>
<!-- 顶级菜单项(无子菜单) -->
<Trigger Property="Role" Value="TopLevelItem">
<Setter Property="Template" Value="{StaticResource {x:Static MenuItem.TopLevelItemTemplateKey}}" />
<Setter Property="Height" Value="{x:Static sys:Double.NaN}" />
</Trigger>
<!-- 子菜单项(带子菜单) -->
<Trigger Property="Role" Value="SubmenuHeader">
<Setter Property="Template" Value="{StaticResource {x:Static MenuItem.SubmenuHeaderTemplateKey}}" />
</Trigger>
<!-- 子菜单项(无子菜单) -->
<Trigger Property="Role" Value="SubmenuItem">
<Setter Property="Template" Value="{StaticResource {x:Static MenuItem.SubmenuItemTemplateKey}}" />
</Trigger>
</Style.Triggers>
</Style>
</ResourceDictionary>
三、应用模板和样式到全局
将上面的资源字典合并到应用程序资源中,然后为ContextMenu和MenuItem指定默认样式:
<Style BasedOn="{StaticResource DefaultContextMenuStyle}" TargetType="{x:Type ContextMenu}">
<Style.Setters>
<Setter Property="local:FluentTooltip.UseFluentStyle" Value="True" />
<Setter Property="Background" Value="{DynamicResource PopupWindowBackground}" />
<Setter Property="Foreground" Value="{DynamicResource ForeColor}" />
</Style.Setters>
</Style>
<Style BasedOn="{StaticResource DefaultMenuItemStyle}" TargetType="MenuItem">
<Setter Property="Height" Value="36" />
<Setter Property="Foreground" Value="{DynamicResource ForeColor}" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
注意,ContextMenu需要使用之前文章中的FluentTooltip.UseFluentStyle来实现亚克力材质特效。其内部原理都是反射获取popup的hwnd句柄,然后附加WindowMaterial特效。
之前的文章中用WPF自带的动画库实现了一个简陋的平滑滚动ScrollViewer,它在只使用鼠标滚轮的情况下表现良好,但仍然有明显的设计缺陷和不足:
- 没有实现真正的动画衔接,只是单纯结束掉上一个动画,而不是继承其滚动速率;
- 使用触控板的体验极差
- 对触控屏和笔设备无效
为了解决以上问题,本文提出一种新的方案来实现平滑滚动ScrollViewer。该方案在OnMouseWheel、OnManipulationDelta和OnManipulationCompleted中直接处理(禁用)了系统的滚动效果,使用CompositionTarget.Rendering事件来驱动滚动动画。并针对滚轮方式和触控“跟手”分别进行优化,使用缓动滚动模型和精确滚动模型来实现平滑滚动。笔的支持得益于EleCho.WpfSuite库提供的StylusTouchDevice模拟,将笔输入映射为触摸输入。
为了最直观和最简单地解决问题,我们将应用场景设置为垂直滚动,水平滚动可以通过类似的方式实现。 在github中查看最小可运行代码:
::github{repo="TwilightLemon/FluentScrollViewer"}
OnMouseWheel(MouseWheelEventArgs e)事件由WPF触发,e.Delta指示鼠标单次滚动的偏移值,通常为120或-120,这个值可以通过Mouse.MouseWheelDeltaForOneLine获得。这一逻辑在传统鼠标滚轮上顺理成章,但是在精准滚动设备(如触控板)上,滚动偏移量变得非常小,事件在高频率低偏移地触发,导致基于动画触发的滚动体验不佳。
测试发现,在以下两种场景中,OnMouseWheel事件具有特定的行为:
因为触控板、触摸屏等精准滚动的使用场景,设备与人交互,意味着其数据本身就遵循物理规律。但是滚动的速率和距离被离散地传递给WPF,导致了滚动的生硬和不自然。
那么有没有一种思路,我们只需先接收这些滚动数据,然后在每一帧中根据这些数据来计算滚动位置?相当于把离散的滚动数据重新平滑化。
CompositionTarget.Rendering是WPF渲染管线的一个事件,它在每一帧渲染之前触发。我们可以利用这个事件来实现自定义的滚动逻辑:先收集滚动参数,然后在OnRender事件中计算实际偏移值,并应用到ScrollViewer上。
我们将滚动分为两种场景:滚轮和触控,分别对应缓动滚动模型和精确滚动模型。
1.3.1 缓动滚动模型类似于鞭挞陀螺使其旋转,每打一次都会给陀螺附加新的加速度,然后在接下来的时间中由于摩擦的存在而缓慢减速。我们基于这个思路来实现简易的缓动滚动模型:
- 先定义几个常量:衰减系数
f=0.92、叠加速率力度系数n=2.0、目标帧时间frameTime=1.0/144以及行进常量p=24 - 每次
OnMouseWheel事件触发时,叠加速率:v += e.Delta * n - 在
CompositionTarget.Rendering事件中:- 计算刷新间隔时间
Δt,以按照间隔时间计算滚动增量 t= Δt / frameTimev *= f^t,模拟摩擦力的影响(确保在目标帧时间内衰减幅度一致)offset += v *(t/p),计算滚动增量
- 计算刷新间隔时间
- 将新的位置应用到ScrollViewer上
调整以上参数会带来相应的变化:
- 衰减系数f越小,滚动越快停下来
- 叠加速率力度系数n越大,每次滚动的速度越快
- 行进常量p越大,滚动越慢
- 通常你无需调整frameTime,它只是用来标准化滚动速度的
对于一个指定的滚动距离,我们希望能够精确地滚动到目标位置,而不是依赖于速率和衰减。模型只需要对离散距离补帧即可。具体而言,定义一个插值系数l,指示接近目标位置的速率,则offset=_targetOffset - _currentOffset) *l.
因为CompositionTarget.Rendering事件的触发频率并不固定,可能会因为系统负载等原因而变化较大,因此在计算滚动增量时需要考虑实际的时间增量。
现在我们已经有思路了:先捕获OnMouseWheel等事件->判断使用哪个模型->挂载OnRender事件->在每一帧中计算新的滚动位置->应用到ScrollViewer上。以下实现通过继承ScrollViewer创建新的控件来实现。
从OnMouseWheel中收集数据并判断模型:
protected override void OnMouseWheel(MouseWheelEventArgs e)
{
e.Handled = true;
//触摸板使用精确滚动模型
_isAccuracyControl = IsTouchpadScroll(e);
if (_isAccuracyControl)
{
_targetVelocity = 0; // 防止下一次触发缓动模型时继承没有消除的速度,造成滚动异常
_targetOffset = Math.Clamp(_currentOffset - e.Delta, 0, ScrollableHeight);
}
else
_targetVelocity += -e.Delta * VelocityFactor;// 鼠标滚动,叠加速度(惯性滚动)
if (!_isRenderingHooked)
{
CompositionTarget.Rendering += OnRendering;
_isRenderingHooked = true;
}
}
WPF似乎并没有提供直接判断触发设备的方法,这里使用了一个启发式判断逻辑:判断触发间隔时间和偏移值是否为滚轮偏移值的倍数。这一代码在诺尔大佬的EleCho.WpfSuite中亦有记载。
private bool IsTouchpadScroll(MouseWheelEventArgs e)
{
var tickCount = Environment.TickCount;
var isTouchpadScrolling =
e.Delta % Mouse.MouseWheelDeltaForOneLine != 0 ||
(tickCount - _lastScrollingTick < 100 && _lastScrollDelta % Mouse,MouseWheelDeltaForOneLine != 0);
_lastScrollDelta = e.Delta;
_lastScrollingTick = e.Timestamp;
return isTouchpadScrolling;
}
2.2 适配触摸屏和笔(更新:不建议使用)
触摸屏的输入可以通过ManipulationDelta和ManipulationCompleted事件来处理。我们将触摸输入映射为滚动偏移量,并使用精确滚动模型,在结束滚动时,可能还有由于快速滑动造成的惯性速率,我们在ManipulationCompleted中交给惯性滚动模型处理。
protected override void OnManipulationDelta(ManipulationDeltaEventArgs e)
{
base.OnManipulationDelta(e); //如果没有这一行则不会触发ManipulationCompleted事件??
e.Handled = true;
//手还在屏幕上,使用精确滚动
_isAccuracyControl = true;
double deltaY = -e.DeltaManipulation.Translation.Y;
_targetOffset = Math.Clamp(_targetOffset + deltaY, 0, ScrollableHeight);
// 记录最后一次速度
_lastTouchVelocity = -e.Velocities.LinearVelocity.Y;
if (!_isRenderingHooked)
{
CompositionTarget.Rendering += OnRendering;
_isRenderingHooked = true;
}
}
protected override void OnManipulationCompleted(ManipulationCompletedEventArgs e)
{
base.OnManipulationCompleted(e);
e.Handled = true;
Debug.WriteLine("vel: "+ _lastTouchVelocity);
_targetVelocity = _lastTouchVelocity; // 用系统识别的速度继续滚动
_isAccuracyControl = false;
if (!_isRenderingHooked)
{
CompositionTarget.Rendering += OnRendering;
_isRenderingHooked = true;
}
}
适配笔只需要把笔设备映射为触摸设备即可。这里使用了EleCho.WpfSuite库中的StylusTouchDevice来模拟触摸输入,最小可用代码在仓库中给出。
public MyScrollViewer()
{
//...
StylusTouchDevice.SetSimulate(this, true);
}
2.3 OnRender事件
在CompositionTarget.Rendering事件中,我们根据当前模型计算新的滚动位置,并应用到ScrollViewer上。
private void OnRendering(object? sender, EventArgs e)
{
// 计算时间增量
long currentTimestamp = Stopwatch.GetTimestamp();
double deltaTime = (double)(currentTimestamp - _lastTimestamp) / Stopwatch.Frequency;
_lastTimestamp = currentTimestamp;
double timeFactor = deltaTime / TargetFrameTime;
double _currentOffset = VerticalOffset;
if (_isAccuracyControl)
{
// 精确滚动:Lerp 逼近目标(使用时间因子调整)
double lerpAmount = 1.0 - Math.Pow(1.0 - LerpFactor, timeFactor);
_currentOffset += (_targetOffset - _currentOffset) * lerpAmount;
// 如果已经接近目标,就停止
if (Math.Abs(_targetOffset - _currentOffset) < 0.5)
{
_currentOffset = _targetOffset;
StopRendering();
}
}
else
{
// 缓动滚动:速度衰减模拟(使用时间因子调整)
if (Math.Abs(_targetVelocity) < 0.1)
{
_targetVelocity = 0;
StopRendering();
return;
}
// 使用时间因子调整摩擦力衰减
_targetVelocity *= Math.Pow(Friction, timeFactor);
// 根据实际时间计算偏移量
_currentOffset = Math.Clamp(_currentOffset + _targetVelocity * (timeFactor / 24), 0, ScrollableHeight);
}
ScrollToVerticalOffset(_currentOffset);
}
三、已知问题
- 使用触摸屏时可能会造成闪烁,因为并没有完全禁用系统的滚动实现。但是如果禁用
base.OnManipulationDelta(e),则无法触发ManipulationCompleted事件,导致无法处理惯性滚动。 - 尚未测试与ListBox等控件的兼容性。
以下是完整的MyScrollViewer代码,包含了上述所有实现细节。
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace FluentScrollViewer;
public class MyScrollViewer : ScrollViewer
{
#region 模型参数
/// <summary>
/// 缓动模型的叠加速度力度,数值越大,滚动起始速率越快,滚得越远
/// </summary>
private const double VelocityFactor = 2.0;
/// <summary>
/// 缓动模型的速度衰减系数,数值越小,越快停下来
/// </summary>
private const double Friction = 0.92;
/// <summary>
/// 精确模型的插值系数,数值越大,滚动越快接近目标
/// </summary>
private const double LerpFactor = 0.5;
/// <summary>
/// 目标帧时间
/// </summary>
private const double TargetFrameTime = 1.0d / 144;
#endregion
public MyScrollViewer()
{
this.IsManipulationEnabled = true;
this.PanningMode = PanningMode.VerticalOnly;
this.IsManipulationEnabled = true;
//使用此触屏滚动会导致闪屏,先不用了..
// this.PanningDeceleration = 0; // 禁用默认惯性
//StylusTouchDevice.SetSimulate(this, true);
Unloaded += ScrollViewer_Unloaded;
}
//记录参数
private int _lastScrollingTick = 0, _lastScrollDelta = 0;
//private double _lastTouchVelocity = 0;
private double _targetOffset = 0;
private double _targetVelocity = 0;
private long _lastTimestamp = 0;
//标志位
private bool _isRenderingHooked = false;
private bool _isAccuracyControl = false;
private void ScrollViewer_Unloaded(object sender, RoutedEventArgs e)
{
if (_isRenderingHooked)
{
CompositionTarget.Rendering -= OnRendering;
_isRenderingHooked = false;
}
}
/* protected override void OnManipulationDelta(ManipulationDeltaEventArgs e)
{
base.OnManipulationDelta(e); //如果没有这一行则不会触发ManipulationCompleted事件??
e.Handled = true;
//手还在屏幕上,使用精确滚动
_isAccuracyControl = true;
double deltaY = -e.DeltaManipulation.Translation.Y;
_targetOffset = Math.Clamp(_currentOffset + deltaY, 0, ScrollableHeight);
// 记录最后一次速度
_lastTouchVelocity = -e.Velocities.LinearVelocity.Y;
if (!_isRenderingHooked)
{
_lastTimestamp = Stopwatch.GetTimestamp();
CompositionTarget.Rendering += OnRendering;
_isRenderingHooked = true;
}
}
protected override void OnManipulationCompleted(ManipulationCompletedEventArgs e)
{
base.OnManipulationCompleted(e);
e.Handled = true;
Debug.WriteLine("vel: " + _lastTouchVelocity);
_targetVelocity = _lastTouchVelocity; // 用系统识别的速度继续滚动
_isAccuracyControl = false;
if (!_isRenderingHooked)
{
_lastTimestamp = Stopwatch.GetTimestamp();
CompositionTarget.Rendering += OnRendering;
_isRenderingHooked = true;
}
}*/
/// <summary>
/// 判断MouseWheel事件由鼠标触发还是由触控板触发
/// </summary>
/// <param name="e"></param>
/// <returns></returns>
private bool IsTouchpadScroll(MouseWheelEventArgs e)
{
var tickCount = Environment.TickCount;
var isTouchpadScrolling =
e.Delta % Mouse.MouseWheelDeltaForOneLine != 0 ||
(tickCount - _lastScrollingTick < 100 && _lastScrollDelta % Mouse.MouseWheelDeltaForOneLine != 0);
//Debug.WriteLine(e.Delta + " " + e.Timestamp + " ==>" + isTouchpadScrolling);
_lastScrollDelta = e.Delta;
_lastScrollingTick = e.Timestamp;
return isTouchpadScrolling;
}
protected override void OnMouseWheel(MouseWheelEventArgs e)
{
e.Handled = true;
//触摸板使用精确滚动模型
_isAccuracyControl = IsTouchpadScroll(e);
if (_isAccuracyControl)
{
_targetVelocity = 0; // 防止下一次触发缓动模型时继承没有消除的速度,造成滚动异常
_targetOffset = Math.Clamp(VerticalOffset - e.Delta, 0, ScrollableHeight);
}
else
_targetVelocity += -e.Delta * VelocityFactor;// 鼠标滚动,叠加速度(惯性滚动)
if (!_isRenderingHooked)
{
_lastTimestamp = Stopwatch.GetTimestamp();
CompositionTarget.Rendering += OnRendering;
_isRenderingHooked = true;
}
}
private void OnRendering(object? sender, EventArgs e)
{
// 计算时间增量
long currentTimestamp = Stopwatch.GetTimestamp();
double deltaTime = (double)(currentTimestamp - _lastTimestamp) / Stopwatch.Frequency;
_lastTimestamp = currentTimestamp;
double timeFactor = deltaTime / TargetFrameTime;
double _currentOffset = VerticalOffset;
if (_isAccuracyControl)
{
// 精确滚动:Lerp 逼近目标(使用时间因子调整)
double lerpAmount = 1.0 - Math.Pow(1.0 - LerpFactor, timeFactor);
_currentOffset += (_targetOffset - _currentOffset) * lerpAmount;
// 如果已经接近目标,就停止
if (Math.Abs(_targetOffset - _currentOffset) < 0.5)
{
_currentOffset = _targetOffset;
StopRendering();
}
}
else
{
// 缓动滚动:速度衰减模拟(使用时间因子调整)
if (Math.Abs(_targetVelocity) < 0.1)
{
_targetVelocity = 0;
StopRendering();
return;
}
// 使用时间因子调整摩擦力衰减
_targetVelocity *= Math.Pow(Friction, timeFactor);
// 根据实际时间计算偏移量
_currentOffset = Math.Clamp(_currentOffset + _targetVelocity * (timeFactor / 24), 0, ScrollableHeight);
}
ScrollToVerticalOffset(_currentOffset);
}
private void StopRendering()
{
CompositionTarget.Rendering -= OnRendering;
_isRenderingHooked = false;
}
}
前几天手贱把网站图片弄丢了,我又一个个上传了一遍……