💨

MAUI で独自のコントロールを作る方法のメモ

2022/07/01に公開

Xamarin.Forms の後継の MAUI がカスタムのネイティブのコントロールにマッピングされたコントロールを作る方法としてカスタムレンダラーからハンドラーという方法になったので軽く見てみました。

仕組み

Microsoft.Maui.Controls.View あたりを継承してコントロール用のクラスを定義して Microsoft.Maui.Handlers.ViewHandler<TVirtualView, TPlatformView> を実装したハンドラーのクラスでネイティブのコントロールとのマッピングを行います。コントロールのクラスとハンドラーが出来たら MauiAppBuilder クラスの ConfigureMauiHandlers あたりで handlers.AddHandler<MyBox, MyBoxHandler>(); のようにコントロールとハンドラーを紐づけてあげることで動くようになります。

簡単なものを作ってみよう

Windows で MyBox というコントロールを Microsoft.UI.Xaml.Controls.Border の中に Microsoft.UI.Xaml.Controls.TextBlock を置いている状態のコントロールとして表示するようにしてみようと思います。

MyBox の作成

とりあえず実体は Border なので背景色が設定できるのと Text プロパティで表示する文字列も表示できるようにしつつ、イベントも試してみたいのでマウスがコントロール上に来た時に PointerEntered イベントを発行するようにしたいと思います。定義はこんな感じになります。

MyBox.cs
using System.ComponentModel;

namespace MyMauiApp;

public interface IMyBox : IView
{
    string? Text { get; set; }
    event EventHandler PointerEntered;
    void SendPointerEntered();
}

public class MyBox : View, IMyBox
{
    public static readonly BindableProperty TextProperty = BindableProperty.Create(
        "Text",
        typeof(string), typeof(MyBox));
    public string? Text
    {
        get => (string?)GetValue(TextProperty);
        set => SetValue(TextProperty, value);
    }

    public event EventHandler PointerEntered;

    [EditorBrowsable(EditorBrowsableState.Never)]
    public void SendPointerEntered() => PointerEntered?.Invoke(this, new());
}

インターフェースは必須ではないのですが、MAUI のリポジトリを見てるとインターフェースを定義しているので作ってみました。SendPointerEntered メソッドですが、これはハンドラー側からイベントを発火するための口になります。

MyBoxHandler の作成

では次にハンドラーを作ります。これはサポートするプラットフォームの数だけ作る必要があります。今回は妥協して Windows のものしか作りません。

MyBoxHandler.Windows.cs
using Microsoft.Maui.Handlers;

namespace MyMauiApp;

public class MyBoxHandler : ViewHandler<IMyBox, Microsoft.UI.Xaml.Controls.Border>
{
    public static IPropertyMapper<IMyBox, MyBoxHandler> Mapper = 
        new PropertyMapper<IMyBox, MyBoxHandler>(ViewMapper)
        {
            [nameof(MyBox.Text)] = MapText,
        };

    private static void MapText(MyBoxHandler handler, IMyBox view)
    {
        ((Microsoft.UI.Xaml.Controls.TextBlock)handler.PlatformView.Child).Text = view.Text;
    }

    public static CommandMapper<IMyBox, MyBoxHandler> CommandMapper = 
        new(ViewCommandMapper);

    public MyBoxHandler() : base(Mapper, CommandMapper)
    {
    }

    protected override Microsoft.UI.Xaml.Controls.Border CreatePlatformView() => new()
    {
        Child = new Microsoft.UI.Xaml.Controls.TextBlock
        {
            HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center,
            VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Center,
        },
    };

    protected override void ConnectHandler(Microsoft.UI.Xaml.Controls.Border platformView)
    {
        base.ConnectHandler(platformView);
        platformView.PointerEntered += PlatformView_PointerEntered;
    }

    private void PlatformView_PointerEntered(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e)
    {
        VirtualView.SendPointerEntered();
    }

    protected override void DisconnectHandler(Microsoft.UI.Xaml.Controls.Border platformView)
    {
        platformView.PointerEntered -= PlatformView_PointerEntered;
        base.DisconnectHandler(platformView);
    }
}

1 つ目のポイントとして CreatePlatformView メソッドでネイティブのコントロールのインスタンスを作って返すところです。次に、見慣れない Mapper プロパティについてです。
これは MAUI の世界からネイティブの世界へのプロパティのマッピングを行います。プロパティ名をキーにマッピングロジックを指定するような感じです。

PropertyMapper を作るところで指定している ViewMapperViewHandler クラスで定義されている親のマッピング ロジックの詰まった PropertyMapper になります。コンストラクターで渡しておくと、ここらへんのマッピングロジックを数珠繋ぎのように呼び出してくれるようになっています。
親クラスのマッピングロジックが不要になることはレアケースだと思うので基本的にはコンストラクターで親の PropertyMapper を指定するようにしましょう。

この他にも、プラットフォーム固有のロジックをマッピングするための CommandMapper も同じような感じです。

そして ConnectHandler でネイティブの世界から MAUI の世界へのイベントの橋渡しをおこなうためのイベント ハンドラーの購読を行います。イベント ハンドラーでは、先ほど IMyBox のほうで定義しておいた SendPointerEntered メソッドを呼び出してイベントを起こしています。

ここまで出来たら、あとは ConfigureMauiHandlers でマッピングを追加する感じです。

MauiProgram.cs
using Microsoft.Maui.LifecycleEvents;
using System.Diagnostics;

namespace MyMauiApp;

public static partial class MauiProgram
{
    static partial void AddHandlers(IMauiHandlersCollection handlers);
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureMauiHandlers(handlers =>
            {
#if WINDOWS
                handlers.AddHandler<MyBox, MyBoxHandler>();
#endif
            })
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });

        return builder.Build();
    }
}

ここまでやると画面に置いて表示されるようになります。

MainPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:MyMauiApp"
             x:Class="MyMauiApp.MainPage">
			 
    <ScrollView>
        <VerticalStackLayout 
            Spacing="25" 
            Padding="30,0" 
            VerticalOptions="Center">

            <Label 
                x:Name="myLabel"
                SemanticProperties.HeadingLevel="Level2"
                SemanticProperties.Description="Welcome to dot net Multi platform App U I"
                FontSize="18"
                HorizontalOptions="Center" />

            <local:MyBox Text="こんにちは世界" 
                         PointerEntered="MyBox_PointerEntered" 
                         MinimumHeightRequest="30" 
                         MinimumWidthRequest="30"
                         Background="DarkBlue"/>
        </VerticalStackLayout>
    </ScrollView>
 
</ContentPage>
MainPage.xaml.cs
namespace MyMauiApp;

public partial class MainPage : ContentPage
{
	public MainPage()
	{
        InitializeComponent();
    }

    private void MyBox_PointerEntered(object sender, EventArgs e)
    {
        myLabel.Text = $"PointerEntered at {DateTime.Now}";
    }
}

動かすとこんな感じになります。Android とかで動かすとハンドラーがないのでエラーになります。

このように作っておくと以下のドキュメントにあるようにカスタムが出来るようなことに対応できます。

https://docs.microsoft.com/ja-jp/dotnet/maui/user-interface/handlers/customize

こうすると…

MainPage.xamlcs
namespace MyMauiApp;

public partial class MainPage : ContentPage
{
	public MainPage()
	{
        InitializeComponent();
#if WINDOWS
        MyBoxHandler.Mapper.AppendToMapping("Text", (handler, view) =>
        {
            ((Microsoft.UI.Xaml.Controls.TextBlock)handler.PlatformView.Child).Text = $"[[[{view.Text}]]]";
        });
#endif
    }

    private void MyBox_PointerEntered(object sender, EventArgs e)
    {
        myLabel.Text = $"PointerEntered at {DateTime.Now}";
    }
}

こういう実行結果になります。

まとめ

以外と簡単にできました。CommandMapper 自体が利用シーンがそこまで多くないので例を示しづらい…。

Microsoft (有志)

Discussion