WPF自定义控件第一 - 进度条控件

原文:WPF自定义控件第一 - 进度条控件

本文主要针对WPF新手,高手可以直接忽略,更希望高手们能给出一些更好的实现思路。

前期一个小任务需要实现一个类似含步骤进度条的控件。虽然对于XAML的了解还不是足够深入,还是摸索着做了一个。这篇文章介绍下实现这个控件的步骤,最后会放出代码。还请高手们给出更好的思路。同时也希望这里的思路能给同道中人一些帮助。话不多说,开始正题。

实现中的一些代码采用了网上现有的方案,代码中通过注释标记了来源,再次对代码作者一并表示感谢。

 

首先放一张最终效果图。

WPF自定义控件第一 - 进度条控件

 

节点可以被点击

WPF自定义控件第一 - 进度条控件

 

控件会根据绑定的集合数据生成一系列节点,根据集合中的数据还可以按比例放置节点的位置。

节点的实体代码如下:

public class FlowItem
{
    public FlowItem()
    {
    }

    public FlowItem(int id, string title,double offsetRate)
    {
        Id = id;
        Title = title;
        OffsetRate = offsetRate;
    }

    public int Id { get; set; }

    public string Title { get; set; }

    public double OffsetRate { get; set; }
}

其中三个属性分别代表了节点的编号,标题和偏移量(用来确定节点在整个条中的位置)。

 

控件的实现

忘了很久以前在哪看到过一句话,说设计WPF控件时不一定按照MVVM模式来设计,但一定要确保设计的控件可以按照MVVM模式来使用。本控件也是本着这么目标来完成。

控件实现为TemplatedControl,个人认为这种方式更为灵活,做出来的控件可复用度更高。反之UserControl那种组合控件的方式更适用于一个项目内复用的需要。

遵循一般的原则,我们将控件单独放于一个项目中。在TemplatedControl项目中,“模板”即XAML内容一般都放置在一个名为Generic.xaml文件中,这个文件应该放置在解决方案Themes文件夹下。

如果要使用Themes/Generic.xaml这个默认的模板样式地址,要保证AssemblyInfo.cs中如下语句:

[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)]

另外也不要试图修改Themes/Generic.xaml这个文件位置了。虽然据说是可以改,但不知道会不会有潜在问题。RoR流行时常说“约定大于配置”,就把这个路径当作一个约定就好了。

一般来说控件的模板也不宜直接放到Generic.xaml而是每个控件都定义到一个单独的xaml文件,然后在Generic中用如下方式进行引用。这样可以有效的防止Generic.xaml文件变的过大,也可以更利于项目模板的查找和修改(直接定位到相关文件即可,博主常用Ctrl+T键定位文件,也不知道这个是VS的功能还是Resharper的功能)。

<ResourceDictionary Source="/Zq.Control;component/Flow/FlowControl.xaml"></ResourceDictionary>

 这样控件的模板就可以移入FlowControl.xaml中,接着我们就看一下这里面控件模板的定义:

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:system="clr-namespace:System;assembly=mscorlib"
                    xmlns:flow="clr-namespace:Zq.Control.Flow">

    <flow:MultiThicknessConverter x:Key="FlowMultiThicknessConverter"></flow:MultiThicknessConverter>
    <flow:MultiWidthAnimationConverter x:Key="FlowMultiWidthAnimationConverter"></flow:MultiWidthAnimationConverter>
    <system:Double x:Key="FlowDoubleZero">0</system:Double>
    <Duration x:Key="FlowDuration">0:0:1.5</Duration>

    <Style TargetType="{x:Type flow:FlowControl}">
        <Setter Property="NodeWidth" Value="30"></Setter>
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type flow:FlowControl}">
                    <Grid VerticalAlignment="Top">
                        <Grid.Triggers>
                            <EventTrigger RoutedEvent="SizeChanged">
                                <BeginStoryboard>
                                    <Storyboard >
                                        <DoubleAnimation Storyboard.TargetName="Bar" Storyboard.TargetProperty="Tag"
                                             From="0" To="1" Duration="{StaticResource FlowDuration}"/>
                                    </Storyboard>
                                </BeginStoryboard>
                            </EventTrigger>
                        </Grid.Triggers>
                        <Rectangle x:Name="Bar" Panel.ZIndex="0" StrokeThickness="0" Fill="#61d0b3" 
                                   HorizontalAlignment="Left" VerticalAlignment="Top"
                                   Height="{TemplateBinding BarHeight}">
                            <Rectangle.Margin>
                                <MultiBinding Converter="{StaticResource FlowMultiThicknessConverter}">
                                    <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="BarMarginLeft"></Binding>
                                    <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="BarMarginTop"></Binding>
                                    <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="BarMarginLeft"></Binding>
                                    <Binding Source="{StaticResource FlowDoubleZero}"></Binding>
                                </MultiBinding>
                            </Rectangle.Margin>
                            <Rectangle.Tag>
                                <system:Double>0.0</system:Double>
                            </Rectangle.Tag>
                            <Rectangle.Width>
                                <MultiBinding Converter="{StaticResource FlowMultiWidthAnimationConverter}">
                                    <Binding Path="ShadowWidth" RelativeSource="{RelativeSource TemplatedParent}" />
                                    <Binding Path="Tag" RelativeSource="{RelativeSource Self}" />
                                </MultiBinding>
                            </Rectangle.Width>
                        </Rectangle>

                        <ItemsPresenter />
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
        <Setter Property="ItemsPanel">
            <Setter.Value>
                <ItemsPanelTemplate>
                    <flow:FlowControlPanel AnimationDuration="{StaticResource FlowDuration}" />
                </ItemsPanelTemplate>
            </Setter.Value>
        </Setter>
    </Style>

</ResourceDictionary>

这个xaml文件的根节点是ResourceDictionary,表示其中内容是各种资源:样式,模板等等..

最开始的部分定义了模板中用到的一些Conveter及常量值。

然后就是TemplatedControl最核心的部分,Control Template的定义:

<Style TargetType="{x:Type flow:FlowControl}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type flow:FlowControl}">
                   ...控件模板内容...
                </ControlTemplate>
            </Setter.Value>
        </Setter>
<Style>

除了模板的定义还定义一些控件依赖属性的默认值,这些值也可以被用户显示设置的值所覆盖:

<Setter Property="NodeWidth" Value="30"></Setter>

这里我们定义了节点宽度的默认值。

 

控件的主体分两部分,一个是背景中绿色的矩形条,另一个是节点。节点是放置在Item中,通过ItemsPresenter显示出来的。这个后面会详细说。

模板是需要配合代码使用的,正如Petzold的第一本WPF书的标题Applications = Code + Markup。我们有了“标记”了,下面来看看“代码”:

public class FlowControl : ItemsControl
{

    static FlowControl()
    {
        DefaultStyleKeyProperty.OverrideMetadata(typeof(FlowControl), new FrameworkPropertyMetadata(typeof(FlowControl)));
    }

    #region dependency property

    private const double NodeWidthDefault = 30;

    public static readonly DependencyProperty NodeWidthProperty = DependencyProperty.Register(
        "NodeWidth", typeof(double), typeof(FlowControl),
        new PropertyMetadata(NodeWidthDefault));

    public double NodeWidth
    {
        get { return (double)GetValue(NodeWidthProperty); }
        set
        {
            SetValue(NodeWidthProperty, value);
        }
    }

    private const double BarHeightDefault = 10;

    public static readonly DependencyProperty BarHeightProperty = DependencyProperty.Register(
        "BarHeight", typeof(double), typeof(FlowControl), new PropertyMetadata(BarHeightDefault));

    public double BarHeight
    {
        get { return (double)GetValue(BarHeightProperty); }
        set { SetValue(BarHeightProperty, value); }
    }

    public static readonly DependencyProperty BarMarginLeftProperty = DependencyProperty.Register(
        "BarMarginLeft", typeof(double), typeof(FlowControl), new PropertyMetadata(0.0));

    public double BarMarginLeft
    {
        get { return (double)GetValue(BarMarginLeftProperty); }
        set { SetValue(BarMarginLeftProperty, value); }
    }

    public static readonly DependencyProperty BarMarginTopProperty = DependencyProperty.Register(
        "BarMarginTop", typeof(double), typeof(FlowControl), new PropertyMetadata(default(double)));

    private double BarMarginTop
    {
        get { return (double)GetValue(BarMarginTopProperty); }
        set { SetValue(BarMarginTopProperty, value); }
    }

    public static readonly DependencyProperty ShadowWidthProperty = DependencyProperty.Register(
        "ShadowWidth", typeof(double), typeof(FlowControl), new PropertyMetadata(default(double)));

    private double ShadowWidth
    {
        get { return (double)GetValue(ShadowWidthProperty); }
        set { SetValue(ShadowWidthProperty, value); }
    }

    public static readonly DependencyProperty AnimationDurationProperty = DependencyProperty.Register(
        "AnimationDuration", typeof(Duration), typeof(FlowControl), new PropertyMetadata(default(Duration)));

    public Duration AnimationDuration
    {
        get { return (Duration)GetValue(AnimationDurationProperty); }
        set { SetValue(AnimationDurationProperty, value); }
    }
	
	#endregion

    protected override Size MeasureOverride(Size constraint)
    {
        SetValue(BarMarginLeftProperty, NodeWidth / 2);
        SetValue(BarMarginTopProperty, (NodeWidth - BarHeight) / 2);
        SetValue(ShadowWidthProperty, constraint.Width - BarMarginLeft * 2);

        return base.MeasureOverride(new Size(constraint.Width, NodeWidth * 3));
    }

    protected override Size ArrangeOverride(Size arrangeBounds)
    {
        return base.ArrangeOverride(arrangeBounds);
    }

    #region route event

    //route event
    public static readonly RoutedEvent NodeSelectedEvent =
        FlowNodeControl.NodeSelectedEvent.AddOwner(typeof(FlowControl));

    public event RoutedEventHandler NodeSelected
    {
        add { AddHandler(FlowNodeControl.NodeSelectedEvent, value, false); }
        remove { RemoveHandler(FlowNodeControl.NodeSelectedEvent, value); }
    }

    #endregion

}

可以看到这个控件由ItemsControl继承而来,像是我们节点集合这种数据很适合用ItemsControl来展示,当然我们也可以直接继承自Control自己添加处理Items的一些功能,能实现同样的效果。

大部分代码主要定义依赖属性,路由事件以及重写了父类的布局方法。构造函数中那句将代码与我们的XAML模板做了关联:

DefaultStyleKeyProperty.OverrideMetadata(typeof(FlowControl), new FrameworkPropertyMetadata(typeof(FlowControl)));

这样控件的大体结构就有了。下面对其中的一些细节进行解释。

先来说说那个绿色进度条的实现,其最主要的一点就是要实现距离左上右三部分有适当的距离,而且这个距离应该随着节点小圆球半径的变化自动变化从而始终保持在节点圆球中心部位穿过。

这里的实现办法还是比较简陋的,但我没找到更好的办法:

代码中定义了2个依赖属性BarMarginLeft和BarMarginTop分别用来存储背景进度条左(右)上3部分的Margin值。这两个值是在重写的控件的布局方法MeasureOverride中根据节点的宽度进行计算得出的。

protected override Size MeasureOverride(Size constraint)
{
    SetValue(BarMarginLeftProperty, NodeWidth / 2);
    SetValue(BarMarginTopProperty, (NodeWidth - BarHeight) / 2);

    return base.MeasureOverride(new Size(constraint.Width, NodeWidth * 3));
}

然后使用了一个MultiBinding和转换器(和MultiBinding配合需要实现IMultiValueConverter的多值转换器)将上面的值绑定到进度条的Margin属性:

<Rectangle.Margin>
    <MultiBinding Converter="{StaticResource FlowMultiThicknessConverter}">
        <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="BarMarginLeft"></Binding>
        <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="BarMarginTop"></Binding>
        <Binding RelativeSource="{RelativeSource TemplatedParent}" Path="BarMarginLeft"></Binding>
        <Binding Source="{StaticResource FlowDoubleZero}"></Binding>
    </MultiBinding>
</Rectangle.Margin>

用到的多值转换器来自网上,代码如下:

//来源http://*.com/questions/6249518/binding-only-part-of-the-margin-property-of-wpf-control
public class MultiThicknessConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        return new Thickness(System.Convert.ToDouble(values[0]),
                             System.Convert.ToDouble(values[1]),
                             System.Convert.ToDouble(values[2]),
                             System.Convert.ToDouble(values[3]));
    }

    public object[] ConvertBack(object value, Type[] targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        return null;
    }
}

接着来看看进度条的动画(节点动画后文另说)是怎样实现的。WPF中实现动画无非就是通过Trigger触发一个BeginStoryboard,里面放一个Storyboard包装的动画。如下:

<Grid.Triggers>
    <EventTrigger RoutedEvent="SizeChanged">
        <BeginStoryboard>
            <Storyboard >
                <DoubleAnimation Storyboard.TargetName="Bar" Storyboard.TargetProperty="Tag"
                     From="0" To="1" Duration="{StaticResource FlowDuration}"/>
            </Storyboard>
        </BeginStoryboard>
    </EventTrigger>
</Grid.Triggers>

我们通过EventTrigger触发动画,而这个Event就是控件Size发生变化。可能你会比较奇怪为啥动画修改的不是Width属性而是一个名为Tag的属性。

真相是由于不能将动画的To的值设置为进度条的宽度(这个From和To的值只能是一个常量值),所以在网上找到这种变通的方案(出处见下面代码的注释),动画控制一个比例值。然后进度条的Width绑定到其宽度可能的最大值*比例值。From和To设置的是这个比例的最大最小值。

这个进度条宽度的最大值通过一个名为ShadowWidth的属性来存储。其也是在控件布局时被计算:

SetValue(ShadowWidthProperty, constraint.Width - BarMarginLeft * 2);

有了最大值和比例值,只需的通过一个多值绑定和转换器变为进度条的实际尺寸就可以了。

<Rectangle.Width>
    <MultiBinding Converter="{StaticResource FlowMultiWidthAnimationConverter}">
        <Binding Path="ShadowWidth" RelativeSource="{RelativeSource TemplatedParent}" />
        <Binding Path="Tag" RelativeSource="{RelativeSource Self}" />
    </MultiBinding>
</Rectangle.Width>

多值转换器实现很简单,就是把传入参数相乘并返回:

// *.com/questions/2186933/wpf-animation-binding-to-the-to-attribute-of-storyboard-animation
public class MultiWidthAnimationConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        double result = 1.0;
        for (int i = 0; i < values.Length; i++)
        {
            if (values[i] is double)
                result *= (double)values[i];
        }

        return result;
    }

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
    {
        throw new Exception("Not implemented");
    }
}

进度条基本上就这些内容了。下面看看节点的实现。

 

节点的布局主要通过一个自定义的Panel实现:

public class FlowControlPanel : Panel
{
    public static readonly DependencyProperty AnimationDurationProperty = DependencyProperty.Register(
        "AnimationDuration", typeof (Duration), typeof (FlowControlPanel), new PropertyMetadata(default(Duration)));

    public Duration AnimationDuration
    {
        get { return (Duration) GetValue(AnimationDurationProperty); }
        set { SetValue(AnimationDurationProperty, value); }
    }

    protected override Size MeasureOverride(Size availableSize)
    {
        var s = base.MeasureOverride(availableSize);
        foreach (UIElement element in this.Children)
        {
            element.Measure(availableSize);
        }
        return availableSize;
    }
    protected override Size ArrangeOverride(Size finalSize)
    {
        const double y = 0;
        double margin = 0;

        foreach (UIElement child in Children)
        {
            var newMargin = child.DesiredSize.Width / 2;
            if (newMargin > margin)
            {
                margin = newMargin;
            }
        }

        //double lastX = 0; todo
        foreach (ContentPresenter element in Children)
        {
            var node = element.Content as FlowItem;
            var x = Convert.ToDouble(node.OffsetRate) * (finalSize.Width - margin * 2);
            element.Arrange(new Rect(0, y, element.DesiredSize.Width, element.DesiredSize.Height));

            //方法来自http://www.mgenware.com/blog/?p=326
            var transform = element.RenderTransform as TranslateTransform;
            if (transform == null)
                element.RenderTransform = transform = new TranslateTransform();

            transform.BeginAnimation(TranslateTransform.XProperty, new DoubleAnimation(0, x, AnimationDuration));

        }
        return finalSize;
    }
}

给节点进行布局主要发生在ArrangeOverride中。取出每个节点对象中存储的OffsetRate的值乘以节点可以占据的最终宽度即节点的最终位置(x值,y值固定为0)。这个节点占据的宽度不是使用的进度条的宽度,而是用控件(面板)的最终尺寸减去一个最宽节点的宽度的一半乘二。因为节点标题的存在这个节点可分布的宽度要比进度条的宽度小。而且节点标题的宽度还不能太宽。

标题宽度通过Converter做了限制,因为进度条只能根据节点圆球的宽度进行适应,而无法根据节点实际宽度--即算上标题的宽度--进行适应,如果不限制标题长度,太长的标题会导致两头节点位置与进度条不匹配。

得到节点最终位置后,还通过一个小技巧把这个布局过程变成一个动画。动画的持续时间通过自定义模板中的依赖属性获取。传递给自定义模板的动画时间和传递给进度条动画的时间是同一个XAML常量值,这样更改持续时间时,可以很方便的让两个不同位置的动画保持一致。

通过如下的XAML将自定义Panel设置给控件的ItemsPanel属性(继承自ItemsControl控件)。

<Setter Property="ItemsPanel">
    <Setter.Value>
        <ItemsPanelTemplate>
            <flow:FlowControlPanel AnimationDuration="{StaticResource FlowDuration}" />
        </ItemsPanelTemplate>
    </Setter.Value>
</Setter>

这样设置给控件的节点项就可以按我们希望的方式显示出来。(下面代码是调用控件的代码,我们通过MVVM方式使用控件)

<flow:FlowControl HorizontalAlignment="Stretch" Margin="0 0 0 0"
                      Padding="30 0" AnimationDuration="0:0:0.5"
                      ItemsSource="{Binding Nodes}" >
                      ...

其中Nodes的声明和初始化(ViewModel中需要完成的):

private ObservableCollection<FlowItem> _nodes;

public ObservableCollection<FlowItem> Nodes
{
    get { return _nodes; }
    set { Set(() => Nodes, ref _nodes, value); }
}

_dataService.GetData(
    (item, error) =>
    {
        Nodes = new ObservableCollection<FlowItem>(

            new List<FlowItem>()
            {
                new FlowItem() {Id = 1, OffsetRate = 0, Title = "接到报修"},
                new FlowItem() {Id = 2, OffsetRate = 0.5, Title = "派工完成"},
                new FlowItem() {Id = 3, OffsetRate = 0.75, Title = "维修完成"},
                new FlowItem() {Id = 3, OffsetRate = 1, Title = "客户确认(我是特别长的标题)"},
            }
            );
    });

可以看到从ItemsControl继承的好处就是我们立刻有了ItemsSource属性,给其赋值后就可以在Panel中访问到这些item,进行布局等操作。另外我们也得到了通过ItemTemplate设置item模板的能力,这些都无需自己另外实现:

<flow:FlowControl.ItemTemplate>
    <DataTemplate>
        <flow:FlowNodeControl Id="{Binding Id}"
            NodeTitle="{Binding Title}"
            OffsetRate="{Binding OffsetRate}"></flow:FlowNodeControl>
    </DataTemplate>
</flow:FlowControl.ItemTemplate>

可以看到我们给Item赋的模板是另一个 TemplatedControl,这个控件用来表示一个进度节点:

这个控件模板结构很简单:

<Style TargetType="flow:FlowNodeControl">
    <Setter Property="NodeWidth" Value="30"></Setter>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="flow:FlowNodeControl">
                <StackPanel Orientation="Vertical">
                    <RadioButton x:Name="PART_NodeRadioButton" GroupName="FlowNodeGroup" Width="{TemplateBinding NodeWidth}" Height="{TemplateBinding NodeWidth}" Style="{StaticResource FlowNodeRadioButton}"></RadioButton>
                    <TextBlock Text="{TemplateBinding NodeTitle}" TextWrapping="Wrap" MaxWidth="{TemplateBinding NodeWidth,Converter={StaticResource FlowTitleMaxWidthConverter}}"></TextBlock>
                </StackPanel>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

其中就是一个RadioButton和一个TextBlock,分别用来表示绿色的节点圆圈和下面的的进度文本。另外给RadioButton定义了一套新的控件模板,用来实现进度节点被按下时的不同样式。

<Style x:Key="FlowNodeRadioButton" TargetType="RadioButton">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate>
                <Grid>
                    <Ellipse x:Name="Border" StrokeThickness="1">
                        <Ellipse.Fill>
                            <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
                                <GradientStop Color="#91c885" Offset="0" />
                                <GradientStop Color="#65b254" Offset="1" />
                            </LinearGradientBrush>
                        </Ellipse.Fill>
                    </Ellipse>
                    <Ellipse x:Name="CheckMark" Margin="4" Visibility="Collapsed">
                        <Ellipse.Fill>
                            <SolidColorBrush Color="#20830a" />
                        </Ellipse.Fill>
                    </Ellipse>


                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroup x:Name="CommonStates">
                            <VisualState x:Name="Normal" />
                            <VisualState x:Name="MouseOver">
                                <Storyboard>
                                    <ColorAnimationUsingKeyFrames 
                                        Storyboard.TargetName="Border" 
                                        Storyboard.TargetProperty="(Shape.Fill).(GradientBrush.GradientStops)[1].(GradientStop.Color)">
                                        <EasingColorKeyFrame KeyTime="0" Value="#399c24" />
                                    </ColorAnimationUsingKeyFrames>
                                </Storyboard>
                            </VisualState>
                            <VisualState x:Name="Pressed">
                                <Storyboard>
                                    <ColorAnimationUsingKeyFrames 
                                        Storyboard.TargetName="Border"
                                        Storyboard.TargetProperty="(Shape.Fill).(GradientBrush.GradientStops)[1].(GradientStop.Color)">
                                        <EasingColorKeyFrame KeyTime="0" Value="#20830a" />
                                    </ColorAnimationUsingKeyFrames>
                                </Storyboard>
                            </VisualState>
                            <VisualState x:Name="Disabled">
                                <Storyboard>
                                    <ColorAnimationUsingKeyFrames Storyboard.TargetName="Border"
                                            Storyboard.TargetProperty="(Shape.Fill).(GradientBrush.GradientStops)[0].(GradientStop.Color)">
                                        <EasingColorKeyFrame KeyTime="0" Value="#c1cbcb" />
                                    </ColorAnimationUsingKeyFrames>
                                    <ColorAnimationUsingKeyFrames Storyboard.TargetName="Border"
                                            Storyboard.TargetProperty="(Shape.Fill).(GradientBrush.GradientStops)[1].(GradientStop.Color)">
                                        <EasingColorKeyFrame KeyTime="0" Value="#a0abab" />
                                    </ColorAnimationUsingKeyFrames>
                                </Storyboard>
                            </VisualState>
                        </VisualStateGroup>
                        <VisualStateGroup x:Name="CheckStates">
                            <VisualState x:Name="Checked">
                                <Storyboard>
                                    <ObjectAnimationUsingKeyFrames 
                                        Storyboard.TargetName="CheckMark"
                                        Storyboard.TargetProperty="(UIElement.Visibility)">
                                        <DiscreteObjectKeyFrame KeyTime="0" Value="{x:Static Visibility.Visible}" />
                                    </ObjectAnimationUsingKeyFrames>
                                </Storyboard>
                            </VisualState>
                            <VisualState x:Name="Unchecked" />
                            <VisualState x:Name="Indeterminate" />
                        </VisualStateGroup>
                    </VisualStateManager.VisualStateGroups>
                </Grid>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

节点控件的代码:

[TemplatePart(Name = "PART_NodeRadioButton", Type = typeof(RadioButton))]
public class FlowNodeControl : System.Windows.Controls.Control
{
    static FlowNodeControl()
    {
        DefaultStyleKeyProperty.OverrideMetadata(typeof(FlowNodeControl), new FrameworkPropertyMetadata(typeof(FlowNodeControl)));
    }

    #region Dependency Property

    public static readonly DependencyProperty OffsetRateProperty = DependencyProperty.Register(
        "OffsetRate", typeof(double), typeof(FlowNodeControl), new PropertyMetadata(default(double)));

    public double OffsetRate
    {
        get { return (double)GetValue(OffsetRateProperty); }
        set { SetValue(OffsetRateProperty, value); }
    }

    public static readonly DependencyProperty NodeTitleProperty = DependencyProperty.Register(
        "NodeTitle", typeof(string), typeof(FlowNodeControl), new PropertyMetadata(string.Empty));

    public string NodeTitle
    {
        get { return (string)GetValue(NodeTitleProperty); }
        set { SetValue(NodeTitleProperty, value); }
    }

    //用于向上通知哪个Node被点击
    public static readonly DependencyProperty IdProperty = DependencyProperty.Register(
        "Id", typeof(int), typeof(FlowNodeControl), new PropertyMetadata(default(int)));

    public int Id
    {
        get { return (int)GetValue(IdProperty); }
        set { SetValue(IdProperty, value); }
    }

    private const double NodeWidthDefault = 30;
    public static readonly DependencyProperty NodeWidthProperty = DependencyProperty.Register(
        "NodeWidth", typeof(double), typeof(FlowNodeControl), new PropertyMetadata(NodeWidthDefault));

    public double NodeWidth
    {
        get { return (double)GetValue(NodeWidthProperty); }
        set { SetValue(NodeWidthProperty, value); }
    }

    #endregion

    private RadioButton nodeRadioButton;

    public override void OnApplyTemplate()
    {
        if (nodeRadioButton != null)
        {
            nodeRadioButton.Click -= nodeRadioButton_Click;
        }

        base.OnApplyTemplate();

        nodeRadioButton = GetTemplateChild("PART_NodeRadioButton") as RadioButton;

        if (nodeRadioButton != null)
        {
            nodeRadioButton.Click += nodeRadioButton_Click;
        }
    }

    void nodeRadioButton_Click(object sender, RoutedEventArgs e)
    {
        RaiseEvent(new RoutedEventArgs(NodeSelectedEvent,this));
    }

    //route event
    public static readonly RoutedEvent NodeSelectedEvent = EventManager.RegisterRoutedEvent(
            "NodeSelected", RoutingStrategy.Bubble,
            typeof(RoutedEventHandler),
            typeof(FlowNodeControl));

    public event RoutedEventHandler NodeSelected
    {
        add { AddHandler(NodeSelectedEvent, value); }
        remove { RemoveHandler(NodeSelectedEvent, value); }
    }
}

其中这行:

[TemplatePart(Name = "PART_NodeRadioButton", Type = typeof(RadioButton))]

说明控件模板中需要定义一个名为PART_NodeRadioButton的RadioButton,因为WPF允许控件使用者自行替换控件模板,这样的声明可以提示模板创建者模板中这个元素对于控件必不可少一定要存在。

最后一个需要介绍的功能就是点击进度节点触发控件中订阅事件的方法。

事件的来源是我们这个节点控件FlowNodeControl中的RadioButton。为了让事件可以向上传播在FlowNodeControl中定义了一个路由事件NodeSelected:

//route event
public static readonly RoutedEvent NodeSelectedEvent = EventManager.RegisterRoutedEvent(
        "NodeSelected", RoutingStrategy.Bubble,
        typeof(RoutedEventHandler),
        typeof(FlowNodeControl));

public event RoutedEventHandler NodeSelected
{
    add { AddHandler(NodeSelectedEvent, value); }
    remove { RemoveHandler(NodeSelectedEvent, value); }
}

为了能在RadioButton被点击时触发这个路由事件,在代码获取RadioButton对象并手动给它关联事件处理(事件处理即触发路由事件):

public override void OnApplyTemplate()
{
    if (nodeRadioButton != null)
    {
        nodeRadioButton.Click -= nodeRadioButton_Click;
    }

    base.OnApplyTemplate();

    nodeRadioButton = GetTemplateChild("PART_NodeRadioButton") as RadioButton;

    if (nodeRadioButton != null)
    {
        nodeRadioButton.Click += nodeRadioButton_Click;
    }
}

如代码所示,OnApplyTemplate方法一般是获取模板中元素对应的对象的引用的地方。获取对象后给起Click事件添加处理。

接下来还需要把FlowNodeControl中的路由事件向上传递到FlowControl中,我们需要在FlowControl中定义路由事件,但不同于FlowNodeControl中,这里不是新注册一个路由事件,而是通过下面的语法告知系统FlowControl也可以处理NodeSelectedEvent事件,这样如果FlowNodeControl没有处理事件,事件将向上传播。

//route event
public static readonly RoutedEvent NodeSelectedEvent =
    FlowNodeControl.NodeSelectedEvent.AddOwner(typeof(FlowControl));

public event RoutedEventHandler NodeSelected
{
    add { AddHandler(FlowNodeControl.NodeSelectedEvent, value, false); }
    remove { RemoveHandler(FlowNodeControl.NodeSelectedEvent, value); }
}

这样我们在使用FlowControl控件时给其NodeSelected事件绑定一个Command就可以了:

<i:Interaction.Triggers>
    <i:EventTrigger EventName="NodeSelected">
        <command:EventToCommand Command="{Binding NodeClickCommand, Mode=OneWay}" PassEventArgsToCommand="True" />
    </i:EventTrigger>
</i:Interaction.Triggers>

在NodeClickCommand中可以获取被点击的节点(节点就是事件的原始触发源):

private RelayCommand<RoutedEventArgs> _nodeClickCommand;

public RelayCommand<RoutedEventArgs> NodeClickCommand
{
    get
    {
        return _nodeClickCommand
            ?? (_nodeClickCommand = new RelayCommand<RoutedEventArgs>(
                                  p =>
                                  {
                                      var aa = p;
                                      MessageBox.Show(((FlowNodeControl)aa.OriginalSource).NodeTitle);
                                  }));
    }
}

 

基本上上面这些就把整个控件设计实现使用介绍清楚了,希望能给WPF新手以帮助,也希望WPF大神能给与更好的解决方案拓展下博主的眼界。

 

代码下载

Github 

 

版权说明:本文版权归博客园和hystar所有,转载请保留本文地址。文章代码可以在项目随意使用,如果以文章出版物形式出现请表明来源,尤其对于博主引用的代码请保留其中的原出处尊重原作者劳动成果。

 

上一篇:wpf-datagrid/listbox隔行换色


下一篇:C# DllImport 方法,出现 The system cannot find the file specified 错误