寒假過完,在家真心什么都做不了,可能年齡大了,再想以前那樣能專心坐下來已經不行了。回來第一件事就是改了項目的一個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控件:

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代碼中的省略部分,我現在只貼出這部分代碼:

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>
對應的后台依賴屬性:

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