博客 / 詳情

返回

WPF 使用 RenderTransform 實現高性能平滑滾動的 ScrollViewer

在之前的兩篇文章中,我們探討了 WPF 中實現平滑滾動的不同方案:

  1. WPF 如何流暢地滾動ScrollViewer 簡單實現下:基於 DoubleAnimation 的動畫方案。
  2. WPF 使用CompositionTarget.Rendering實現平滑流暢滾動的ScrollViewer:基於 CompositionTarget.Rendering 的每幀佈局更新方案。

雖然第二版方案解決了觸控板和物理慣性的問題,但它引入了一個新的性能瓶頸:每幀調用 ScrollToVerticalOffset。這會導致 WPF 在每一幀都進行佈局計算,在高負載場景下會直接卡死整個UI線程,造成掉幀或其他UI組件無響應。

為了解決這個問題,我進行了第三版(v3)設計,核心思路是:視覺層與邏輯層分離。

三種方案對比

方案 實現方式 優點 缺點
v1 (動畫版) DoubleAnimation 驅動 VerticalOffset 實現簡單,代碼量少 無法保留慣性速度(動畫打斷);觸控板體驗差;不支持觸摸/筆。
v2 (佈局驅動) Rendering 事件每幀調用 ScrollToVerticalOffset 物理模型更真實;支持多種輸入設備 性能差:每幀觸發 Layout 計算,高負載下掉幀嚴重。
v3 (視覺分離) RenderTransform 驅動視覺,低頻同步邏輯位置 高性能:視覺滿幀運行,邏輯低頻更新;物理模型完善。 實現相對複雜,需要處理座標系轉換和幀同步。

現在來看看v3的效果(gif幀率好低):

2025-12-22-17-04-20

接下來,我們詳細介紹 v3 版本的設計與實現原理。

一、視覺與邏輯分離

v3 的核心在於將“用户看到的滾動”(視覺層)和“控件實際的滾動”(邏輯層)分離。

  1. 視覺層:

    • 使用 TranslateTransform 對 Content 進行位移。
    • 在 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 引入了一個基於時間間隔的動態速度因子。當用户快速連續滾動時,速度因子會呈指數級增長,從而產生更大的加速度。

vf=(Vmax−Vmin)⋅e^(−Δt/20)+Vmin

  • Vmax:最大速度倍率,固定為 2.5。
  • Vmin:最小速度倍率 (MinVelocityFactor),默認為 1.2。
  • Δt:兩次滾動事件的時間間隔 (ms)。

這意味着:如果你慢慢滾動,每次滾動的距離約為原始值的 1.2 倍;如果你瘋狂撥動滾輪,這個倍率會迅速逼近 2.5 倍,與真實的物理滾動手感更接近。

物理衰減

模擬物理摩擦力,使滾動速度隨時間自然衰減。

  • 速度衰減:vnew=vold⋅f^tf
  • 位置更新:xnew=xold+vnew⋅(tf/24)

其中:

  • f:速率衰減係數,默認為 0.92。數值越小,停得越快。
  • tf:時間標準化因子,dt/TargetFrameTime (基準幀率為 144Hz),dt為繪製兩幀之間的間隔時間。
  • 常數 24 是一個經驗值,用於調整速度到位移的映射比例。

2.2 精確模型

適用於觸控板。觸控板本身提供了高精度的 Delta 值,我們不需要模擬慣性(系統已處理),只需要平滑地過渡到目標位置,避免畫面撕裂或抖動。

  • 插值計算:xnew=xold+(xtargetxold)(1(1l)^tf)

其中:

  • l:插值係數 (LerpFactor),默認為 0.5。數值越大,跟隨越緊密。
  • tf:時間標準化因子。

三、快速開始

在項目中引入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:物理模型在每幀計算出的“應該看到的位置”。

兩者的差值就是視覺補償量:

Δ= visual offset − logical offset

視覺層每幀做的事情非常純粹:把這個差值通過 Transform 反向抵消掉,讓用户“看到”的內容位置跟隨視覺 offset。

  • 垂直滾動:Transform.Y = -Δ
  • 水平滾動:Transform.X = -Δ

這樣帶來兩個好處:

  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:告訴外部何時可以退出渲染循環。

寫在最後

TwilightLemon/FluentWpfCore: A WPF library providing core Fluent Design controls, materials, and visual effects.

相關組件均開源在 FluentWpfCore 倉庫,歡迎 star 和 PR!倉庫保持活躍更新。

感謝閲讀,文章如有不妥之處,請各位大佬不吝指正!

 

  本作品採用 知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議 進行許可。歡迎轉載、使用、重新發布,但務必保留文章署名TwilightLemon,不得用於商業目的,基於本文修改後的作品務必以相同的許可發佈。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.