BackacheEngineerの技術的な備忘録

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

XAML: カスタムコントロール(今風なToggleButton)

いよいよもってカスタムコントロールである。ControlTemplate の知識等の集大成に近い。 なお、ほかにもいろいろな知識がいるので、結局こいつは難しい部類の印象。 そして、これをまとめるからには「ResourceDictionary」もまとめないといけない。 実のところ、外観だけなら「ResourceDictionary」のが適していると思う。

カスタムコントロールを使う場面は「独自のバインディングターゲットを作りたい」、これに尽きると思う。 このバインディングターゲットを作りたいというのは、「元のプロパティが依存関係プロパティじゃないから、カスタムコントロールで仕方なく依存関係プロパティにしなくちゃいけない」も含まれる。

独自バインディングターゲットはいらない、ってなれば、まぁResourceDictionaryでいいかなぁと個人的には思う。

目次

カスタムコントロール

カスタムコントロールというのは、言ってしまえば自作コントロールである。 ユーザコントロールとちょっと似ているけれど、明確に違うところがある。

機能 バインディングターゲット作成 Name属性付与 内部コード実装 外観の実装
カスタムコントロール
ユーザコントロール ×
ResourceDictionary × ×

しれっと ResourceDictionary いるけどこれも入れておいた方がよさそうだったので入れた。 他にも差異がありそうな項目がいそうだが、とりあえずこれだけで。

見てのとおり、1点明確な違いはName属性が付与できるかできないかである。 Name属性については過去にまとめた。

backacheengineer.hatenablog.com

したがって、Name属性をつけられるという点においてはカスタムコントロールはライブラリ向きということになる。 そのツール独自の画面でいい、となるとまぁユーザコントロールでよいだろう。 バインディングターゲットが必要ないのであれば、ユーザコントロールである必要もなく、ResourceDictionary を使えばよい。

デザインを作るうえでの参考

デザインを作るのは困難だと思う。参考になるデザインは他のツールか、 Windowsの設定とかのデザインを見ればなんとなーくふわっと「こんなの作りたい」というのは出てくる。 けれども、ControlTemplate でそれを実現しようとなると普通に難しいという・・・。

そういう時には generic.xaml というファイルが下記フォルダにいるはずなので参考になる(はず)。 途中の番号部分はたぶんwin10のビルド番号かなんかなので、人によって違う。

C:\Program Files (x86)\Windows Kits\10\DesignTime\CommonConfiguration\Neutral\UAP\10.0.19041.0\Generic

こいつには VisualStateManager とか ツールのテーマカラーとかがなんと定義されているのだ。 おーすごい。ただ量もえげつない。でも参考になる(はず)。

今風な ToggleButton を作る

ということでカスタムコントロールで今風な ToggleButton を作ってみる。 普通の ToggleButton だと押してある状態がON、そうでないときOFFみたいなボタンなんだけれども、 今風だとなんかバーになってると思う。それを作る。

既に作ったのでまずは結果から。まだまだ改良の余地ありなやつだが、とりあえずできたんで・・・。

f:id:BackacheEngineer:20210507173552g:plain
今風なToggleButton

必要な機能

必要な機能は以下のとおり。実際に使う場合も含めて考えてる。

  1. ON状態かOFF状態か判別可能なテキストを表示する。
  2. このテキストは設定で非表示にできる。
  3. ON・OFFの文字列は設定で変更できる。(ON、OFFが常にツールとして最良とは限らないため)
  4. デフォはテキスト表示、ON/OFF の文字列とする。
  5. トグルボタンの外観は今風なバー形式。
  6. ON時は青、OFF時は赤の背景を使用する。
  7. バー部分をクリックすると、ちゃんとバーが動いているように見せる。

1 は TextBlock をそのまま使う。

テキストの切り替えは Trriger でサクッと。

2 はソースとなる変数を作って設定する。ターゲットは「Visibility」というenum。 これなら表示状態、非表示状態、非表示かつ描画領域非確保状態を扱える。

3 はソースとなる変数を作って設定する。ターゲットはもちろん Text である。

4 は内部コード内で初期化する。

5 は Slider を使って実装していく。

6 は Slider の外観を変更して対応する。

7 はどうしようか悩んだ結果、透明な ToggleButton をバーにかぶせることにした(おい)。 これでクリック動作は確保できるので、あとは VisualState を実装してやって、自分で動かす。

XAMLC#コード

まずはXAMLから。 まとめていて気が付いたけど、これサイズが変更できないのでは・・・。とりあえずこのままで。 本当に改善の余地が多いなぁ。

コメントが一切ないのは、カスタムコントロールに対してコピペする可能性を考慮した。 日本語含むカスタムコントロールの「Generic.xaml」はビルドに失敗するんで。 (1文足せば日本語も使える。単純に「UTF-8」しかできないのが問題で、なんか「shift-jis」もいけるように1文だけ足せばいいはず)

<ControlTemplate x:Key="sliderTemplate" TargetType="Slider">
    <Grid Width="70" Height="40">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>

        <Rectangle Name="checkedRect"
                   Grid.Column="2"
                   Fill="Red">
        </Rectangle>

        <Thumb Name="barThumb"
                 Grid.Column="1"
                 Width="24">
        </Thumb>

        <Rectangle Name="uncheckedRect"
                   Grid.Column="0"
                   Width="{Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=Slider}, Path=Value}"
                   Fill="Blue"/>
    </Grid>
</ControlTemplate>

<Style TargetType="{x:Type local:ModernToggle}">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type local:ModernToggle}">
                <Grid HorizontalAlignment="{TemplateBinding HorizontalAlignment}"
                      VerticalAlignment="{TemplateBinding VerticalAlignment}"
                      Margin="{TemplateBinding Margin}">

                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroup x:Name="CheckedState">
                            <VisualState x:Name="UnChecked">
                                <Storyboard>
                                    <DoubleAnimation Storyboard.TargetName="slider"
                                                     Storyboard.TargetProperty="Value"
                                                     To="0" Duration="0:0:0.2"/>
                                </Storyboard>
                            </VisualState>
                            <VisualState x:Name="Checked">
                                <Storyboard>
                                    <DoubleAnimation Storyboard.TargetName="slider"
                                                     Storyboard.TargetProperty="Value"
                                                     To="46" Duration="0:0:0.2"/>
                                </Storyboard>
                            </VisualState>
                        </VisualStateGroup>
                    </VisualStateManager.VisualStateGroups>
                    
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="Auto" />
                        <ColumnDefinition Width="Auto" />
                    </Grid.ColumnDefinitions>

                    <TextBlock Grid.Column="0"
                               Name="stateText"
                               Text="{Binding RelativeSource={RelativeSource Mode=TemplatedParent},Path=UnCheckedStateText}"
                               FontSize="{TemplateBinding FontSize}"
                               Margin="24"
                               Visibility="{Binding RelativeSource={RelativeSource Mode=TemplatedParent},Path=StateTextVisibility}"
                               HorizontalAlignment="Center"
                               VerticalAlignment="Center"/>
                    
                    <Slider Grid.Column="1"
                            Name="slider"
                            Template="{StaticResource sliderTemplate}"
                            Value="0"
                            Maximum="46"/>

                    <ToggleButton Name="toggle"
                                  IsChecked="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=IsChecked}"
                                  Grid.Column="1"
                                  Opacity="0"/>
                </Grid>

                <ControlTemplate.Triggers>
                    <DataTrigger Binding="{Binding ElementName=toggle, Path=IsChecked}" Value="True">
                        <Setter TargetName="stateText" Property="Text" Value="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=CheckedStateText}"/>
                    </DataTrigger>
                </ControlTemplate.Triggers>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

続いてC#コード。 ここで注意すべきなのが、「バインディングソースは依存関係プロパティじゃなくていいこと」。 別に依存関係プロパティで定義してもいいんだけど、無駄なコードは避けておきたい。 継承元が ContentControl なので、IsChecked を実装している。 実は ToggleButton を継承すると IsChecked は独自実装する必要ないんだけど、VisualState の操作がうまくできなかった。 力不足だ。この継承をうまいこと使えれば、たぶんだが VisualState をもっと楽できる。

public class ModernToggle : ContentControl
{
    public event EventHandler CheckedChanged;

    /// <summary>
    /// text of checked state 
    /// </summary>
    public string CheckedStateText { get; set; } = "ON";
    /// <summary>
    /// text of unchecked state
    /// </summary>
    public string UnCheckedStateText { get; set; } = "OFF";
    /// <summary>
    /// visibility statetext
    /// </summary>
    public Visibility StateTextVisibility { get; set; } = Visibility.Visible;

    /// <summary>
    /// dependencyproperty IsChecked
    /// </summary>
    public static DependencyProperty IsCheckedProperty { get; private set; }
    /// <summary>
    /// true: Checked, false: Unchecked
    /// </summary>
    public bool IsChecked
    {
        get => (bool)GetValue(IsCheckedProperty);
        set => SetValue(IsCheckedProperty, value);
    }

    /// <summary>
    /// static constructor
    /// </summary>
    static ModernToggle()
    {
        IsCheckedProperty = DependencyProperty.Register(nameof(IsChecked), typeof(bool), typeof(ModernToggle), new PropertyMetadata(false, OnCheckedChanged));
    }

    /// <summary>
    /// event of CheckedChanged for DependencyProperty
    /// </summary>
    static void OnCheckedChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
    {
        (obj as ModernToggle).OnCheckedChanged(EventArgs.Empty);
    }

    /// <summary>
    /// event of CheckedChanged 
    /// </summary>
    protected virtual void OnCheckedChanged(EventArgs args)
    {
        VisualStateManager.GoToState(this, IsChecked == true ? "Checked" : "UnChecked", true);
        CheckedChanged?.Invoke(this, args);
    }
}

まとめ

  • カスタムコントロールは難しい
  • カスタムコントロールはName属性がつけれるのでライブラリ向け
  • 実は参考になる xaml がいる

こんなもんだろうか。 ControlTemplate は本当に難しい。 今回の実装も、正直微妙だなと思っている。 うまいこと継承とか使いこなしたいところ。

あと、もっと今風にするなら丸くしたいなと。 Thumb コントロールの外観を丸くして、Rectangle の角を丸くすればもっと今風になるなって。 デザインは奥が深い。プログラマもデザインする時代となると、もっと本読まないといけないかもね。