封面图
玉清滴风小竹楼 玉清滴风小竹楼
今朝剑指叠云处

友链

Jellyfin 更新至 10.10.7 及以后版本后,后台登录IP显示为容器或反代IP。解决方法包括在Nginx反代中配置IP透传,如设置`X-Real-IP`、`X-Forwarded-For`等头信息,以及在Jellyfin控制台的“Known proxies”中填写受信的IP网段(如`172.33.5.0/24`),并重启服务。配置完成后,登录IP将恢复正常显示。
Jellyfin 反向代理中的真实IP透传

友链

激动地宣布第五季已经发布,首部作品自2016年以来已有八年。第五季分为两批释出,目前仅提供前四集,余下三集将于12月25日和31日依次上线。4K版本也已包含其中。作者同步提供了多个在线观看和下载的链接,包括夸克、115、百度网盘等,还有相应的磁力链接。作者未能立即观看全部内容,表示已下载文件准备观看。
怪奇物语第五季来了!附各大网盘、磁力资源 8集全 已完结

友链

作者宣布新增了991个动画Emoji表情,并更新了表情包插件的拖动排序功能。作者亲自排序耗时两小时,并分享了三款个人最喜欢的表情包。新增的表情包来自神秘平台,可供用户下载回顾。
新增 991 个动态 Emoji 表情

友链

作者最近更新了6个新的动画表情包,包括黄豆小黄鸭、佩佩蛙、可爱Emoji、动态Emoji、搞怪Emoji和圣诞老人。其中,作者最喜欢佩佩蛙这一表情。这些表情包已经更新到GitHub,并提醒用户如果表情包不生效,可能需要重启Halo。
表情包仙人又来了!

友链

文章探讨了Python包管理工具uv的优势和用法。uv,由Astral公司基于Rust编写,旨在提供高性能、多功能且易用的包管理体验,其速度快、使用方便,并集成了多种功能。文章详细介绍了uv的安装方法、基本命令、依赖管理、Python版本管理、CLI工具以及脚本模式等,并给出了推荐的工作流框架。作者认为uv相较于pip和conda等工具,在性能和易用性方面具有明显优势。
Python 包管理神器 - UV

友链

在之前的两篇文章中,我们探讨了 WPF 中实现平滑滚动的不同方案:

  1. WPF 如何流畅地滚动ScrollViewer 简单实现下:基于 DoubleAnimation 的动画方案。
  2. WPF 使用CompositionTarget.Rendering实现平滑流畅滚动的ScrollViewer:基于 CompositionTarget.Rendering 的每帧布局更新方案。

虽然第二版方案解决了触控板和物理惯性的问题,但它引入了一个新的性能瓶颈:每帧调用 ScrollToVerticalOffset。这会导致 WPF 在每一帧都进行布局计算,在高负载场景下会直接卡死整个UI线程,造成掉帧或其他UI组件无响应。

为了解决这个问题,我进行了第三版(v3)设计,核心思路是:视觉层与逻辑层分离

三种方案对比 方案 实现方式 优点 缺点 v1 (动画版) DoubleAnimation 驱动 VerticalOffset 实现简单,代码量少 无法保留惯性速度(动画打断);触控板体验差;不支持触摸/笔。 v2 (布局驱动) Rendering 事件每帧调用 ScrollToVerticalOffset 物理模型更真实;支持多种输入设备 性能差:每帧触发 Layout Pass,高负载下掉帧严重。 v3 (视觉分离) RenderTransform 驱动视觉,低频同步逻辑位置 高性能:视觉满帧运行,逻辑低频更新;物理模型完善。 实现相对复杂,需要处理坐标系转换和帧同步。

接下来,我们详细介绍 v3 版本的设计与实现原理。

一、视觉与逻辑分离

v3 的核心在于将“用户看到的滚动”(视觉层)和“控件实际的滚动”(逻辑层)分离。

  1. 视觉层

    • 使用 TranslateTransformContent 进行位移。
    • CompositionTarget.Rendering 中以屏幕刷新率(如 60Hz 或 144Hz)更新 Transform.Y
    • 因为 RenderTransform 只影响渲染而不触发布局(Measure/Arrange),所以性能极高,完全由 GPU 加速。
  2. 逻辑层

    • 维护实际的 ScrollViewer.VerticalOffset
    • 降频更新:不再每帧调用 ScrollToVerticalOffset,而是以较低的频率(如 24Hz)同步逻辑位置。
    • 这保证了滚动条的位置更新和虚拟化加载新内容,同时避免了频繁的布局计算。
渲染循环逻辑

只需遵守一条坐标系变换规则:逻辑位置 = 视觉位置 + 视觉偏差
当用户滚动时,视觉层将以“插帧”方式在逻辑层低帧率更新之间平滑过渡。

在每一帧的渲染回调中:

  1. 计算物理模型的当前位置 _currentVisualOffset
  2. 计算视觉偏差 _visualDelta = _currentVisualOffset - _logicalOffset
  3. 应用 _transform.Y = -_visualDelta,实现视觉上的平滑移动,不会触发布局重置。
  4. 累加时间,如果超过 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 是一个经验值,用于调整速度到位移的映射比例。
2.2 精确模型

适用于触控板。触控板本身提供了高精度的 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 首选滚动方向:VerticalHorizontal 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:

  • 逻辑 offsetScrollViewer 真正的 VerticalOffset/HorizontalOffset,决定滚动条与虚拟化。
  • 视觉 offset:物理模型在每帧计算出的“应该看到的位置”。

两者的差值就是视觉补偿量:

$$\Delta = offset_{visual} - offset_{logical}$$

视觉层每帧做的事情非常纯粹:把这个差值通过 Transform 反向抵消掉,让用户“看到”的内容位置跟随视觉 offset。

  • 垂直滚动:Transform.Y = -$\Delta$
  • 水平滚动:Transform.X = -$\Delta$

这样带来两个好处:

  1. 逻辑层什么时候同步(调用 ScrollTo*Offset)可以自由选择频率,不会影响视觉连续性。
  2. 当逻辑层因为外部原因突变(拖动滚动条、代码调用 ScrollTo...、键盘导航)时,只要立刻重算差值,视觉层就仍然能保持连续。
3) 为什么要用真实 dt(而不是假设固定帧率)

CompositionTarget.Rendering 的触发并不严格等间隔:后台负载、窗口被遮挡、显示器刷新率、系统节能策略都会让帧间隔波动。

因此实现中用 Stopwatch.GetTimestamp() 计算真实 dt,并把 dt 传给物理模型。这意味着:

  • 低帧率时不会“走慢动作”或突然“加速冲刺”
  • 高刷屏(120/144Hz)不会因为更高帧数而滚得更远

配合 DefaultScrollPhysics 中的 timeFactor = dt / TargetFrameTime,滚动手感可以在不同帧率下保持一致。

4) 关于逻辑同步频率

实现把逻辑同步频率设为 24Hz(ScrollBarUpdateInterval = 1/24s),这是一个折中:

  • 频率更高:滚动条更“实时”,虚拟化更及时,但布局压力上升。
  • 频率更低:性能更好,但滚动条会有视觉滞后,虚拟化加载可能会出现空白频闪。

一个可能的缓解思路是让虚拟化容器提前加载,会增加一点内存开销,但能减少空白频闪。

一般来说:

  • 内容很重(大量图片、复杂控件、阴影/模糊多):可以把同步频率调低一点。
  • 列表虚拟化强依赖“及时生成下一屏”(例如聊天列表/文件列表):可以适当提高,但要观察 CPU。
5) 注意 ScrollChanged 状态变更

只靠渲染循环还不够,因为用户可以通过滚动条拖动来改变 offset。实现里在 OnScrollChanged 中做了两件重要的事情:

  1. 更新逻辑 offset(垂直/水平各自维护)。
  2. 如果当前正在平滑滚动,并且变化来自“当前激活方向”,就立刻更新 Transform,让画面位置保持连续。
6) 横向滚动与 Shift 切换

v3 支持横向与纵向两种滚动方向,并且提供了按住 Shift 切换滚动方向的特性。

7) 为什么滚动时要临时关闭 HitTest

渲染循环开始时把 IsHitTestVisible 设为 false,结束时恢复。

高速滚动时,鼠标在大量元素上扫过会触发频繁的命中测试和状态变更(Hover、ToolTip、触发器)。 关闭命中测试能够显著降低这些开销,提升滚动性能。

当然,它也意味着滚动过程中无法点击内容。

8) 如何写你自己的物理模型

IScrollPhysics 的接口设计刻意简单:

  • OnScroll(...):只负责接收一次输入意图(delta + 是否精确 + 边界 + 时间间隔)。
  • Update(...):每帧推进到新位置(帧率无关)。
  • IsStable:告诉外部何时可以退出渲染循环。
写在最后

::github{repo="TwilightLemon/FluentWpfCore"} 相关组件均开源在 FluentWpfCore 仓库,欢迎 star 和 PR!仓库保持活跃更新。

感谢阅读,文章如有不妥之处,请各位大佬不吝指正!

友链

该插件适用于Halo 2.22版本,自动刷新对应页面的CDN缓存,支持阿里云、腾讯云和Cloudflare等多家CDN提供商。插件可在文章发布、更新或评论审核通过时自动刷新缓存,并支持手动刷新指定URL,记录和查看刷新日志,具有防抖功能,实现高效缓存更新。用户可通过GitHub下载安装插件,并可在开启后观察速度和响应的改善。
Halo CDN 缓存刷新插件

友链

该插件适用于Halo文章编辑器,支持插入OwO表情包,并与多种编辑器兼容。用户可以自定义表情包,新增类别,上传、删除和编辑表情。主要功能包括内置表情包、自定义表情管理、导入/导出表情和智能显示。插件的安装和使用过程明确,并提供截图预览及配置说明,方便用户理解和操作。
Halo 编辑器表情包插件

友链

书接上回,我们的Fluent WPF的版图已经完成了:

先来看看效果图(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和MenuItem样式

如果你直接给ContextMenu应用WindowMaterial特效,可能会出现以下丑陋的效果:
或者:
原因在于古老的ContextMenu和MenuItem样式并不能通过简单修改Background实现我们想要的布局和交互效果。

二、ContextMenu与MenuItem的结构 1. ContextMenu 的结构

ContextMenu直观上看是一个Popup,但它的控件模板并不包含Popup,而是直接指定内部元素(包含一个ScrollViewerStackPanel)。因此不能从控件模板中替换Popup为自定义的FluentPopup,需要使用其他手段让其内部Popup也支持Acrylic材质(见下文)。

2. MenuItem 的四种形态

在示例代码仓库中展示了较为完整的Menu相关的结构:

MenuItem根据其在菜单树中的位置,通过Role属性分为四种形态。我们在Style.Triggers中分别为它们指定了不同的模板:

  • TopLevelHeader: 顶级菜单项,且包含子菜单Popup(例如菜单栏上的"File")。
  • TopLevelItem: 顶级菜单项,不含子菜单(例如菜单栏上的"Help")。
  • SubmenuHeader: 子菜单项,且包含下一级子菜单Popup
  • SubmenuItem: 子菜单项,不含子菜单(叶子节点)。其模板主要处理图标、文字、快捷键的布局。
三、重写Menu相关控件模板和样式 1. ContextMenu 样式

ContextMenu的样式主要参考了.NET 9自带的Fluent样式,并作了一些调整以适配Acrylic材质。主要目的是覆盖原始模板的Icon部分白框和分割线: 因为我们的WindowMaterial已经为窗口自动附加上圆角、阴影和亚克力材质的DWM效果,所以我们只需要将ContextMenu的背景设置为透明,并移除不必要的边框和分割线即可。
此外,还添加了弹出动画(是在Popup内部做的,并非对window,效果可能不会很理想)。

2. MenuItem 样式

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,它在只使用鼠标滚轮的情况下表现良好,但仍然有明显的设计缺陷和不足:

  1. 没有实现真正的动画衔接,只是单纯结束掉上一个动画,而不是继承其滚动速率;
  2. 使用触控板的体验极差
  3. 对触控屏和笔设备无效

为了解决以上问题,本文提出一种新的方案来实现平滑滚动ScrollViewer。该方案在OnMouseWheelOnManipulationDeltaOnManipulationCompleted中直接处理(禁用)了系统的滚动效果,使用CompositionTarget.Rendering事件来驱动滚动动画。并针对滚轮方式和触控“跟手”分别进行优化,使用缓动滚动模型精确滚动模型来实现平滑滚动。笔的支持得益于EleCho.WpfSuite库提供的StylusTouchDevice模拟,将笔输入映射为触摸输入。

为了最直观和最简单地解决问题,我们将应用场景设置为垂直滚动,水平滚动可以通过类似的方式实现。 在github中查看最小可运行代码:
::github{repo="TwilightLemon/FluentScrollViewer"}

一、一些先验事实和设计思路 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. 先定义几个常量:衰减系数f=0.92、叠加速率力度系数n=2.0、目标帧时间frameTime=1.0/144以及行进常量p=24
  2. 每次OnMouseWheel事件触发时,叠加速率:v += e.Delta * n
  3. CompositionTarget.Rendering事件中:
    • 计算刷新间隔时间Δt,以按照间隔时间计算滚动增量
    • t= Δt / frameTime
    • v *= f^t,模拟摩擦力的影响(确保在目标帧时间内衰减幅度一致)
    • offset += v *(t/p),计算滚动增量
  4. 将新的位置应用到ScrollViewer上

调整以上参数会带来相应的变化:

  • 衰减系数f越小,滚动越快停下来
  • 叠加速率力度系数n越大,每次滚动的速度越快
  • 行进常量p越大,滚动越慢
  • 通常你无需调整frameTime,它只是用来标准化滚动速度的
1.3.2 精确滚动模型

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

2025/11/08 更新

因为CompositionTarget.Rendering事件的触发频率并不固定,可能会因为系统负载等原因而变化较大,因此在计算滚动增量时需要考虑实际的时间增量。

二、实现

现在我们已经有思路了:先捕获OnMouseWheel等事件->判断使用哪个模型->挂载OnRender事件->在每一帧中计算新的滚动位置->应用到ScrollViewer上。以下实现通过继承ScrollViewer创建新的控件来实现。

2.1 先从鼠标滚轮与触控板开始

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 适配触摸屏和笔(更新:不建议使用)

触摸屏的输入可以通过ManipulationDeltaManipulationCompleted事件来处理。我们将触摸输入映射为滚动偏移量,并使用精确滚动模型,在结束滚动时,可能还有由于快速滑动造成的惯性速率,我们在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);
}
三、已知问题
  1. 使用触摸屏时可能会造成闪烁,因为并没有完全禁用系统的滚动实现。但是如果禁用base.OnManipulationDelta(e),则无法触发ManipulationCompleted事件,导致无法处理惯性滚动。
  2. 尚未测试与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;
    }
}