sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

WPF 依存関係プロパティを持ったカスタムコントロールの作り方

WPF で依存関係プロパティ (Dependency Property) を含んだカスタムコントロールを作成する方法について、まとめた記事です。

例えば、好みのデザインの TextBox コントロールに、独自の Binding 可能なプロパティを追加する場合、特に XAML の書き方(デザイン)の問題で作り方はある程度決まっています。ポイントを整理しました。

1.カスタムコントロールのファイルを準備する

最初に新しい項目の追加から「カスタムコントロール」を追加します。「カスタムコントロール」を追加すると「Themes」フォルダとその中に「Generic.xaml」というリソースディクショナリーのファイルが追加されます。

f:id:shikaku_sh:20200316130333p:plain

f:id:shikaku_sh:20200316130200p:plain:w400
カスタムコントロールの新規作成

Themes/Generic.xaml というファイルは、(Unity にはよくあるのですが)特別な動作をするファイルです。ファイル名を変更したり、フォルダー位置を変更しないでください。1

これは特殊なファイルです。フレームワークがこのファイルを自動的にアプリのリソースにマージします。ここで定義するリソースは、アプリケーション レベルでスコープ設定されます。「Microsoft Docs - カスタム XAML コントロール

カスタムコントロールで作成したファイル自体は、cs ファイル単体です。複数のコントロールの組み合わせる目的で主に利用されるユーザーコントロールに慣れていると、xaml ファイルと cs ファイルがセットになっていないため、自由度が失われたような感じがしますが、気にしなくてもよいと思います。

カスタムコントロールは、Generic.xaml にスタイルを定義して、cs ファイルに依存関係プロパティを定義するものだと思えば、いつもの2ファイル編集になり、ほとんど変わりないと思います。

2.カスタムコントロールの cs ファイル

カスタムコントロール.cs ファイルを開いてみると、静的コンストラクターDefaultStyleKey 依存関係プロパティが設定されています。

static コンストラクター()
{
    DefaultStyleKeyProperty.OverrideMetadata(typeof(カスタムコントロール名), new FrameworkPropertyMetadata(typeof(カスタムコントロール名)));
}

これが Generic.xaml連携するポイントになっています。ただ、どこにも Generic.xaml をインポートすると書いていないし、スタイルのキーを明示的に指定しているわけでもないです。

論理的な繋がりが見えないため、とっつきにくいと感じるかもしれないですが、Xceed.Wpf.Toolkit などの、主なカスタムコントロールもこのやり方を採用しているため、やってみて慣れるしかないのかと。

確認のついでに、今回のサンプルではテキストボックスのカスタムコントロール(DataGridCustomTextBoxColumn)を作成することにして、依存関係プロパティ SubText を追加してみます。

public class カスタムコントロール名 : TextBox
{
    public static readonly DependencyProperty SubTextProperty =
        DependencyProperty.Register(
            nameof(SubText),
            typeof(string),
            typeof(カスタムコントロール名),
            new UIPropertyMetadata(null)
        );

    public string SubText
    {
        get { return (string)GetValue(SubTextProperty); }
        set { SetValue(SubTextProperty, value); }
    }

    ...
}

3.Generic.xaml の編集

Generic.xaml ファイルは、次のようなテンプレートが最初に定義されています。基本的には、これがカスタムコントロールのスタイル定義になります。

<Style TargetType="{x:Type local:カスタムコントロール名}">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type local:カスタムコントロール名}">
                <Border Background="{TemplateBinding Background}"
                        BorderBrush="{TemplateBinding BorderBrush}"
                        BorderThickness="{TemplateBinding BorderThickness}">
                </Border>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

今回ならテキストボックスを作成するため、「Microsoft MsDocs - TextBox のスタイルとテンプレート」なんかを参考にしてスタイルを好みのとおりに編集します。

個人的なやり方ですが、Generic.xaml にいくつもカスタムコントロールを定義すると一覧性が悪くなったりします。(コンバーターやブラシ(色)の関連性がわかりづらくなりやすいです)なので、Themes フォルダーにリソースディクショナリーファイルを追加して、そっちのファイルに具体的なカスタムコントロール定義を書きます。

Generic.xaml ファイルは、リソースディクショナリーファイルをインポートして、カスタムコントロールのスタイル定義を一行追加するだけにしています。「Xceed.Wpf.Toolkit」でも、似たような方法が採用されています。

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:DependencyCustomControlSample">

    <ResourceDictionary.MergedDictionaries>
        <ResourceDictionary Source="/DependencyCustomControlSample;component/Themes/DataGridTextBoxColumnStyle.xaml" />
    </ResourceDictionary.MergedDictionaries>

    <Style TargetType="{x:Type local:DataGridCustomTextBoxColumn}" BasedOn="{StaticResource DataGridCustomTextBoxColumnDefaultStyle}" />

</ResourceDictionary>

コントロールのスタイルを書く 専用リソースファイル

1つのファイルにカスタムコントロールのスタイルを1つ定義するため、わかりやすい構成になります。

留意する必要があるのは、カスタムコントロールでは OverridesDefaultStyle を設定してはいけないです。

コントロールで OverridesDefaultStyle を true に設定した場合は、テーマスタイルによって提供される既定のコントロールテンプレートが抑制されます。「Microsoft Docs - FrameworkElement.OverridesDefaultStyle プロパティ

以下、サンプルとして作ってみたテキストボックスのカスタムコントロールです。新しく追加した依存関係プロパティも簡単に設定できています。

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:local="clr-namespace:DependencyCustomControlSample"
                    xmlns:local_conv="clr-namespace:DependencyCustomControlSample.ValueConverter">

    <local_conv:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />

    <SolidColorBrush x:Key="DisabledControlLightColor">#FFF4F4F4</SolidColorBrush>
    <SolidColorBrush x:Key="DisabledFontLightColor">#FF6D6D6D</SolidColorBrush>

    <Style x:Key="DataGridCustomTextBoxColumnDefaultStyle" TargetType="{x:Type local:DataGridCustomTextBoxColumn}">
        <!--
        <Setter Property="OverridesDefaultStyle" Value="True" />
        -->
        <Setter Property="SnapsToDevicePixels" Value="True" />
        <Setter Property="KeyboardNavigation.TabNavigation" Value="None" />
        <Setter Property="FocusVisualStyle" Value="{x:Null}" />
        <Setter Property="MinWidth" Value="120" />
        <Setter Property="MinHeight" Value="20" />
        <Setter Property="AllowDrop" Value="true" />
        <Setter Property="HorizontalAlignment" Value="Stretch" />
        <Setter Property="HorizontalContentAlignment" Value="Left" />
        <Setter Property="VerticalAlignment" Value="Stretch" />
        <Setter Property="VerticalContentAlignment" Value="Center" />
        <Setter Property="Margin" Value="-1" />

        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:DataGridCustomTextBoxColumn}">
                    <Border x:Name="MainBorder" Padding="2" 
                            BorderThickness="1" BorderBrush="Transparent"
                            Background="{TemplateBinding Background}">
                        <Grid>
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="*" />
                                <ColumnDefinition Width="Auto" />
                            </Grid.ColumnDefinitions>
                            <ScrollViewer x:Name="PART_ContentHost"
                                          Grid.Column="0" Margin="0"
                                          FontFamily="Courier New" FontSize="15"
                                          TextOptions.TextFormattingMode="Display" 
                                          TextOptions.TextRenderingMode="ClearType">
                            </ScrollViewer>
                            <TextBlock x:Name="SubTextBlock"
                                       Grid.Column="1"
                                       HorizontalAlignment="Left" VerticalAlignment="Bottom"
                                       Margin="0 0 1 1" 
                                       Text="{TemplateBinding SubText}"
                                       FontSize="8" Foreground="#FF666666"
                                       Visibility="{TemplateBinding IsEnabled, Converter={StaticResource BoolToVisibilityConverter}}" />
                        </Grid>
                    </Border>
                    <ControlTemplate.Triggers>
                        <Trigger Property="IsEnabled" Value="False">
                            <Setter TargetName="MainBorder" Property="Background" Value="{StaticResource DisabledControlLightColor}" />
                            <Setter TargetName="MainBorder" Property="BorderThickness" Value="0" />
                            <Setter Property="Foreground" Value="{StaticResource DisabledFontLightColor}" />
                        </Trigger>
                    </ControlTemplate.Triggers>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>

</ResourceDictionary>

補足

カスタムコントロールではなくて、ユーザーコントロールを使って同じことをやる場合を考えてみます。

ユーザーコントロールの中は、メインで利用するテキストボックスと、SubText を表示するサブのテキストボックスの2つのコントロール構成でしょうか。

<UserControl x:class="UserControlLikeCustomControl">
    <StackPanel>
        <TextBox x:Name="Main" />
        <TextBox x:Name="Sub" />
    </StackPanel>
</UserControl>

このコントロールは、テキストボックスのように利用するため、当然このような感じで利用します。

<local:UserControlLikeCustomControl Text="" SubText="">

UserControl はたくさんの依存関係プロパティを作成しないと連携できないことがわかると思います。TextSubTextUserControl はどのコントロールのどのプロパティと連携しているのかわかりません。TextBox を直接設定したカスタムコントロールと比べて、1つ1つの感じがでます。

<UserControl x:class="UserControlLikeCustomControl" x:Name="RootControl">
    <StackPanel>
        <TextBox x:Name="Main" 
                 Text="{Binding Text, ElementName=RootControl}" />
        <TextBox x:Name="Sub"
                 Text="{Binding SubText, ElementName=RootControl}" />
    </StackPanel>
</UserControl>

また、テキストボックス用の 添付プロパティ を利用しようと思ったときにも、型が一致せず利用できないといった問題も発生するのではないかと思います。

<local:UserControlLikeCustomControl 
    Text="" SubText=""
    Foreground="Green" Background="Red"
    local:MaskingTextBoxBehavior.Mask="^[0-9]+$">

細かく設定しようとしたときに、TextBox クラスではないため、プロパティの関連づけに面倒が増えてしまったり、うまく使えなかったりする。

ユーザーコントロールは、1つのコントロールを表現するよりも UserControl という、あくまでコントロールをまとめた動きをするクラスというのが基本なんだと思います。

逆に、カスタムコントロールは、すでにあるコントロールの拡張くらいの認識が基本で、ちょうどよい気がします。

サンプル

GitHub の「Samples」リポジトリーにまとめて公開しています。今回のサンプルは「DependencyCustomControlSample」です。

f:id:shikaku_sh:20200316130412p:plain:w400
データグリッドにちょうどいいテキストボックスを作りました

こんな感じで、いつでも単位が見えるコントロールを作成しました。テキストボックスを継承しているから IsEnabled などのプロパティとのバインディングも当然できる。

いちおう、サンプルデータは以下のような感じです。

public MainWindow()
{
    InitializeComponent();

    Records.Add(new SampleData { No = 1, Text = "ABCDEF", SubText = "ポイント", IsEnabled = false });
    Records.Add(new SampleData { No = 2, Text = "67890", SubText = "枚", IsEnabled = true });
    Records.Add(new SampleData { No = 3, Text = "100", SubText = "G", IsEnabled = false });
    Records.Add(new SampleData { No = 4, Text = "2000", SubText = "G", IsEnabled = true });

    DataContext = this;
}

参考


  1. 実際は、Themes/Generic.xaml という階層の前にフォルダーがあっても問題ないはずです。Xceed.Wpf.Toolkit などが参考になります。