原文: WPF自定義TextBox及ScrollViewer
寒假過完,在家真心什麼都做不了,可能年齡大了,再想以前那樣能專心坐下來已經不行了。回來第一件事就是改了項目的一個bug,最近又新增了一個新的功能,為程式添加了一個消息欄。消息欄有許多形式,要求是一個不需要曆史記錄,可以用滑鼠選中消息内容的消息欄。我首先想到的就是TextBox,我個人比較喜歡美觀的,有點強迫症,是以必須把TextBox中的ScrollViewer給改寫了,好吧,開始。
本博文分為三個部分,第一部分将描述如何改寫TextBox的布局,第二部分則描述如何改寫TextBox中的ScrollViewer樣式,第三部分則是對自定義樣式時産生的不明問題進行修補。
一、生成自定義TextBox控件
還是把這次寫的消息框做成使用者控件的形式,首先,前台簡單的XAML:

1 <TextBox x:Class="FS.PresentationManagement.Controls.MessageTextBox"
2 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
3 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Background="SkyBlue">
4 <TextBox.Template>
5 <ControlTemplate TargetType="{x:Type TextBox}">
6 <Grid Background="{TemplateBinding Background}">
7 <Grid.ColumnDefinitions>
8 <ColumnDefinition />
9 <ColumnDefinition Width="62" />
10 </Grid.ColumnDefinitions>
11 <!-- 文本框 -->
12 <ScrollViewer x:Name="PART_ContentHost">
13 <!-- 暫時省略 -->
14 </ScrollViewer>
15 <!-- 按鈕 -->
16 <Button Name="BTN_Clear" Margin="5" Grid.Column="1" Click="BTN_Clear_Click">
17 <Button.Template>
18 <ControlTemplate>
19 <Image Name="IMG_Clear" Source="../Pic/clear.png"/>
20 <ControlTemplate.Triggers>
21 <Trigger Property="IsMouseOver" Value="True">
22 <Setter TargetName="IMG_Clear" Property="Source" Value="../Pic/clear2.png" />
23 </Trigger>
24 <Trigger Property="Button.IsPressed" Value="True">
25 <Setter TargetName="IMG_Clear" Property="Source" Value="../Pic/clear3.png" />
26 </Trigger>
27 </ControlTemplate.Triggers>
28 </ControlTemplate>
29 </Button.Template>
30 </Button>
31 <Button Name="BTN_Close" Margin="0,-18,-25,0" VerticalAlignment="Top" Width="32" Height="32" Grid.Column="1" Click="BTN_Close_Click">
32 <Button.Template>
33 <ControlTemplate>
34 <Image Name="IMG_Close" Source="../Pic/close.png" />
35 <ControlTemplate.Triggers>
36 <Trigger Property="IsMouseOver" Value="True">
37 <Setter TargetName="IMG_Close" Property="Source" Value="../Pic/close2.png" />
38 </Trigger>
39 <Trigger Property="Button.IsPressed" Value="True">
40 <Setter TargetName="IMG_Close" Property="Source" Value="../Pic/close3.png" />
41 </Trigger>
42 </ControlTemplate.Triggers>
43 </ControlTemplate>
44 </Button.Template>
45 </Button>
46 </Grid>
47 </ControlTemplate>
48 </TextBox.Template>
49 </TextBox>
這個時候架構大概是,左邊将是一個ScrollViewer,用來顯示消息,右邊則是關閉和清理,兩個按鈕,至于按鈕的樣式,也已經進行了更改,每個按鈕使用三張圖檔來表示原始、停靠、按下三種狀态,需要注意,上面的XAML中按鈕的Source路徑是像“../Pic/xxx.png”,這是我把圖檔放到了目前檔案的--->上級目錄的--->Pic目錄下,是以實際上大家在使用的時候需要把這個屬性改成圖檔所在路徑。
背景代碼此時也非常簡單,隻是簡單地繼承了TextBox控件:

消息框基礎C#
1 namespace FS.PresentationManagement.Controls
2 {
3 /// <summary>
4 /// 文本消息框控件
5 /// </summary>
6 public partial class MessageTextBox : TextBox
7 {
8 public MessageTextBox()
9 {
10 InitializeComponent();
11 }
12 }
13 }
此時的效果如圖所示:
看起來還不錯吧,右上角的關閉按鈕由于截圖原因不是很清晰,稍後我們可以看到完整版的要好一些。
二、改造ScrollViewer控件
下面介紹本文的核心,如何自定義ScrollViewer控件,當然,我們的目标也不是把它改成什麼奇葩,隻是想把滾動條變得漂亮一點而已。如果使用WPF比較多的朋友會知道,許多控件都是由很多層一層一層地疊加形成可視化樹的,ScrollViewer也不例外,現在通過Template屬性可以完全自己定義其結構。
要進行改造的ScrollViewer控件就位于第一部分XAML代碼中的省略部分,我現在隻貼出這部分代碼:

自定義ScrollViewer模版
1 <ScrollViewer x:Name="PART_ContentHost">
2 <ScrollViewer.Template>
3 <ControlTemplate TargetType="{x:Type ScrollViewer}">
4 <Grid Background="{Binding Path=ScrollViewerBackground,RelativeSource={RelativeSource Mode=FindAncestor,AncestorType={x:Type TextBox}}}">
5 <Grid.ColumnDefinitions>
6 <ColumnDefinition />
7 <ColumnDefinition Width="Auto"/>
8 </Grid.ColumnDefinitions>
9 <Grid.RowDefinitions>
10 <RowDefinition/>
11 <RowDefinition Height="Auto"/>
12 </Grid.RowDefinitions>
13 <ScrollContentPresenter Margin="5,5,0,5" />
14 <ScrollBar Name="PART_VerticalScrollBar" Grid.Column="1" Value="{TemplateBinding VerticalOffset}" Maximum="{TemplateBinding ScrollableHeight}" ViewportSize="{TemplateBinding ViewportHeight}" Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}">
15 <ScrollBar.Template>
16 <ControlTemplate TargetType="{x:Type ScrollBar}">
17 <!-- 豎向滾動條寬度 -->
18 <Grid Width="10">
19 <Grid.RowDefinitions>
20 <RowDefinition Height="1" />
21 <RowDefinition />
22 <RowDefinition Height="1" />
23 </Grid.RowDefinitions>
24 <Track x:Name="PART_Track" Grid.Row="1" IsDirectionReversed="True">
25 <Track.DecreaseRepeatButton>
26 <!--上空白-->
27 <RepeatButton Command="ScrollBar.PageUpCommand" Opacity="0.5">
28 <RepeatButton.Template>
29 <ControlTemplate>
30 <Border Background="{Binding Path=ScrollBarBackground,RelativeSource={RelativeSource Mode=FindAncestor,AncestorType={x:Type TextBox}}}" CornerRadius="5,5,0,0" />
31 </ControlTemplate>
32 </RepeatButton.Template>
33 </RepeatButton>
34 </Track.DecreaseRepeatButton>
35 <Track.Thumb>
36 <!--滑塊-->
37 <Thumb>
38 <Thumb.Template>
39 <ControlTemplate>
40 <Border Background="{Binding Path=ScrollBarForeground,RelativeSource={RelativeSource Mode=FindAncestor,AncestorType={x:Type TextBox}}}" CornerRadius="5" />
41 </ControlTemplate>
42 </Thumb.Template>
43 </Thumb>
44 </Track.Thumb>
45 <Track.IncreaseRepeatButton>
46 <!--下空白-->
47 <RepeatButton Command="ScrollBar.PageDownCommand" Opacity="0.5">
48 <RepeatButton.Template>
49 <ControlTemplate>
50 <Border Background="{Binding Path=ScrollBarBackground,RelativeSource={RelativeSource Mode=FindAncestor,AncestorType={x:Type TextBox}}}" CornerRadius="0,0,5,5" />
51 </ControlTemplate>
52 </RepeatButton.Template>
53 </RepeatButton>
54 </Track.IncreaseRepeatButton>
55 </Track>
56 </Grid>
57 </ControlTemplate>
58 </ScrollBar.Template>
59 </ScrollBar>
60 <ScrollBar Name="PART_HorizontalScrollBar" Orientation="Horizontal" Grid.Row="1" Value="{TemplateBinding HorizontalOffset}" Maximum="{TemplateBinding ScrollableWidth}" ViewportSize="{TemplateBinding ViewportWidth}" Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}">
61 <ScrollBar.Template>
62 <ControlTemplate TargetType="{x:Type ScrollBar}">
63 <!-- 橫向滾動條高度 -->
64 <Grid Height="10">
65 <Grid.ColumnDefinitions>
66 <ColumnDefinition Width="1" />
67 <ColumnDefinition />
68 <ColumnDefinition Width="1" />
69 </Grid.ColumnDefinitions>
70 <Track x:Name="PART_Track" Grid.Column="1" IsDirectionReversed="False">
71 <Track.DecreaseRepeatButton>
72 <!--左空白-->
73 <RepeatButton Command="ScrollBar.PageLeftCommand" Opacity="0.5">
74 <RepeatButton.Template>
75 <ControlTemplate>
76 <Border Background="{Binding Path=ScrollBarBackground,RelativeSource={RelativeSource Mode=FindAncestor,AncestorType={x:Type TextBox}}}" CornerRadius="5,0,0,5" />
77 </ControlTemplate>
78 </RepeatButton.Template>
79 </RepeatButton>
80 </Track.DecreaseRepeatButton>
81 <Track.Thumb>
82 <!--滑塊-->
83 <Thumb>
84 <Thumb.Template>
85 <ControlTemplate>
86 <Border Background="{Binding Path=ScrollBarForeground,RelativeSource={RelativeSource Mode=FindAncestor,AncestorType={x:Type TextBox}}}" CornerRadius="5" />
87 </ControlTemplate>
88 </Thumb.Template>
89 </Thumb>
90 </Track.Thumb>
91 <Track.IncreaseRepeatButton>
92 <!--右空白-->
93 <RepeatButton Command="ScrollBar.PageRightCommand" Opacity="0.5">
94 <RepeatButton.Template>
95 <ControlTemplate>
96 <Border Background="{Binding Path=ScrollBarBackground,RelativeSource={RelativeSource Mode=FindAncestor,AncestorType={x:Type TextBox}}}" CornerRadius="0,5,5,0" />
97 </ControlTemplate>
98 </RepeatButton.Template>
99 </RepeatButton>
100 </Track.IncreaseRepeatButton>
101 </Track>
102 </Grid>
103 </ControlTemplate>
104 </ScrollBar.Template>
105 </ScrollBar>
106 </Grid>
107 </ControlTemplate>
108 </ScrollViewer.Template>
109 </ScrollViewer>
對應的背景依賴屬性:

ScrollViewer的背景依賴屬性
1 /// <summary>
2 /// 卷軸欄背景
3 /// </summary>
4 public Brush ScrollViewerBackground
5 {
6 get { return (Brush)GetValue(ScrollViewerBackgroundProperty); }
7 set { SetValue(ScrollViewerBackgroundProperty, value); }
8 }
9 public static readonly DependencyProperty ScrollViewerBackgroundProperty =
10 DependencyProperty.Register("ScrollViewerBackground", typeof(Brush), typeof(MessageTextBox), new PropertyMetadata(Brushes.LightBlue));
11
12 /// <summary>
13 /// 滾動條前景
14 /// </summary>
15 public Brush ScrollBarForeground
16 {
17 get { return (Brush)GetValue(ScrollBarForegroundProperty); }
18 set { SetValue(ScrollBarForegroundProperty, value); }
19 }
20 public static readonly DependencyProperty ScrollBarForegroundProperty =
21 DependencyProperty.Register("ScrollBarForeground", typeof(Brush), typeof(MessageTextBox), new PropertyMetadata(Brushes.RoyalBlue));
22
23 /// <summary>
24 /// 滾動條背景
25 /// </summary>
26 public Brush ScrollBarBackground
27 {
28 get { return (Brush)GetValue(ScrollBarBackgroundProperty); }
29 set { SetValue(ScrollBarBackgroundProperty, value); }
30 }
31 public static readonly DependencyProperty ScrollBarBackgroundProperty =
32 DependencyProperty.Register("ScrollBarBackground", typeof(Brush), typeof(MessageTextBox), new PropertyMetadata(Brushes.WhiteSmoke));
在構造前台界面時,首先,定義了一個Grid做為容器,并把它分成了四份,分别是内容、豎向滾動條、橫向滾動條、空白。其中,内容位于0行、0列,使用ScrollContentPresenter來表示将要顯示的内容;豎向滾動條位于0行1列,使用ScrollBar來表示;橫向滾動條位于1行0列,使用橫向(Orientation="Horizontal")的ScrollBar來表示。
然後,分别自定義ScrollBar的樣式。以豎向滾動條為例,自定義ControlTemplate,使用Grid作為容器,把滾動條分為三行,第一行為向上按鈕、第二行為滾動條、第三行為向下按鈕。我這裡出于美觀考慮,把兩個按鈕全省略了(實際上我們很少使用按鈕來上下滾動,大部分時候用的滑鼠中輪和拖動滑塊)。
滾動條是使用的Track控件,它又包含三個區域,分别是上空白、滑塊、下空白,我們來看個示例圖:
Track的DecreaseRepeatButton就是上空白、Thumb則是滑塊、IncreaseRepeatButton是下空白,分别對這三個控件進行樣式自定義即可改變其外觀。需要說明的是豎向滾動條需要把Track的IsDirectionReversed屬性設定為True,橫向則設定為False,不然會出現非常奇怪的現象(原因嘛,大家看屬性名的意思就知道了)。
最後,還有一點要解釋一下,大家發現許多控件有類似于“PART_***”的名稱,這些名稱請不要随意更改,這是WPF内置的特殊名稱,比如ScrollViewer的“PART_ContentHost”名稱,就是表示這個控件是用于裝載TextBox的文本内容的,并且經過測試,這個名稱隻能用于ScrollViewer或者Adorner、Decorator控件。如果沒有使用這些特殊名稱,可能就無法像你想象中那樣自動完成工作了。
三、修正一些問題
為什麼把這做為單獨的一環來讨論呢?因為前面的代碼已經能夠完成基本的工作了,而且出現的問題關系也并不是非常大。但是總會不爽,因為它就不那麼完善,是以,Fix It!
問題1:滑鼠中輪不能使ScrollViewer上下滾動
産生這個問題的原因非常詭異,如果不是修改ScrollViewer的Template來完全改變它,而是使用ScrollViewer.Resources來定義ScrollBar的Style則完全不會産生這種問題,但是這無法使的改變各控件的大小和布局。
另外,如果不是把ScrollViewer的Name設定為“PART_ContentHost”,而是使用<TextBlock Text="{TemplateBinding Text}" TextWrapping="{TemplateBinding TextWrapping}" />放置到ScrollViewer體中,就可以正常滾動。不過這時會導緻無法選中文本了,因為TextBlock中的文本是不支援選中的,特别注意到,這時的滾動效率非常低,滾動時畫面有明顯的遲鈍現象。同樣如果不把ScrollViewer的Name設定為“PART_ContentHost”,而用<Decorator Name="PART_ContentHost" />放置到ScrollViewer體中,雖然選中也能支援,但是依然不能滾動。
解決方法:
首先,為ScrollViewer添加Initialized="PART_ContentHost_Initialized"事件,背景增加新的屬性ScrollViewer以便使用:

初始化滾動條
1 /// <summary>
2 /// 消息體卷軸欄
3 /// </summary>
4 public ScrollViewer ScrollViewer { get; set; }
5
6 // 初始化滾動條
7 private void PART_ContentHost_Initialized(object sender, EventArgs e)
8 {
9 this.ScrollViewer = sender as ScrollViewer;
10 }
然後,自己實作中輪滾動方法,為ScrollViewer添加MouseWheel="PART_ContentHost_MouseWheel"事件,添加背景響應代碼:
private void PART_ContentHost_MouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e)
{
ScrollViewer.ScrollToVerticalOffset(ScrollViewer.VerticalOffset - (e.Delta >> 2));
}
便可以完美解決滑鼠中輪滾動問題。
問題2:滑鼠左鍵按住拖動不能使ScrollViewer滾動
一般來說,我們在任何文字相關軟體上,比如記事本、網頁等,隻要滑鼠左鍵按下拖動選中文本,如果滑鼠超出文本框可顯示範圍,便會自動向滑鼠所在方向滾動文本内容,以實作跨頁選中的效果。但是與問題1一樣,由于更改了ScrollViewer的Template,導緻這個通用功能也需要自己實作了。
解決方法:
首先,給前台的最上層元素TextBox添加SelectionChanged="TextBox_SelectionChanged"事件,以追蹤選中時滑鼠所在位置:
1 private void TextBox_SelectionChanged(object sender, RoutedEventArgs e)
2 {
3 if (ScrollViewer != null && this.SelectedText != "")
4 {
5 var point = System.Windows.Input.Mouse.GetPosition(ScrollViewer);
6 // 縱向位移
7 double y = point.Y;
8 if (y > 0)
9 {
10 y = y - ScrollViewer.ActualHeight;
11 if (y < 0) y = 0;
12 }
13 _ScrollY = y;
14 // 橫向位移
15 double x = point.X;
16 if (x > 0)
17 {
18 x = x - ScrollViewer.ActualWidth;
19 if (x < 0) x = 0;
20 }
21 _ScrollX = x;
22 }
23 }
說明一下,_ScrollX和_ScrollY是兩個成員屬性,它們分别用來記錄橫向、豎向的滑鼠位移,以用于決定是否滾動。隻有在超出ScrollViewer的範圍時,它們的值才會不為0,當小于0時表示要向上/左滾動,大于0時表示向下/右滾動,它們的絕對值越大,則滾動速度越快。
現在,滾動量已經能更新了,但滾動觸發條件還需要考慮。首先,橫向和豎向滾動相對于前台界面肯定是異步進行的;其次,已經在滾動時要實時根據滾動量來控制滾動速度;還有,滾動終止條件應該是滾動量為0或者已經滾動到了盡頭。好了,目标明确,需要添加兩個委托來分别處理橫向、豎向滾動,還需要兩個異步操作狀态來表示滾動是否結束,那麼,代碼擴充為:

滾動委托
1 // 堅向位移
2 private double _ScrollY
3 {
4 get { return _scrollY; }
5 set
6 {
7 _scrollY = value;
8 // 開啟滾動
9 if (_scrollY != 0 && (_ScrollYResult == null || _ScrollYResult.IsCompleted))
10 _ScrollYResult = _ScrollYAction.BeginInvoke(null, null);
11 }
12 }
13 private double _scrollY;
14
15 // 橫向位移
16 private double _ScrollX
17 {
18 get { return _scrollX; }
19 set
20 {
21 _scrollX = value;
22 // 開啟滾動
23 if (_scrollX != 0 && (_ScrollXResult == null || _ScrollXResult.IsCompleted))
24 _ScrollXResult = _ScrollXAction.BeginInvoke(null, null);
25 }
26 }
27 private double _scrollX;
28
29 // 豎向滾動
30 private Action _ScrollYAction;
31 private IAsyncResult _ScrollYResult;
32
33 // 橫向滾動
34 private Action _ScrollXAction;
35 private IAsyncResult _ScrollXResult;
也就是說,在_ScrollX和_ScrollY更新的時候,程式會進行一次判斷,如果滾動量不為0,而且委托調用沒有開始或者已經結束的時候,就調用委托,開始進行滾動。
最後,就是編寫滾動委托調用的函數了,分别有兩個函數,在函數内以100ms為一循環,不停地進行滾動,當滾動到結束或者滾動量已經為0時跳出循環,退出函數執行。

滾動函數體
1 // 豎向
2 private void ScrollYMethod()
3 {
4 double endOffset = 0;
5 if (_ScrollY < 0) // 向上滾動
6 endOffset = 0;
7 else // 向下滾動
8 ScrollViewer.Dispatcher.Invoke((Action)(() => endOffset = ScrollViewer.ScrollableHeight), null);
9 // 初始位置
10 double offset = 0;
11 ScrollViewer.Dispatcher.Invoke((Action)(() => offset = ScrollViewer.VerticalOffset), null);
12 // 開始滾動
13 while (offset != endOffset && _ScrollY != 0)
14 {
15 ScrollViewer.Dispatcher.Invoke((Action)(() =>
16 {
17 offset = ScrollViewer.VerticalOffset;
18 ScrollViewer.ScrollToVerticalOffset(ScrollViewer.VerticalOffset + _ScrollY);
19 }), null);
20 Thread.Sleep(100);
21 }
22 }
23
24 // 橫向
25 private void ScrollXMethod()
26 {
27 double endOffset = 0;
28 if (_ScrollX < 0) // 向左滾動
29 endOffset = 0;
30 else // 向右滾動
31 ScrollViewer.Dispatcher.Invoke((Action)(() => endOffset = ScrollViewer.ScrollableWidth), null);
32 // 初始位置
33 double offset = 0;
34 ScrollViewer.Dispatcher.Invoke((Action)(() => offset = ScrollViewer.HorizontalOffset), null);
35 // 開始滾動
36 while (offset != endOffset && _ScrollX != 0)
37 {
38 ScrollViewer.Dispatcher.Invoke((Action)(() =>
39 {
40 offset = ScrollViewer.HorizontalOffset;
41 ScrollViewer.ScrollToHorizontalOffset(ScrollViewer.HorizontalOffset + _ScrollX);
42 }), null);
43 Thread.Sleep(100);
44 }
45 }
當然不要忘記,把“_ScrollYAction = ScrollYMethod;”,“_ScrollXAction = ScrollXMethod;”這兩條委托初始化語句放到PART_ContentHost_Initialized事件處理函數中去,不然就白寫了。
至此,問題2也修改完畢。
問題3:自動滾動到底部
實際上這不是問題,而是一個改善,因為一般的滾動條都沒有這個功能。在實用中,假如消息是不停地填寫到消息框中,理想中應該是當拖動滾動條時,不會自動把滾動條更新到最近的一條消息,而是鎖定到拖動的位置(因為我想看的是拖動到的消息)。另外,如果想實時看新消息,就需要自動滾動到最底部。
當滾動條拖動到最底部時,就開啟自動滾動,每來一條新消息都滾動一次到最底部。如果滾動條不在最底部就不用自動滾動。實作方法就是為TextBox添加TextChanged="TextBox_TextChanged"事件,以判斷是否需要滾動:
1 private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
2 {
3 if (this.Text != "" && ScrollViewer != null)
4 {
5 // 如果已經拖到最底端,則固定住
6 if (ScrollViewer.ScrollableHeight == ScrollViewer.VerticalOffset)
7 ScrollViewer.ScrollToBottom();
8 }
9 }
終于碼完字了,多想隻貼代碼啊。放個圖,大家看看吧:
請無視上面的那個藍色橫條,那是我另外一個程式中的GridSplitter。這個自定義控件除了支援TextBox的所有屬性外,還可以改變配色(使用公開的屬性),另外還有點選清空、關閉按鈕的操作實作都不難,不貼了,感興趣的下載下傳源代碼看看吧。
源代碼:
ScrollTest.rar轉載請注明原址:
http://www.cnblogs.com/lekko/archive/2013/02/27/2935022.html