在之前的兩篇文章中,我們探討了 WPF 中實現平滑滾動的不同方案:
- WPF 如何流暢地滾動ScrollViewer 簡單實現下:基於
DoubleAnimation的動畫方案。 - WPF 使用CompositionTarget.Rendering實現平滑流暢滾動的ScrollViewer:基於
CompositionTarget.Rendering的每幀佈局更新方案。
雖然第二版方案解決了觸控板和物理慣性的問題,但它引入了一個新的性能瓶頸:每幀調用 ScrollToVerticalOffset。這會導致 WPF 在每一幀都進行佈局計算,在高負載場景下會直接卡死整個UI線程,造成掉幀或其他UI組件無響應。
為了解決這個問題,我進行了第三版(v3)設計,核心思路是:視覺層與邏輯層分離。
三種方案對比
| 方案 | 實現方式 | 優點 | 缺點 |
|---|---|---|---|
| v1 (動畫版) | DoubleAnimation 驅動 VerticalOffset |
實現簡單,代碼量少 | 無法保留慣性速度(動畫打斷);觸控板體驗差;不支持觸摸/筆。 |
| v2 (佈局驅動) | Rendering 事件每幀調用 ScrollToVerticalOffset |
物理模型更真實;支持多種輸入設備 | 性能差:每幀觸發 Layout 計算,高負載下掉幀嚴重。 |
| v3 (視覺分離) | RenderTransform 驅動視覺,低頻同步邏輯位置 |
高性能:視覺滿幀運行,邏輯低頻更新;物理模型完善。 | 實現相對複雜,需要處理座標系轉換和幀同步。 |
現在來看看v3的效果(gif幀率好低):

接下來,我們詳細介紹 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 引入了一個基於時間間隔的動態速度因子。當用户快速連續滾動時,速度因子會呈指數級增長,從而產生更大的加速度。
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+(xtarget−xold)⋅(1−(1−l)^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 = -Δ
這樣帶來兩個好處:
- 邏輯層什麼時候同步(調用
ScrollTo*Offset)可以自由選擇頻率,不會影響視覺連續性。 - 當邏輯層因為外部原因突變(拖動滾動條、代碼調用
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 中做了兩件重要的事情:
- 更新邏輯 offset(垂直/水平各自維護)。
- 如果當前正在平滑滾動,並且變化來自“當前激活方向”,就立刻更新 Transform,讓畫面位置保持連續。
6) 橫向滾動與 Shift 切換
v3 支持橫向與縱向兩種滾動方向,並且提供了按住 Shift 切換滾動方向的特性。
7) 為什麼滾動時要臨時關閉 HitTest
渲染循環開始時把 IsHitTestVisible 設為 false,結束時恢復。
高速滾動時,鼠標在大量元素上掃過會觸發頻繁的命中測試和狀態變更(Hover、ToolTip、觸發器)。 關閉命中測試能夠顯著降低這些開銷,提升滾動性能。
當然,它也意味着滾動過程中無法點擊內容。
8) 如何寫你自己的物理模型
IScrollPhysics 的接口設計簡單:
OnScroll(...):只負責接收一次輸入意圖(delta + 是否精確 + 邊界 + 時間間隔)。Update(...):每幀推進到新位置(幀率無關)。IsStable:告訴外部何時可以退出渲染循環。
寫在最後
相關組件均開源在 FluentWpfCore 倉庫,歡迎 star 和 PR!倉庫保持活躍更新。
感謝閲讀,文章如有不妥之處,請各位大佬不吝指正!

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