原文: 在WPF中實作平滑滾動 WPF實作滾動條還是比較友善的,隻要在控件外圍加上ScrollViewer即可,但美中不足的是:滾動的時候沒有動畫效果。在滾動的時候添加過渡動畫能給我們的軟體增色不少,例如Office 2013的滾動的時候支援動畫看起來就舒服多了。 之前倒是研究過如何實作這個平滑滾動,不過網上的方案大部分大多數如下:
- 通過VisualTree找到ScrollViewer
- 在ScrollChanged事件中添加動畫
這種方案效果并不好,以為我們的滾動很多時候都是一口氣滾動好幾格滾輪的,這個時候上一個動畫還沒有結束,下一個動畫就來了,反而還出現了卡頓的感覺,并且網上的一些算法大部分還都會導緻偏移錯位。
趁着這兩天有點時間,就研究了一下ScorllViewer,從
MSDN文檔中看到,它是支援兩種滾動方式的:
實體滾動:
系統預設的滾動方案,控件本身啥都不用幹,完全由ScrollViewer來實作滾動。這種方式的好處是簡單,但也正由于簡單,控件本身完全感覺不到ScorllViewer的存在,也就無法加以控制了。
邏輯滾動:
将這種方式需要設定ScrollViewer的CanContentScroll為"True"才能生效,同時需要控件實作IScrollInfo接口。此時ScrollViewer隻是将滾動事件通過IScrollInfo接口傳遞給控件,由控件本身自己去實作滾動。同時從IScrollInfo接口中讀取相關的屬性更新滾動條界面。
也就是說,邏輯滾動才是我們所需要的方案。由于它要求控件實作IScrollInfo接口,自行控制滾動。也就是說我們要實作自己的Panel,并且實作IScrollInfo接口。關于這個接口,MSDN上有一系列文章介紹過如何實作它:
- IScrollInfo in Avalon part I
- IScrollInfo in Avalon part II
- IScrollInfo in Avalon part III
- IScrollInfo in Avalon part IV
這個接口實作也不算麻煩,我倒沒有細看這幾篇文章,自己照着最後的一個例子嘗試着弄了一陣子也弄出來了。實際上麻煩的地方不在于實作這個接口,而是實作Panel,我這裡為了簡單,直接繼承了WrapPanel類,代碼如下:

1 class MyWrapPanel : WrapPanel, IScrollInfo
2 {
3 TranslateTransform _transForm;
4 public MyWrapPanel()
5 {
6 _transForm = new TranslateTransform();
7 this.RenderTransform = _transForm;
8 }
9
10 #region Layout
11
12 Size _screenSize;
13 Size _totalSize;
14
15 protected override Size MeasureOverride(Size availableSize)
16 {
17 _screenSize = availableSize;
18
19 if (Orientation == Orientation.Horizontal)
20 availableSize = new Size(availableSize.Width, double.PositiveInfinity);
21 else
22 availableSize = new Size(double.PositiveInfinity, availableSize.Height);
23
24 _totalSize = base.MeasureOverride(availableSize);
25 return _totalSize;
26 }
27
28 protected override Size ArrangeOverride(Size finalSize)
29 {
30 var size = base.ArrangeOverride(finalSize);
31 if (ScrollOwner != null)
32 {
33 _transForm.Y = -VerticalOffset;
34 _transForm.X = -HorizontalOffset;
35
36 ScrollOwner.InvalidateScrollInfo();
37 }
38 return _screenSize;
39 }
40 #endregion
41
42 #region IScrollInfo
43
44 public ScrollViewer ScrollOwner { get; set; }
45 public bool CanHorizontallyScroll { get; set; }
46 public bool CanVerticallyScroll { get; set; }
47
48 public double ExtentHeight { get { return _totalSize.Height; } }
49 public double ExtentWidth { get { return _totalSize.Width; } }
50
51 public double HorizontalOffset { get; private set; }
52 public double VerticalOffset { get; private set; }
53
54 public double ViewportHeight { get { return _screenSize.Height; } }
55 public double ViewportWidth { get { return _screenSize.Width; } }
56
57 void appendOffset(double x, double y)
58 {
59 var offset = new Vector(HorizontalOffset + x, VerticalOffset + y);
60
61 offset.Y = range(offset.Y, 0, _totalSize.Height - _screenSize.Height);
62 offset.X = range(offset.X, 0, _totalSize.Width - _screenSize.Width);
63
64 HorizontalOffset = offset.X;
65 VerticalOffset = offset.Y;
66
67 InvalidateArrange();
68 }
69
70 double range(double value, double value1, double value2)
71 {
72 var min = Math.Min(value1, value2);
73 var max = Math.Max(value1, value2);
74
75 value = Math.Max(value, min);
76 value = Math.Min(value, max);
77
78 return value;
79 }
80
81
82 const double _lineOffset = 30;
83 const double _wheelOffset = 90;
84
85 public void LineDown()
86 {
87 appendOffset(0, _lineOffset);
88 }
89
90 public void LineUp()
91 {
92 appendOffset(0, -_lineOffset);
93 }
94
95 public void LineLeft()
96 {
97 appendOffset(-_lineOffset, 0);
98 }
99
100 public void LineRight()
101 {
102 appendOffset(_lineOffset, 0);
103 }
104
105 public Rect MakeVisible(Visual visual, Rect rectangle)
106 {
107 throw new NotSupportedException();
108 }
109
110 public void MouseWheelDown()
111 {
112 appendOffset(0, _wheelOffset);
113 }
114
115 public void MouseWheelUp()
116 {
117 appendOffset(0, -_wheelOffset);
118 }
119
120 public void MouseWheelLeft()
121 {
122 appendOffset(0, _wheelOffset);
123 }
124
125 public void MouseWheelRight()
126 {
127 appendOffset(_wheelOffset, 0);
128 }
129
130 public void PageDown()
131 {
132 appendOffset(0, _screenSize.Height);
133 }
134
135 public void PageUp()
136 {
137 appendOffset(0, -_screenSize.Height);
138 }
139
140 public void PageLeft()
141 {
142 appendOffset(-_screenSize.Width, 0);
143 }
144
145 public void PageRight()
146 {
147 appendOffset(_screenSize.Width, 0);
148 }
149
150 public void SetVerticalOffset(double offset)
151 {
152 this.appendOffset(HorizontalOffset, offset - VerticalOffset);
153 }
154
155 public void SetHorizontalOffset(double offset)
156 {
157 this.appendOffset(offset - HorizontalOffset, VerticalOffset);
158 }
159 #endregion
160 }
View Code
基本上從代碼中也能看出IScrollInfo接口的互動流程,這裡就不多介紹了。
主界面代碼如下:
<ItemsControl ItemsSource="{Binding}" >
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderThickness="1" BorderBrush="Black" Margin="8" Width="150" Height="50">
<Rectangle Fill="{Binding}" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<local:MyWrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer CanContentScroll="True">
<ItemsPresenter />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
</ItemsControl>
需要注意的是,這兒需要設定<ScrollViewer CanContentScroll="True">,否則使用的不是邏輯滾動。
資料源代碼如下:
var brushes = from property in typeof(Brushes).GetProperties()
let value = property.GetValue(null)
select value;
this.DataContext = brushes.Take(100).ToArray();
由于使用了IscrollInfo接口,所有的滾動操作是自己實作的,這裡我是通過設定Panel的RenderTransFrom的X,Y偏移來實作滾動操作的。運作後看上去上和WrapPanel沒有什麼差別,但是由于是自己控制的滾動,加上動畫效果也隻是分分鐘的事情了,把上面代碼的RenderTransFrom的X,Y硬切換改成動畫切換即可:
protected override Size ArrangeOverride(Size finalSize)
{
var size = base.ArrangeOverride(finalSize);
if (ScrollOwner != null)
{
var yOffsetAnimation = new DoubleAnimation() { To = -VerticalOffset, Duration = TimeSpan.FromSeconds(0.3) };
_transForm.BeginAnimation(TranslateTransform.YProperty, yOffsetAnimation);
var xOffsetAnimation = new DoubleAnimation() { To = -HorizontalOffset, Duration = TimeSpan.FromSeconds(0.3) };
_transForm.BeginAnimation(TranslateTransform.XProperty, xOffsetAnimation);
ScrollOwner.InvalidateScrollInfo();
}
return _screenSize;
}
對于其它的Panel,如Grid,DockPanel等,基本上也可以按照這種方式實作,IScrollInfo接口處基本上可以保持不變,隻需要重寫MeasureOverride和ArrangeOverride兩個函數即可。一個特殊的控件是StackPanel,由于它本身已經實作了IScrollInfo接口,也就是說它本身就有自身的自繪制滾動的方案,并且沒有提供接口在覆寫自身的自繪制滾動,是以我們需要自己寫一個StackPanel,好在實作StackPanel并不難,由于篇幅有限,這裡我懶得繼續寫了,讀者朋友自己實作吧。至于那些非Panel的控件,實作就更簡單了,也留着讀者朋友自己實作吧。