dullwhaleのメモ帳

何度も同じことを調べなくてよいように...

Windows App SDK(WinUI3)でXAMLから独自のプロパティを設定可能なUser Controlを作る

Windows App SDK(WinUI3)でコンポーネントをUser Controlとして作る - dullwhaleのメモ帳 の発展的な内容として、作成したUser ControlにXAMLから指定できるプロパティを実装する。

この記事ではUser ControlにXAMLからstringのプロパティと関数のプロパティを渡す部分を実装する。 また、実用性やUXよりも分かりやすさを優先している。 厳密には間違いである点が多々あるから、概要を掴んだらMSのドキュメントを見よ。

XAMLプロパティの制限

C#では関数をプリミティブ型の変数などと同等に扱わない。 C#の関数はfirst class objectだと説明されることがあるが、真に受けてはいけない。 これが原因で、プリミティブ型のプロパティの実装方法と関数のプロパティの実装方法が異なる。

DependencyPropertyとProperty wrapper

XAMLプロパティを機能させるためには、コード側でDependencyPropertyとProperty wrapperの実装が必要になる。 これらには命名規則や可視性の決まりがある。 変なこだわりを出すと機能しない可能性があることに注意せよ。 それぞれの要件は以下のように要約される。

表: User ControlでカスタムのXAMLプロパティを使えるよう、同User ControlのClassに実装すべきDependencyPropertyとProperty wrapperの要件

もの アクセス修飾子など 命名 役割
DependencyProperty DependencyProperty public static readonly ${XAML-property-name}Property XAMLのプロパティとして機能させるための既定のstaticメンバ
Property wrapper *
実装したい型
public ${XAML-property-name} C#から普通のClassプロパティかのようにアクセスするための上記のラッパー

単純な型のプロパティの例としてString型を実装する

ここで、最小限のサンプルコードを示す。 BazというUser ControlでSampleというプロパティを使えるよう、コードビハインドを実装する例を考える1。 つまり、XAMLで次のような記述ができるようにする。

<qux:Baz Sample="This is a test."/>

このとき、Sampleがプリミティブ型やそれに準ずる単純な型であるなら、基本の実装はそれぞれ次のようになる。 ここではString型とした。

using Microsoft.UI.Xaml;

// ----- 途中略 -----

// これがDependencyProperty
public static readonly DependencyProperty SampleProperty =
  DependencyProperty.Register(
    nameof(),
    typeof(Sample),
    typeof(Baz),
    null
  );

// これがProperty wrapper
public string Sample
{
  get { return (string)GetValue(SampleProperty); }
  set { SetValue(SampleProperty, (string)value); }
}

注意が必要な「関数」のプロパティを実装する

続いて、同User Controlに関数のプロパティProcedureを設定できるよう実装する例を考える。 XAMLでは次のような記述を想定する。

<qux:Baz Procedure="SomeFunctionImplementation"/>

関数のプロパティを実装するとき、それはイベントハンドラの追加を受け付けるプロパティを実装したことになる。 Property wrapperの違いに着目せよ。

using Microsoft.UI.Xaml;

// ----- 途中略 -----

public delegate void ProcedureType();

// これがDependencyProperty
public static readonly DependencyProperty ProcedureProperty =
  DependencyProperty.Register(
    nameof(),
    typeof(Procedure),
    typeof(Baz),
    null
  );

// これがProperty wrapper
public event ProcedureType Procedure;

予約語eventの意味が分からないなら、直ちにC#のイベントの機能について調べよ。 理解に必要な前提知識である。 自動で実装される暗黙のadd/removeアクセサーがあることにも注意せよ。

Property wrapperの実装例を見つつ、再度XAMLの記述に注目する。 このXAMLの記述の意味は、前の節のString型のものと打って変わって、「event source/senderであるProcedureにevent listener/handlerとしてSomeFunctionImplementationを追加する」となる。

<qux:Baz Procedure="SomeFunctionImplementation"/>

この仕様は恐らく、コントロールに関数を渡すシチュエーションがイベントハンドラを登録するぐらいしか無いからだろう。 関数とそれ以外の両方で同じXAMLの記述をするのにも関わらず、その意味が異なることが混乱の元である。

詳しいことはMSの公式ドキュメントを参照せよ。

Dependency properties overview - UWP applications | Microsoft Learn

この仕組みはwindows presentation foundation、universal windows platformでも利用されているから、WinUI 3ではない情報も参考になることがある。

もう少し実践的な実装例

ここでは、作成したUser Controlを利用するコードも含めたもう少しだけ実践的な実装例を示す。 分かりやすさを優先をしているため、これを参考にプロダクトのコードを書くことは避けるべきである。

Components/FormTextInput.xaml

<?xml version="1.0" encoding="utf-8"?>
<UserControl
    x:Class="foo.Components.FormTextInput"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:deimos.Components"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d">

    <StackPanel Orientation="Vertical">
        <TextBox x:Name="InputTextBox" LostFocus="InputLostFocus" />
        <TextBlock x:Name="ErrorMessage"/>
    </StackPanel>
</UserControl>

Components/FormTextInput.xaml.cs

using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System;

namespace foo.Components
{
  public sealed partial class FormTextInput : UserControl
  {

    // バリデーション行う関数の型
    // バリデーションに成功したらnull、失敗したらエラーメッセージをstringで返す
    public delegate string? ValidationFunction(string text);

    public FormTextInput()
    {
      this.InitializeComponent();
    }

    public static readonly DependencyProperty ValidatorProperty =
      DependencyProperty.Register(
        nameof(Validator),
        typeof(ValidationFunction),
        typeof(FormTextInput),
        null
      );
    public event ValidationFunction Validator;

    public static readonly DependencyProperty InitialMessageProperty =
      DependencyProperty.Register(
        nameof(InitialMessage),
        typeof(String),
        typeof(FormTextInput),
        null
      );
    public string InitialMessage
    {
      get { return (string)GetValue(InitialMessageProperty); }
      set {
        SetValue(InitialMessageProperty, (string)value);
        ErrorMessage.Text = value;
      }
    }

    // TextBoxがフォーカスを失った際に発火するイベントハンドラ
    private void InputLostFocus(object sender, RoutedEventArgs e)
    {
      if (Validator is null) return;
      var result = Validator(InputTextBox.Text);
      if (result == null)
      {
        ErrorMessage.Text = "";
        return;
      }
    }
  }
}

以下は、このUser Controlを使うPageのコード

BarPage.xaml

<?xml version="1.0" encoding="utf-8"?>
<Page
    x:Class="foo.BarPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:u="using:foo.Components"
    mc:Ignorable="d"
    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<!-- 途中略 -->
        <u:FormTextInput InitialMessage="first message" Validator="TitleValidator"/>
<!-- 以下略 -->

BarPage.xaml.cs

using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace foo
{

  public sealed partial class BarPage : Page
  {
    public BarPage()
    {
      this.InitializeComponent();
    }

    public string? TitleValidator(string text)
    {
      return text.Length <= 85 ? null : "85文字以内で入力してください";
    }
  }
}

  1. bazquxともにfoobarに続くメタ変数である。