🦔

MAUIのカスタムコントロールの所感

2022/07/24に公開

MAUIのカスタムコントロールを実際に作成してみたのでその所感やメモなどを書きます。

参考になるリポジトリショートカット

MAUIのリポジトリは同じ名前のフォルダがいろいろあって分かりにくくて迷子になるので、ショートカットを残しておきます。
このあたりを見れば何やっているかを追いやすいです。

MAUIコントロール置き場

https://github.com/dotnet/maui/tree/main/src/Controls/src/Core

ハンドラー置き場

https://github.com/dotnet/maui/tree/main/src/Core/src/Handlers

ハンドラーとは何か?

まだよく分かりませんが、Xamarin.FormsにおけるRendererと考えてもらって特に問題はないと思います。公式の図によるとMAUIの方がインターフェイスをかましてる分、疎結合になった感じでしょうか。

Xamarin.Forms

MAUI

Xamarin.FormsではRendererクラスのOnElementPropertyChangedでプロパティの変更を拾ってプロパティ毎に処理を分岐して対応していましたが、MAUIではPropertyMapperというのを利用してプロパティごとにMapアクションを設定して対応するという感じです。
で、さらにMAUIではこのPropertyMapperの前後にフックしたり置き換えたりすることができるので、標準コントロールやnugetで導入したコントロールの挙動を調整したりすることができて、より柔軟になったのかなという印象です。
今までレンダラーのサブクラス作ってworkaroundとするというような事がMAUIではMapperをいじるだけで解消できるようになるかも知れません。

カスタムコントロールの作成に必要なパーツ

  • MAUIコントロールクラス
    • クロスプラットフォーム用のコントロール
  • コントロールインターフェイス
    • MAUIコントロールとハンドラーの緩衝材
    • I[MAUIコントロール名]という命名
    • 省略化
  • ハンドラー
    • MAUIコントロールをNativeコントロールに接続するもの
    • サポートするプラットフォームの数だけ必要
    • マルチターゲットを活かしてpartialクラスを使って1クラスにまとめることもできる

具体的なミニマムな作成方法はかずきさんの記事がありますので、そちらを参考にすると良いと思います。

https://zenn.dev/okazuki/articles/maui-custom-control

ハンドラーをどう作るか

1つのMAUIコントロールに対応するハンドラーをPlatformsの各フォルダで作ったとして、ハンドラーの登録するには条件付きコンパイルをつかうことになってちょっと面倒臭いし見にくいです。

MauiProgram.cs
var builder = MauiApp.CreateBuilder();
builder
    .UseMauiApp<App>()
    // 略
    .ConfigureMauiHandlers(handlers =>
    {
#if IOS
	handlers.AddHandler<IMyView, MauiFirst.Platforms.iOS.MyViewHandler>();
#elif ANDROID
	handlers.AddHandler<IMyView, MauiFirst.Platforms.Android.MyViewHandler>();
#endif
            });

MAUIリポジトリではどう作ってるかというと、こんな感じでフォルダにまとめてパーシャルクラスを利用して1つのクラスとして定義してます。ファイル名でプラットフォームを区別し、無印はクロスプラットフォーム用という感じです。

https://github.com/dotnet/maui/tree/main/src/Core/src/Handlers/Switch

1つの機能が1つの場所にまとまっていて見やすいし扱いやすいかなと個人的には思います。これは本当に個人の好みの問題にもなってくるとは思いますが。

VSでMAUIテンプレートで作ったプロジェクトはフォルダーベースのマルチターゲット形式には対応していますが、ファイル名ベースのマルチターゲットには対応してませんので、csprojに以下の項目を追記する必要があります。

https://docs.microsoft.com/ja-jp/dotnet/maui/platform-integration/configure-multi-targeting#combine-filename-and-folder-multi-targeting

ファイル名ベースだけの記述を加えるだけだと規定のフォルダーベースの動作が消えてしまうらしく組み合わせる書き方でないと上手く行きませんでした。

これでファイル名に「iOS」が含まれたファイルはiOS用、「Android」が含まれたファイルはAndroid用となり、何も含まれなければクロスプラットフォーム用となります。

サンプル

以下のような構成になりました。

MyView.cs
public interface IMyView : IView
{
    Color Color { get; set; }
}

public class MyView : View, IMyView
{
	public static BindableProperty ColorProperty = BindableProperty.Create(
	    nameof(Color),
	    typeof(Color),
	    typeof(MyView),
	    default(Color),
	    defaultBindingMode: BindingMode.OneWay
	);

	public Color Color
	{
	    get { return (Color)GetValue(ColorProperty); }
	    set { SetValue(ColorProperty, value); }
	}
}
MyViewHandler.iOS.cs
public partial class MyViewHandler: ViewHandler<IMyView, UIView>
{     
	protected override UIView CreatePlatformView()
	{
	    return new UIView();
	}

	private static void MapColor(MyViewHandler handler, IMyView view)
	{
	    handler.PlatformView.BackgroundColor = view.Color.ToPlatform();
	}
}
MyViewHandler.Android.cs
public partial class MyViewHandler: ViewHandler<IMyView, Android.Views.View>
{        
	protected override Android.Views.View CreatePlatformView()
	{
	    return new Android.Views.View(Context);
	}

	private static void MapColor(MyViewHandler handler, IMyView view)
	{
	    handler.PlatformView.SetBackgroundColor(view.Color.ToPlatform());
	}
}
MyViewHandler.cs
public partial class MyViewHandler
{
	public static IPropertyMapper<IMyView, MyViewHandler> Mapper =
	    new PropertyMapper<IMyView, MyViewHandler>(ViewMapper)
	    {
		[nameof(IMyView.Color)] = MapColor,
	    };

	public MyViewHandler() : base(Mapper)
	{
	}

	public MyViewHandler(IPropertyMapper mapper = null) : base(mapper ?? Mapper)
	{
	}        
}

このやり方でハンドラーを作ると条件付きコンパイル無しで1行で記述できるのでスッキリします。

MauiProgram.cs
var builder = MauiApp.CreateBuilder();
builder
    .UseMauiApp<App>()
    // 略
    .ConfigureMauiHandlers(handlers =>
    {
	handlers.AddHandler<IMyView, MyViewHandler>();
    });

ポイントとしては主となるクラスをプラットフォーム側とすることです。そうすることで条件付きコンパイルの出番を減らしてます。これを逆にすると以下のようにMyViewHandler.csのusingブロックに条件付きコンパイルの記述をしなければいけなくなります。

#if IOS
using PlatformView = UIKit.UIView;
#elif ANDROID
using PlatformView = Android.Views.View;
#endif

public partial class MyViewHandler:<IMyView, PlatformView>
{
}

ちょっと面倒臭そうな点

共通の方にマッパーの定義を書いてますが、このやり方だとiOSの方だけ先行して実装しようとした場合にもAndroidの方もMap用のstaticメソッドを書かないとコンパイルが通らないのでやや困りそうだなと思いました。

public static IPropertyMapper<IMyView, MyViewHandler> Mapper =
    new PropertyMapper<IMyView, MyViewHandler>(ViewMapper)
    {
	[nameof(IMyView.Color)] = MapColor,
    };

なのでこの部分は開発序盤は、各プラットフォーム側のファイルに記述するようにして双方の実装が揃って来たところで共通に移動するとかいうやり方でいこうかなと思っています。
というかもう最後までこの部分はプラットフォーム側に書いたままでも良い気もします。

Discussion