1. 程式人生 > >[WPF自定義控制元件庫]瞭解如何自定義ItemsControl

[WPF自定義控制元件庫]瞭解如何自定義ItemsControl

1. 前言

對WPF來說ContentControl和ItemsControl是最重要的兩個控制元件。

顧名思義,ItemsControl表示可用於呈現一組Item的控制元件。大部分時候我們並不需要自定義ItemsControl,因為WPF提供了一大堆ItemsControl的派生類:HeaderedItemsControl、TreeView、Menu、StatusBar、ListBox、ListView、ComboBox;而且配合Style或DataTemplate足以完成大部分的定製化工作,可以說ItemsControl是XAML系統靈活性的最佳代表。不過,既然它是最常用的控制元件,那麼掌握一些它的原理對所有WPF開發者都有好處。

我以前寫過一篇文章介紹如何模仿ItemsControl,並且部落格園也已經很多文章深入介紹ItemsControl的原理,所以這篇文章只介紹簡單的自定義ItemsControl知識,通過重寫GetContainerForItemOverride和IsItemItsOwnContainerOverride、PrepareContainerForItemOverride函式並使用ItemContainerGenerator等自定義一個簡單的IItemsControl控制元件。

2. 介紹作為例子的Repeater

作為教學我建立了一個繼承自ItemsControl的控制元件Repeater(雖然簡單,用來展示資料的話好像還真的有點用)。它的基本用法如下:

<local:Repeater>
    <local:RepeaterItem Content="1234999"
                        Label="Product ID" />
    <local:RepeaterItem Content="Power Projector 4713"
                        Label="IGNORE" />
    <local:RepeaterItem Content="Projector (PR)"
                        Label="Category" />
    <local:RepeaterItem Content="A very powerful projector with special features for Internet usability, USB"
                        Label="Description" />
</local:Repeater>

也可以不直接使用Items,而是繫結ItemsSource並指定DisplayMemberPath和LabelMemberPath。

public class Product
{
    public string Key { get; set; }

    public string Value { get; set; }

    public static IEnumerable<Product> Products
    {
        get
        {
            return new List<Product>
            {
                new Product{Key="Product ID",Value="1234999" },
                new Product{Key="IGNORE",Value="Power Projector 4713" },
                new Product{Key="Category",Value="Projector (PR)" },
                new Product{Key="Description",Value="A very powerful projector with special features for Internet usability, USB" },
                new Product{Key="Price",Value="856.49 EUR" },
            };

        }
    }
}
<local:Repeater ItemsSource="{x:Static local:Product.Products}"
                DisplayMemberPath="Value"
                LabelMemberPath="Key"/>

執行結果如下圖:

3. 實現

確定好需要實現的ItemsControl後,通常我大致會使用三步完成這個ItemsControl:

  1. 定義ItemContainer
  2. 關聯ItemContainer和ItemsControl
  3. 實現ItemsControl的邏輯

3.1 定義ItemContainer

派生自ItemsControl的控制元件通常都會有匹配的子元素控制元件,如ListBox對應ListBoxItem,ComboBox對應ComboBoxItem。如果ItemsControl的Items內容不是對應的子元素控制元件,ItemsControl會建立對應的子元素控制元件作為容器再把Item放進去。

<ListBox>
    <system:String>Item1</system:String>
    <system:String>Item2</system:String>
</ListBox>

例如這段XAML中,Item1和Item2是ListBox的LogicalChildren,而它們會被ListBox封裝到ListBoxItem,ListBoxItem才是ListBox的VisualChildren。在這個例子中,ListBoxItem可以稱作ItemContainer

ItemsControl派生類的ItemContainer控制元件要使用父元素名稱做字首、-Item做字尾,例如ComboBox的子元素ComboBoxItem,這是WPF約定俗成的做法(不過也有TabControl和TabItem這種例外)。Repeater也派生自ItemsControl,Repeatertem即為Repeater的ItemContainer控制元件。

public RepeaterItem()
{
    DefaultStyleKey = typeof(RepeaterItem);
}

public object Label
{
    get => GetValue(LabelProperty);
    set => SetValue(LabelProperty, value);
}

public DataTemplate LabelTemplate
{
    get => (DataTemplate)GetValue(LabelTemplateProperty);
    set => SetValue(LabelTemplateProperty, value);
}
<Style TargetType="local:RepeaterItem">
    <Setter Property="Padding"
            Value="8" />
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="local:RepeaterItem">
                <Border BorderBrush="{TemplateBinding BorderBrush}"
                        BorderThickness="{TemplateBinding BorderThickness}"
                        Background="{TemplateBinding Background}">
                    <StackPanel Margin="{TemplateBinding Padding}">
                        <ContentPresenter Content="{TemplateBinding Label}"
                                          ContentTemplate="{TemplateBinding LabelTemplate}"
                                          VerticalAlignment="Center"
                                          TextBlock.Foreground="#FF777777" />
                        <ContentPresenter x:Name="ContentPresenter" />
                    </StackPanel>
                </Border>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

上面是RepeaterItem的程式碼和DefaultStyle。RepeaterItem繼承ContentControl並提供Label、LabelTemplate。DefaultStyle的做法參考ContentControl。

3.2 關聯ItemContainer和ItemsControl

<Style TargetType="{x:Type local:Repeater}">
    <Setter Property="ScrollViewer.VerticalScrollBarVisibility"
            Value="Auto" />
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type local:Repeater}">
                <Border BorderBrush="{TemplateBinding BorderBrush}"
                        BorderThickness="{TemplateBinding BorderThickness}"
                        Background="{TemplateBinding Background}">
                    <ScrollViewer Padding="{TemplateBinding Padding}">
                        <ItemsPresenter />
                    </ScrollViewer>
                </Border>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

如上面XAML所示,Repeater的ControlTemplate中需要提供一個ItemsPresenter,用於指定ItemsControl中的各Item擺放的位置。

[StyleTypedProperty(Property = "ItemContainerStyle", StyleTargetType = typeof(RepeaterItem))]
public class Repeater : ItemsControl
{
    public Repeater()
    {
        DefaultStyleKey = typeof(Repeater);
    }

    protected override bool IsItemItsOwnContainerOverride(object item)
    {
        return item is RepeaterItem;
    }

    protected override DependencyObject GetContainerForItemOverride()
    {
        var item = new RepeaterItem();
        return item;
    }
}

Repeater的基本程式碼如上所示。要將Repeater和RepeaterItem關聯起來,除了使用約定俗成的命名方式告訴使用者,還需要使用下面兩步:

重寫 GetContainerForItemOverride
protected virtual DependencyObject GetContainerForItemOverride () 用於返回Item的Container。Repeater返回的是RepeaterItem。

重寫 IsItemItsOwnContainer
protected virtual bool IsItemItsOwnContainerOverride (object item),確定Item是否是(或者是否可以作為)其自己的Container。在Repeater中,只有RepeaterItem返回True,即如果Item的型別不是RepeaterItem,就將它作使用RepeaterItem包裝起來。

完成上面幾步後,為Repeater設定ItemsSource的話Repeater將會建立對應的RepeaterItem並新增到自己的VisualTree下面。

使用 StyleTypedPropertyAttribute

最後可以在Repeater上新增StyleTypedPropertyAttribute,指定ItemContainerStyle的型別為RepeaterItem。新增這個Attribute後在Blend中選擇“編輯生成專案的容器(ItemContainerStyle)”就會預設使用RepeaterItem的樣式。

3.3 實現ItemsControl的邏輯

public string LabelMemberPath
{
    get => (string)GetValue(LabelMemberPathProperty);
    set => SetValue(LabelMemberPathProperty, value);
}

/*LabelMemberPathProperty Code...*/

protected virtual void OnLabelMemberPathChanged(string oldValue, string newValue)
{
    // refresh the label member template.
    _labelMemberTemplate = null;
    var newTemplate = LabelMemberPath;

    int count = Items.Count;
    for (int i = 0; i < count; i++)
    {
        if (ItemContainerGenerator.ContainerFromIndex(i) is RepeaterItem RepeaterItem)
            PrepareRepeaterItem(RepeaterItem, Items[i]);
    }
}

private DataTemplate _labelMemberTemplate;

private DataTemplate LabelMemberTemplate
{
    get
    {
        if (_labelMemberTemplate == null)
        {
            _labelMemberTemplate = (DataTemplate)XamlReader.Parse(@"
            <DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation""
                        xmlns:x=""http://schemas.microsoft.com/winfx/2006/xaml"">
                    <TextBlock Text=""{Binding " + LabelMemberPath + @"}"" VerticalAlignment=""Center""/>
            </DataTemplate>");
        }

        return _labelMemberTemplate;
    }
}

protected override void PrepareContainerForItemOverride(DependencyObject element, object item)
{
    base.PrepareContainerForItemOverride(element, item);

    if (element is RepeaterItem RepeaterItem )
    {
        PrepareRepeaterItem(RepeaterItem,item);
    }
}

private void PrepareRepeaterItem(RepeaterItem RepeaterItem, object item)
{
    if (RepeaterItem == item)
        return;

    RepeaterItem.LabelTemplate = LabelMemberTemplate;
    RepeaterItem.Label = item;
}

Repeater本身沒什麼複雜的邏輯,只是模仿DisplayMemberPath添加了LabelMemberPathLabelMemberTemplate屬性,並把這個屬性和RepeaterItem的Label和'LabelTemplate'屬性關聯起來,上面的程式碼即用於實現這個功能。

LabelMemberPath和LabelMemberTemplate
Repeater動態地建立一個內容為TextBlock的DataTemplate,這個TextBlock的Text繫結到LabelMemberPath

XamlReader相關的技術我在如何使用程式碼建立DataTemplate這篇文章裡講解了。

ItemContainerGenerator.ContainerFromIndex
ItemContainerGenerator.ContainerFromIndex(Int32)返回ItemsControl中指定索引處的Item,當Repeater的LabelMemberPath改變時,Repeater首先強制更新了LabelMemberTemplate,然後用ItemContainerGenerator.ContainerFromIndex找到所有的RepeaterItem並更新它們的Label和LabelTemplate。

PrepareContainerForItemOverride
protected virtual void PrepareContainerForItemOverride (DependencyObject element, object item) 用於在RepeaterItem新增到UI前為其做些準備工作,其實也就是為RepeaterItem設定LabelLabelTemplate而已。

4. 結語

實際上WPF的ItemsControl很強大也很複雜,原始碼很長,對初學者來說我推薦參考Moonlight中的實現(Moonlight, an open source implementation of Silverlight for Unix systems),上面LabelMemberTemplate的實現就是抄Moonlight的。Silverlight是WPF的簡化版,Moonlight則是很久沒維護的Silverlight的簡陋版,這使得Moonlight反而成了很優秀的WPF教學材料。

當然,也可以參考Silverlight的實現,使用JustDecompile可以輕鬆獲取Silverlight的原始碼,這也是很好的學習材料。不過ItemsControl的實現比Moonlight多了將近一倍的程式碼。

5. 參考

ItemsControl Class (System.Windows.Controls) Microsoft Docs
moon_ItemsControl.cs at master
ItemContainer Control Pattern - Windows applications _ Microsoft D