原文:
WPF自定义TextBox及ScrollViewer
寒假过完,在家真心什么都做不了,可能年龄大了,再想以前那样能专心坐下来已经不行了。回来第一件事就是改了项目的一个bug,最近又新增了一个新的功能,为程序添加了一个消息栏。消息栏有许多形式,要求是一个不需要历史记录,可以用鼠标选中消息内容的消息栏。我首先想到的就是TextBox,我个人比较喜欢美观的,有点强迫症,所以必须把TextBox中的ScrollViewer给改写了,好吧,开始。
本博文分为三个部分,第一部分将描述如何改写TextBox的布局,第二部分则描述如何改写TextBox中的ScrollViewer样式,第三部分则是对自定义样式时产生的不明问题进行修补。
一、生成自定义TextBox控件
还是把这次写的消息框做成用户控件的形式,首先,前台简单的XAML:

消息框基础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