WPF自定義控件第一 - 進度條控件


本文主要針對WPF新手,高手可以直接忽略,更希望高手們能給出一些更好的實現思路。

前期一個小任務需要實現一個類似含步驟進度條的控件。雖然對於XAML的了解還不是足夠深入,還是摸索着做了一個。這篇文章介紹下實現這個控件的步驟,最后會放出代碼。還請高手們給出更好的思路。同時也希望這里的思路能給同道中人一些幫助。話不多說,開始正題。

實現中的一些代碼采用了網上現有的方案,代碼中通過注釋標記了來源,再次對代碼作者一並表示感謝。

 

首先放一張最終效果圖。

 

節點可以被點擊

 

控件會根據綁定的集合數據生成一系列節點,根據集合中的數據還可以按比例放置節點的位置。

節點的實體代碼如下:

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://stackoverflow.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>

多值轉換器實現很簡單,就是把傳入參數相乘並返回:

// stackoverflow.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所有,轉載請保留本文地址。文章代碼可以在項目隨意使用,如果以文章出版物形式出現請表明來源,尤其對於博主引用的代碼請保留其中的原出處尊重原作者勞動成果。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM