カスタムコントロール(CustomControl)を作る

もくじ
https://tera1707.com/entry/2022/02/06/144447

やりたいこと

以前WPFでUserControlを作ったが、CustomControlというものもあるらしい。

どうも、UserCotnrolは、作ったときに定義した見た目(Template)をそのまま使うのだが、CustomControlは、それを使うときに、使う側がTemplateを定義して、見た目を好きにできるらしい。

それって、いつも使ってる、元からあるコントロールに対してTemplateを指定して見た目をカスタムするということが、自分で作ったコントロールでもできるということか。

私のお仕事上、いろんなコントロールを組み合わせて独自のコントロールを作るということは頻繁にあるが、それで作ったコントロールに対して、使う側で好きなテンプレートを指定したいということがなかったので、CustomControlを使う機会がなかった。
(コントロールのライブラリを作って、他に配る、とかするときに使うんだろなと思っていた)

が、とりあえず知ってて損はないと思ったのでいっぺん作ってみる。

前提

WinUI3 1.2.221109.1 VisualStudio2022

実験用プロジェクト作成と、カスタムコントロールの追加

まずは、VisualStudioでひな型をつくるところから。

追加 > 新しい項目、で、カスタムコントロール(WinUI3)を選ぶ。

Themesフォルダの中に、Generic.xamlが追加される。

また、指定した名前のcsファイルが追加される。

カスタムコントロール追加直後の初期状態

WinUI3のテンプレート作った直後

追加直後

追加直後のGeneric.xaml
→初期状態のコードは別にいらんので、後で消しちゃう。

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:CustomControlJikken2">

    <Style TargetType="local:MySignal" >
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="local:MySignal">
                    <Border
                        Background="{TemplateBinding Background}"
                        BorderBrush="{TemplateBinding BorderBrush}"
                        BorderThickness="{TemplateBinding BorderThickness}">
                    </Border>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

追加直後のcsファイル(今回は「MySignal.cs」)
あとでここに必要なものを追記していく。

// Copyright (c) Microsoft Corporation and Contributors.
// Licensed under the MIT License.

using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Documents;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;

// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.

namespace CustomControlJikken2
{
    public sealed class MySignal : Control
    {
        public MySignal()
        {
            this.DefaultStyleKey = typeof(MySignal);
        }
    }
}

csのthis.DefaultStyleKey = typeof(MySignal);は消してはいけない。
このコードで、デフォルトのテンプレートを取ってきてる。

これで、カスタムコントロールの追加はOK。次は、実験的に、中身を変えてみる。

カスタムコントロールの中身を自分の好きなようにする

実験アプリの仕様

今回は、実験的に、信号機のようなカスタムコントロールを作ってみた。仕様はこんなかんじ。

  • 動き仕様
    • ボタンを押すと、赤信号、青信号が一定間隔で交互に点灯する。
    • 青信号、赤信号の点灯時間は、どちらも同じ。
    • 点灯時間は、指定できる。
  • 中身仕様
    • クラス名は「MySignal」。
    • 青信号用のGrid「PART_SignalGreen」と、
    • 赤信号用のGrid「PART_SignalRed」を持っている。
    • 信号点灯開始のためのボタン「PART_StartButton」を持っている。
    • 点灯時間を設定するためのプロパティ「IntervalTime」が生えている。

実験コード

コードは、こんな感じ。
https://github.com/tera1707/CustomControlJikken

generic.xaml

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:CustomControlJikken2">

    <Style TargetType="local:MySignal" >
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="local:MySignal">
                    <Grid Background="Gray" >
                        <Grid.RowDefinitions>
                            <RowDefinition />
                            <RowDefinition />
                            <RowDefinition />
                        </Grid.RowDefinitions>

                        <Grid x:Name="PART_SignalGreen" Grid.Row="0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Margin="5"
                                BorderBrush="{TemplateBinding BorderBrush}" Background="Green"
                                >
                            <TextBlock Text="Go" HorizontalAlignment="Center" VerticalAlignment="Center"/>
                        </Grid>

                        <Grid x:Name="PART_SignalRed" Grid.Row="1" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Margin="5"
                                BorderBrush="{TemplateBinding BorderBrush}" Background="Red"
                                >
                            <TextBlock Text="stop" HorizontalAlignment="Center" VerticalAlignment="Center"/>
                        </Grid>

                        <Button x:Name="PART_StartButton" Grid.Row="2" Content="SignalStart" HorizontalAlignment="Center" VerticalAlignment="Center"/>
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using System;
using Windows.UI;

namespace CustomControlJikken2
{
    // usercontrolは、画面(xaml)とそのコードビハインド(cs)ががっちり紐づいてるが、
    // CustomControlは、画面とコードががっちりは紐づいてない感じ?(最低限必要なものだけを、cs上で定義してる感じ。まただから、デフォルトstyleの中で名前を付けたコントロールがあっても、直接見えなくて、GetTemplateChildで探さないといけないっぽい。)
    // がっちり紐づいてないから、CustomControlを使う側で、styleとtemplateを指定できるのか。

    // やるべきこと
    // xamlで、主要な部品に名前をつける
    // コードで、その主要な部品に対応する変数を定義する
    // コードで、ApplyTemplateの中で、GetTemplateChild()でその名前を付けた部品を探して変数に入れる
    // DependencyPropertyを作成して、このコントロールを使う側から設定、取得できるプロパティを生やす(UserControlと同じ)
    // あとは、その部品たちを使ってやりたいことをやる

    public sealed class MySignal : Control
    {
        /// <summary>
        /// 信号切り替わり間隔(簡単にするために、赤→青、青→赤 どちらも同じ時間で切り替わるようにした)
        /// </summary>
        public TimeSpan IntervalTime
        {
            get => (TimeSpan)GetValue(IntervalTimeProperty);
            set { SetValue(IntervalTimeProperty, value); }
        }
        public static readonly DependencyProperty IntervalTimeProperty
            = DependencyProperty.Register(nameof(IntervalTime), typeof(TimeSpan), typeof(MySignal), new PropertyMetadata(TimeSpan.FromSeconds(5)));

        //------------------------------------------------------

        // 本コントロール中に必須で存在させたい部品(=generic.xamlに名前付きで作成したコントロール)
        private Grid signalGreen;
        private Grid signalRed;
        private Button startButton;

        private DispatcherTimer signalIntervalTimer = new DispatcherTimer();

        // null:信号停止中
        // false:赤信号表示中 true:青信号表示中
        private bool? IsGoing = null;
        private static readonly SolidColorBrush GreenBrush = new(Color.FromArgb(0xff, 0, 0xff, 0));
        private static readonly SolidColorBrush RedBrush = new(Color.FromArgb(0xff, 0xff, 0, 0));
        private static readonly SolidColorBrush GrayBrush = new (Color.FromArgb(0xff, 0xaa, 0xaa, 0xaa));

        public MySignal()
        {
            this.DefaultStyleKey = typeof(MySignal);
            signalIntervalTimer.Tick += SignalIntervalTimer_Tick;
        }

        private void SignalIntervalTimer_Tick(object sender, object e)
        {
            if (IsGoing == false)
            {
                // 青信号にする
                signalGreen.Background = GreenBrush;
                signalRed.Background = GrayBrush;
                IsGoing = true;
            }
            else
            {
                // 赤信号にする
                signalGreen.Background = GrayBrush;
                signalRed.Background = RedBrush;
                IsGoing = false;
            }
        }

        // 自分でoverrideを追加
        // (クラス名のところでAlt+Enterを押して出たメニューで「上書きを生成」を選び、出てきた一覧からOnApplyTemplateを選ぶ、でもいい)
        protected override void OnApplyTemplate()
        {
            base.OnApplyTemplate();

            if (startButton is not null)
                startButton.Click -= StartButton_Click;

            signalGreen = this.GetTemplateChild("PART_SignalGreen") as Grid;
            signalRed = this.GetTemplateChild("PART_SignalRed") as Grid;
            startButton = this.GetTemplateChild("PART_StartButton") as Button;

            if (startButton is not null)
                startButton.Click += StartButton_Click;
        }

        private void StartButton_Click(object sender, RoutedEventArgs e)
        {
            if (IsGoing == null)
            {
                signalIntervalTimer.Interval = IntervalTime;
                signalIntervalTimer.Start();
                IsGoing = false;
            }
            else
            {
                signalIntervalTimer.Stop();
                IsGoing = null;
            }

            // 一旦、全消灯にする
            signalGreen.Background = GrayBrush;
            signalRed.Background = GrayBrush;
        }
    }
}

Custom Controlを作るうえで実装すること

generic.xamlにデフォルトテンプレートを書く

CustomControlは、UserControlと違って、CustomControlを使うときに、外からStyle/Templateを指定することができる。

つまり、機能はそのままで、使う側が好きな見た目にすることができるのが特徴。

だがまず作るうえでは、デフォルトのstyle(使う側がstyleを指定しなかったときに使うstyle)を作る。

今回だと、上の実験コードのgeneric.xamlがそれ。

デフォルトテンプレートの中の主要部品に名前を付ける

CustomControlは、使うときに外からTemplateを指定することができるが、その外からのTemplateの中にも、主要(必須)な部品は備わっている必要がある。

上の実験コードだと、「PART_*****」という名前の付いた部品がそれ。

CustomControlを作るうえでは、主要(必須)な部品には「PART_」というのを頭につけるのが慣例の様子。

コードに、主要な部品に対応する変数を定義し、関連付ける

デフォルトのテンプレートは見た目を司るが、コードの方が動きを司る。

そのコードとテンプレート、動きと見た目を関連付けるために、コード側に、テンプレート中の主要部品に対応する変数を定義し、PART_***と名付けた名前を使って紐づける。

上の実験コードだと、MySignal.cs中の、

private Grid signalGreen;
private Grid signalRed;
private Button startButton;

が変数で、

signalGreen = this.GetTemplateChild("PART_SignalGreen") as Grid;
signalRed = this.GetTemplateChild("PART_SignalRed") as Grid;
startButton = this.GetTemplateChild("PART_StartButton") as Button;

が、名前で紐づけている部分。

デフォルトテンプレート中のコントロールのイベントを関連付ける

デフォルトテンプレート中に配置したコントロールのイベント(「Click」など)を拾って何かする必要があれば、そのイベントに、カスタムコントロールのクラスのメソッドを紐づける。

上の実験コードで言うと、

protected override void OnApplyTemplate()
{
    base.OnApplyTemplate();

    if (startButton is not null)
        startButton.Click -= StartButton_Click;  //★ココで古いイベントを解除し、

    signalGreen = this.GetTemplateChild("PART_SignalGreen") as Grid;
    signalRed = this.GetTemplateChild("PART_SignalRed") as Grid;
    startButton = this.GetTemplateChild("PART_StartButton") as Button; //★ココでイベントを取りたいボタンのコントロールを取得して、

    if (startButton is not null)
        startButton.Click += StartButton_Click; //★ココでイベントとクラス内のメソッドを紐づける
}

の★を付けた3か所の部分。

古いイベントを解除するのは、styleを適用されるたびにOnApplyTemplate()が行われて、イベントが残りっぱなしだとリークしてしまうからと思われる。(styleを途中で付け替えることはあまりないかもしれないが。)

必要なプロパティを生やす

カスタムコントロールに必要なプロパティを作成する。

ここのやり方は、UserControlと同じ。→(UserControlの作り方)https://qiita.com/tera1707/items/13a9ee14c2936c068a45

実験コードだと、

public TimeSpan IntervalTime
{
    get => (TimeSpan)GetValue(IntervalTimeProperty);
    set { SetValue(IntervalTimeProperty, value); }
}
public static readonly DependencyProperty IntervalTimeProperty
    = DependencyProperty.Register(nameof(IntervalTime), typeof(TimeSpan), typeof(MySignal), new PropertyMetadata(TimeSpan.FromSeconds(5)));

の部分。

これで、TimeSpan型の、初期値が5秒のプロパティが作成された。

あとは、用意したものを使ってやりたいことをコードに書く

今まで用意したデフォルトテンプレート、主要部品(PART_****)、イベント、プロパティを使って、やりたいことをコードに書けばOK。

今回の実験コードだと、ボタンを押したらタイマーをスタートして、タイマが満了するたびに青信号と赤信号を切り替えたい、そのタイマーが何秒で満了するかはプロパティの値を見て行いたい、ということなので、それを書いたのが上の実験コードとなる。

CustmoControlを使う側からテンプレートを入れてみる

ここまででCustomControlを作ってみたが、使うときに使う側が好きなテンプレートを指定できるのがCustomControlの特徴だったので、実際に使う側でテンプレートを指定して使ってみる。

MainWindow.xaml

<Window
    x:Class="CustomControlJikken2.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:CustomControlJikken2"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d">

    <GridView>
        <!-- styleを指定しないと、Generic.xamlに書いた標準styleで動く -->
        <local:MySignal Width="200" Height="250" IntervalTime="0:0:1" />

        <!-- Templateを指定すると、そのTemplateで動いてくれる -->
        <local:MySignal Width="200" Height="250" IntervalTime="0:0:3" >
            <local:MySignal.Style>
                <Style TargetType="local:MySignal">
                    <Setter Property="Template">
                        <Setter.Value>
                            <!-- 丸い信号 -->
                            <ControlTemplate>
                                <Grid Background="Gray">
                                    <Grid.RowDefinitions>
                                        <RowDefinition />
                                        <RowDefinition />
                                        <RowDefinition />
                                    </Grid.RowDefinitions>

                                    <Button x:Name="PART_StartButton"  Grid.Row="0" Content="Oshite Kudasai"/>
                                    
                                    <Grid x:Name="PART_SignalGreen" Grid.Row="1">
                                        <Ellipse Fill="ForestGreen"/>
                                    </Grid>
                                    
                                    <Grid x:Name="PART_SignalRed" Grid.Row="2">
                                        <Ellipse Fill="Pink"/>
                                    </Grid>
                                </Grid>
                            </ControlTemplate>
                        </Setter.Value>
                    </Setter>
                </Style>
            </local:MySignal.Style>
        </local:MySignal>
    </GridView>
    
</Window>

上に書いたのが、使う側でテンプレートをしてしないパターン。 下に書いたのが、使う側でテンプレートを指定するパターン。

動かしてみたのがこちら。

テンプレートを指定したほうの見た目がなんかちょっと思ったのと違うが、動きはしてくれてるのでよしとする。

終わり

とりあえず、動くCustomControlができた。

これだけでも何となくは動いているが、

見た目を制御するうえで、VisualStateMangaerというのを使うと、 xamlのほうで、動きを表現できたりするらしい。

次回それをやってみる。

メモ

ソリューション直下の「Theme」フォルダの中の「generic.xaml」が特別で、カスタムコントロールはここからデフォルトのテンプレート(x:keyがなくて、ターゲットタイプが作成したカスタムコントロールのクラス名のTemplate)を探しに行くとのこと。

→それはそうなのだが、なんとなく、自前で作ったResourceDictionary.xamlの中にデフォルトテンプレートを書いててもうまく動く気がする。(まだ試してない2023/6/15)

参考

MS公式

https://learn.microsoft.com/en-us/windows/apps/winui/winui3/xaml-templated-controls-csharp-winui-3

MS公式 Custom XAML Controls(WPF

https://learn.microsoft.com/en-us/archive/msdn-magazine/2019/may/xaml-custom-xaml-controls

かずきさんブログ カスタムコントロール

https://blog.okazuki.jp/entry/2014/09/08/221209

ユーザーコントロールの作り方(カスタムコントロールではなく、ゆーーざーコントロール

https://qiita.com/tera1707/items/13a9ee14c2936c068a45

カスタムコントロールは、デフォルトスタイルがgeneric.xamlに書かれてしまう。csと分離されるのでなんかいやだなと思っていたら、下記のような強引な?やり方で対策できるらしい。こんなのありか?でもやってみる

http://neareal.net/index.php?Programming%2F.NetFramework%2FWPF%2FCuston%20and%20UserControl%2FTechniqueOfAddingCustomControl