BackacheEngineerの技術的な備忘録

技術系でいろいろ書けたらなーと

XAML: Templateについて その2

やっとこさ ControlTemplate が終わったんでまとめる。

目次

ControlTemplate でできること

まずは簡単なおさらいからで、ControlTemplate でできることは「外観の再定義」である。 DataTemplate でも外観の変更はできるのだけど、ControlTemplate はバインディング等も改造できるので、こちらの方ができることが多い。 DataTemplate は、バインディングする際に親定義から探してバインドするといったことができない。 つまり、TemplateBinding や RelativeSource の Mode=TemplatedParent が使えない。

基本的な ControlTemplate

ControlTemplate は Template プロパティに対して定義していく。

まずは 簡単に ControlTemplate を適用したものを載せる。

<Button Content="Click me!"
        Click="Button_Click"
        HorizontalAlignment="Center"
        VerticalAlignment="Center"
        Grid.Row="0"
        Grid.Column="1"
        BorderBrush="Red"
        BorderThickness="2">
    <Button.Template>
        <ControlTemplate>
            <Border>
                <TextBlock Text="temporary"/>
            </Border>
        </ControlTemplate>
    </Button.Template>
</Button>

上記の xaml コードは以下のような結果になる。

f:id:BackacheEngineer:20210506180346p:plain
簡単なControlTemplateを適用したボタン

見てわかるとおり、ただの TextBlock にしか見えない。たとえ Button 側に BorderBrush や BorderThickness を設定しても、うまいこと ControlTemplate に反映してくれるわけではないということである。しかも表示されているテキストは Button 側に設定したものではない。これではボタンとして見てわからないので、当然改造しなければならない。 かといって、ControlTemplate 内に定義している Border 等に決め決めで設定しても、その色やテキストしか使えず、使い物にならない。

これを解決するのが何度か出てきている TemplateBinding である。

TemplateBinding

TemplateBinding は前章で説明した内容を解決するものである。これは、「ControlTemplate を適用するコントロールのプロパティをバインディングソースにする」ということである。

つまり、TemplateBinding を使用すると下記のことを実現できる。

  • Button に設定した色を ControlTemplate に反映する
  • Button に設定した線の太さを ControlTemplate に反映する
  • Button に設定したテキストを ControlTemplate に反映する

もちろん、上記以外にもいろいろ実現できる。HorizontalAlignment も VerticalAlignment もバインディングソースにできる。 Button 側に設定した内容を ControlTemplate に引き継げるのである。

xaml コードを見ていく。

<Button Content="Click me!"
        Click="Button_Click"
        HorizontalAlignment="Center"
        VerticalAlignment="Center"
        Grid.Row="0"
        Grid.Column="2"
        BorderBrush="Red"
        BorderThickness="2">
    <Button.Template>
        <ControlTemplate>
            <Border BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}">
                <TextBlock Text="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=Content}"/>
            </Border>
        </ControlTemplate>
    </Button.Template>
</Button>

そして、適用した結果の画像が下記である。

f:id:BackacheEngineer:20210506181651p:plain
TemplateBinding を適用した結果

見てのとおり、枠線の色と太さ、それとテキストが見事に反映されている。 ここで、Text へのバインディングが TemplateBinding でないことに気が付く。 これは、Buttun の Content プロパティを TwoWay でバインディングしなければいけないのが理由である。 TemplateBinding は OneWay のバインディングを前提としており、TwoWay のバインディングができないためだ。 そして、TwoWay でのバインディングかつ TemplateBinding と同じ効果をもたらすのが RelativeSource による Binding というわけである。 正確に書くと、「Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Path=Content」といったように、Mode の選択が可能なのである。

ControlTemplate にバインドするプロパティが TwoWay でバインドしないといけないのかを見極める必要がある。 なお、OneWay の場合は RelativeSource でもよい。「Binding RelativeSource={RelativeSource Mode=TemplatedParet}, Mode=OneWay, Path=....」の省略形が「TemplateBinding」なのである。 (なお、ぶっちゃけ TwoWay が必要なやつで TemplateBinding するとエラーになったりするのでそのタイミングで RelativeSource の書き方を試せばいい・・・)

しかし、ここで気づくかもしれないが、実は外観はできても「ボタンを押したときの動作が見えない」のだ。 これは VisualState という状態遷移を考慮していないためである。この状態遷移を考慮するうえで必要なのが VisualStateManager だ。

VisualStateManager

VisualStateManager は外観状態を管理するものである(そのまんま)。注意したいのが、この VisualState はコントロールごとにあること。 今回の例に挙げ続けている Button コントロールは2グループの VisualStateGroup からなる。

  • CommonStates:Normal, PointerOver, Pressed, Disabled
  • FocusStates:Focused, Unfocused, PointerFocused

すべての状態を厳密に決める必要はない。特に動作がなくていい状態は空の指定ができるためだ。 そして、これまでの xaml コードはボタンの Template を直接いじくってきたが、ここにきてリソースにおけるように Style として定義していく。 Style としてリソースに定義しておけば、すべてのボタンに対して同じ外観、VisualState を反映できる。

以下が Style の xaml コードだ。一気に長くなるが、下記の実装になる。

  • 枠線の色は 赤色
  • 枠線の太さは 2
  • フォントサイズは 24
  • ボタンの外観と状態を再定義
  • 外観は Border と ContentPresenter
  • 押したときの状態はボタンの色が LighitBlue になる。
  • 操作不可状態にしたらボタンが灰色になって操作不可になる。
  • ボタンにフォーカスがあるとき、テキストが点線で囲まれる。

xaml コードを見ればわかるが、VisualState はプロパティの値を上書きしているだけである。なお、この上書きに対してはアニメーションを用いることが可能である。今回はアニメーションを使わず、瞬時にプロパティを上書きしていく。

<Style x:Key="btnStyle3" TargetType="Button">
    <Setter Property="BorderBrush" Value="Red"/>
    <Setter Property="BorderThickness" Value="2"/>
    <Setter Property="FontSize" Value="24"/>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="Button">
                <Border Name="border"
                        Background="{TemplateBinding Background}"
                        BorderBrush="{TemplateBinding BorderBrush}"
                        BorderThickness="{TemplateBinding BorderThickness}"
                        CornerRadius="12">
                    <!--#region 状態定義 -->
                    <!-- 各状態の定義がないと遷移できない。 -->
                    <!-- また、この定義はすべて書いておかないといけない。なお、不要なものはタグを空にしておけばよい。 -->
                    <!-- さらには、定義場所も注意がいる。下手すると全く反映されない。 -->
                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroup x:Name="CommonStates">
                            <VisualState x:Name="Normal"/>
                            <VisualState x:Name="PointerOver"/>
                            <!-- ボタン押下時の色変更 -->
                            <VisualState x:Name="Pressed">
                                <Storyboard>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="border"
                                                                   Storyboard.TargetProperty="Background">
                                        <DiscreteObjectKeyFrame KeyTime="0:0:0">
                                            <DiscreteObjectKeyFrame.Value>
                                                <SolidColorBrush Color="LightBlue"/>
                                            </DiscreteObjectKeyFrame.Value>
                                        </DiscreteObjectKeyFrame>
                                    </ObjectAnimationUsingKeyFrames>
                                </Storyboard>
                            </VisualState>
                            <!-- 操作不可時、灰色の Rectangle で覆う -->
                            <VisualState x:Name="Disabled">
                                <Storyboard>
                                    <ObjectAnimationUsingKeyFrames Storyboard.TargetName="disabledRect"
                                                                   Storyboard.TargetProperty="Visibility">
                                        <!-- DiscreteObjectKeyFrame : 特定の値に直接変更するやつ -->
                                        <!-- キーフレームは、アニメーションが開始されてから特定の時間が経過したときのターゲットプロパティの値を示す -->
                                        <DiscreteObjectKeyFrame KeyTime="0:0:0">
                                            <DiscreteObjectKeyFrame.Value>
                                                <Visibility>Visible</Visibility>
                                            </DiscreteObjectKeyFrame.Value>
                                        </DiscreteObjectKeyFrame>
                                    </ObjectAnimationUsingKeyFrames>
                                </Storyboard>
                            </VisualState>
                        </VisualStateGroup>

                        <VisualStateGroup x:Name="FocusedStates">
                            <VisualState x:Name="Unfocused"/>
                            <!-- フォーカスした際、瞬時に点線の透明度を 1 にするアニメーション -->
                            <VisualState x:Name="Focused">
                                <Storyboard>
                                    <DoubleAnimation Storyboard.TargetName="focusRectangle"
                                                     Storyboard.TargetProperty="Opacity"
                                                     To="1" Duration="0"/>
                                </Storyboard>
                            </VisualState>
                            <VisualState x:Name="PointerFocused"/>
                        </VisualStateGroup>
                    </VisualStateManager.VisualStateGroups>
                    <!--#endregion-->

                    <Grid>
                        <!-- 内容の本体 -->
                        <ContentPresenter Name="contentpresenter"
                                          Content="{TemplateBinding Content}"
                                          ContentTemplate="{TemplateBinding ContentTemplate}"
                                          Margin="{TemplateBinding Padding}"
                                          HorizontalAlignment="{TemplateBinding HorizontalAlignment}"
                                          VerticalAlignment="{TemplateBinding VerticalAlignment}"/>
                        <!-- フォーカス時の点線用Rectangle -->
                        <Rectangle Name="focusRectangle"
                                   Opacity="0"
                                   Stroke="{TemplateBinding Foreground}"
                                   StrokeThickness="1"
                                   StrokeDashArray="2 2"
                                   Margin="4"
                                   RadiusX="12"
                                   RadiusY="12"/>
                        <!-- 操作不可時の覆い隠す用Rectangle -->
                        <Rectangle Name="disabledRect"
                                   Fill="Black"
                                   Visibility="Collapsed"
                                   Opacity="0.5"
                                   RadiusX="12"
                                   RadiusY="12"/>
                    </Grid>
                </Border>
            </ControlTemplate>
        </Setter.Value>
    </Setter>

このスタイルを使って簡単なデモをする。

デモ内容

  • 左側のボタンを押すと真ん中のボタンが操作不可になる。
  • 右側のボタンを押すと真ん中のボタンが操作可になる。
  • 真ん中のボタンを押すと Debug.WriteLine で「Click」と出てくる。
  • 上記動作はイベントで書く。

まずは結果から。

f:id:BackacheEngineer:20210506193348g:plain
ボタンのVisualStateManagerのデモ

ちゃんとデモ内容どおりに外観が変化していることがわかる。 VisualSteteManager で状態を再定義してやれば、馴染みの動作になるというわけである。 なお、真ん中のボタンを押したときの画像は割愛する。

このデモに使った xaml コードと C# コードも載せておく。

xaml コード(スタイル部分は省略している。)

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="Auto" />
        <ColumnDefinition Width="1*" />
        <ColumnDefinition Width="1*" />
    </Grid.ColumnDefinitions>

    <Button Content="Disable right btn"
            Click="Button_Click_1"
            Grid.Column="0"
            Padding="12"
            HorizontalAlignment="Center"
            VerticalAlignment="Center"
            Style="{StaticResource btnStyle3}"/>

    <Button Name="operateBtn"
            Content="btn"
            Click="Button_Click"
            Grid.Column="1"
            Padding="12"
            HorizontalAlignment="Center"
            VerticalAlignment="Center"
            Style="{StaticResource btnStyle3}"/>

    <Button Content="Enable left btn"
            Click="Button_Click_2"
            Grid.Column="2"
            Padding="12"
            HorizontalAlignment="Center"
            VerticalAlignment="Center"
            Style="{StaticResource btnStyle3}"/>
</Grid>

C# コード

public partial class ButtonTemplateDemo : Window
{
    public ButtonTemplateDemo()
    {
        InitializeComponent();
    }

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        System.Diagnostics.Debug.WriteLine("Click!");
    }

    private void Button_Click_1(object sender, RoutedEventArgs e)
    {
        operateBtn.IsEnabled = false;
    }

    private void Button_Click_2(object sender, RoutedEventArgs e)
    {
        operateBtn.IsEnabled = true;
    }
}

まとめ

  • ControlTemplate は外観を再定義できる。
  • ControlTemplate を適用するコントロールのプロパティをバインドしたいとき、TemplateBinding か Binding の RelativeSource を用いる。
  • 上記の使い分けは バインド方向が OneWay か TwoWay かで決める。
  • 動作中の外観は VisualStateManager で再定義できる。
  • 動作中の外観にアニメーションが使える。

次はこの ControlTemplate の知識を使って「カスタムコントロール」を作っていく。 この知識があって初めて、カスタムコントロールが自在に作れるようになるのだ。