クロスプラットフォーム開発なのであれば、極力プラットフォームコードは書かずに済ませたいものですが、発展途上な上にネイティブUIをラップしているMAUIの場合、 クロスプラットフォームAPIのみで複雑な要件を満たすことは難しいです。ここで、ネイティブビューをカスタマイズする方法のうち有用なものをまとめてみます。

ビューから直接ネイティブビューにアクセスする

クロスプラットフォームビューからview.Handler.PlatformView、もしくはToPlatformでネイティブビューに直接アクセスしてカスタマイズする方法です。 カスタムハンドラーやマッパー追加がクソ面倒な時に使えます。

    public static void Open(this DatePicker datePicker)
    {
        // コードで日付選択画面を表示するメソッドが提供されていないので、ネイティブビューでタップさせる。
#if ANDROID
        var handler = datePicker.Handler as IDatePickerHandler;
        handler.PlatformView?.PerformClick();
        //var platformView = datePicker.ToPlatform(datePicker.Handler.MauiContext);
        //platformView?.PerformClick();
#elif IOS

#endif
    }

    public static void Open(this TimePicker timePicker)
    {
#if ANDROID
        var handler = timePicker.Handler as ITimePickerHandler;
        handler.PlatformView?.PerformClick();
#elif IOS

#endif
    }


手軽に特定のビューに対してカスタマイズを施せるので、直感的であることが利点でしょうか。高度なカスタマイズが必要なく、 カスタムコントロールとして扱う必要もないのであればこれで十分です。再利用性が低い場合は上記のように プリプロセッサディレクティブで殴り書きし、再利用性が高い場合にはヘルパーやユーティリティ、拡張メソッド等でまとめるか、プラットフォーム別の実装を抽象化した インターフェースをクロスプラットフォーム側で呼ぶ形にするといいです。

ちなみに、この方法ではビューのレンダリング後(画面に表示された後)でないと動かないので注意が必要です。view.Handlerはレンダリング後でないとnull参照でアクセスできませんし、 ToPlatformで取得したネイティブビューに変更を加えてもレンダリング時にMAUIのデフォルトハンドラーやバインディングの影響を受けてしまうので、うまく動作しません。 あくまでレンダリング後のビューに対して簡単なカスタマイズを施すためのものと割り切り、多用は避けるべきです。

エフェクトを使用する

カスタムハンドラーを作成せずに、簡易的にネイティブビューをカスタマイズする方法です。 下記ではTimePickerで0時0分の時のみ、テキストを非表示にしています。

実装クラス

//PlatformEffectから取得できるControlはネイティブのUI要素、
//Containerはそれを含むコンテナ要素(親ビューであり、AndroidではFrameLayoutやViewGroup等、iOSではUIView等が該当)を表す。

public class TimePickerNotVisibleTimeTextRoutingEffect : RoutingEffect
{
    
}

#if ANDROID
public partial class TimePickerNotVisibleTimeTextPlatformEffect : PlatformEffect
{
    EditText editText;
    Microsoft.Maui.Controls.TimePicker timePicker;
    Android.Graphics.Color originalTextColor;

    protected override void OnAttached()
    {
        // TimePickerのAndroidネイティブビューはEditTextらしい。タップイベントでTimePickerDialogを表示し、結果を
        // EditTextに入力している。
        editText = Control as Android.Widget.EditText;
        originalTextColor = new Android.Graphics.Color(editText.CurrentTextColor);
        timePicker = Element as Microsoft.Maui.Controls.TimePicker;
        if(editText is null || timePicker is null)
        {
            return;
        }

        UpdateTimeTextVisibility();
        editText.AfterTextChanged += EditText_AfterTextChanged;
    }

    protected override void OnDetached()
    {
        if(editText != null)
        {
            editText.AfterTextChanged -= EditText_AfterTextChanged;
            editText.Visibility = ViewStates.Visible;
        }
    }

    void EditText_AfterTextChanged(object? sender, Android.Text.AfterTextChangedEventArgs e)
    {
        UpdateTimeTextVisibility();
    }

    void UpdateTimeTextVisibility()
    {
        if(timePicker.Time.TotalHours == 0 && timePicker.Time.TotalMinutes == 0)
        {
            editText.SetTextColor(Android.Graphics.Color.Transparent);
        }
        else
        {
            editText.SetTextColor(originalTextColor);
        }
    }
}

#elif IOS
public partial class TimePickerNotVisibleTimeTextPlatformEffect : PlatformEffect
{
    protected override void OnAttached()
    {

    }

    protected override void OnDetached()
    {

    }
}

#endif

エフェクトの登録

// MauiProgram.cs
var builder = MauiApp.CreateBuilder();
builder
    .UseMauiApp<App>()
    // 他なんか色々設定する
    .ConfigureEffects(effects =>
    {
        effects.Add<TimePickerNotVisibleTimeTextRoutingEffect>();
    });

エフェクトの適用

var timePicker = new TimePicker().Effects(new TimePickerNotVisibleTimeTextRoutingEffect());


OnDetachedメソッドはビューが破棄されるタイミングで実行されるので、明示的にview.Effects.Clear()を呼ぶ必要はありません。エフェクトの登録と適用が若干面倒なのは否めませんが、 デフォルトハンドラーの上書きや完全な新しいカスタムコントロールの作成で複雑化するリスクが無く、コードを疎結合に保てます。

エフェクトの実装において、プラットフォーム別にファイルを分けたい場合は、フォルダ名やファイル名で区別することでターゲット別に コンパイル対象を制御できます。(参考:マルチターゲットの構成) 私は保守性が損なわれないのであれば、あちこちファイル移動するほうが嫌いなので1ファイルにまとめます。

冒頭で簡易的なカスタマイズと言いましたが、クラスメンバーやメソッドを追加して色々できる柔軟性のおかげで割と積極的に使っていけます。カスタムコントロールを作成するまでもなく、デフォルトのハンドラーを いじりたくない場合に有用です。

デフォルトハンドラーにマッパーをあてる

デフォルトハンドラーのマッパーにカスタムロジックを追加する方法です。

ハンドラーとマッパーについて MAUIでは、ButtonやEntryをAndroidやiOS等のプラットフォームを意識せずに使用することができますが、 Flutterのような独自コントロールを用いているわけではなく、IButtonやIEntryといったインタフェース(仮想ビュー)を介して 各プラットフォーム固有のネイティブビューにアクセスしているんですよね。 この仮想ビューとネイティブビューとのマッピングを行っているのがハンドラーです。ハンドラーでは、ネイティブビューの インスタンスを生成して仮想ビューに割り当てたり、MAUIのコントロールAPIをネイティブビューAPIにマッピングしたりなど、MAUIコントロールと ネイティブビューとの橋渡しを担っています。

MAUIコントロールの変更を、実際のネイティブビューに反映させる処理を保持しているのがマッパーです。ハンドラーはマッパーを保持しており、 マッパーをカスタマイズすることで、MAUIコントロールで独自の機能や振る舞いを追加することができます。 ネイティブビューのプロパティ変更はプロパティマッパー、ネイティブビューのコマンド変更はコマンドマッパーで設定可能です。

ちなみに、Xamarin時代のカスタムレンダラーはMAUIに移植可能ですが、ハンドラーを差し置いて使う理由が無いのでここでは取り上げません。


ハンドラーやマッパーのより詳しい解説は下記のドキュメントと記事がとても参考になります。

.NET MAUI ハンドラー
MAUI で独自のコントロールを作る方法のメモ
MAUIのカスタムコントロールの所感
MAUIのHandlerってどう使うの


プロパティマッパー

公式のサンプルコード

    Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("MyCustomization", (handler, view) =>
    {
#if ANDROID
        handler.PlatformView.SetSelectAllOnFocus(true);
#elif IOS || MACCATALYST
        handler.PlatformView.EditingDidBegin += (s, e) =>
        {
            handler.PlatformView.PerformSelector(new ObjCRuntime.Selector("selectAll"), null, 0.0f);
        };
#elif WINDOWS
        handler.PlatformView.GotFocus += (s, e) =>
        {
            handler.PlatformView.SelectAll();
        };
#endif
    });

コマンドマッパー

EntryHandler.CommandMapper.AppendToMapping("Focus", (handler, view, args) =>
{
    Log.Debug("Entry", "Entryにフォーカスが!!!");
});

添付プロパティで制御したい場合

EntryHandler.Mapper.AppendToMapping("NoUnderline", (handler, view) =>
{
    // フラグが設定されていたら、Entryの下線を非表示にする
    if(handler?.PlatformView != null && !EntryAttached.GetIsVisibleUnderLine(view as Entry))
    {
        handler.PlatformView.BackgroundTintList = Android.Content.Res.ColorStateList.ValueOf(Android.Graphics.Color.Transparent);
    }
});

public static partial class EntryAttached
{
    public static readonly BindableProperty IsVisibleUnderLineProperty = BindableProperty.CreateAttached("IsVisibleUnderLine", typeof(bool), typeof(EntryAttached), false);

    public static bool GetIsVisibleUnderLine(BindableObject view)
    {
        return (bool)view.GetValue(IsVisibleUnderLineProperty);
    }

    public static void SetIsVisibleUnderLine(BindableObject view, bool isVisibleUnderline)
    {
        view.SetValue(IsVisibleUnderLineProperty, isVisibleUnderline);
    }
}

// 初期化時にフラグを設定する
var entry = new Entry();
entry.SetValue(EntryAttached.IsVisibleUnderLineProperty, true);


カスタムハンドラーを作成せずに特定のプロパティやコマンドを拡張できます。カスタマイズをアプリケーション全体で適用する場合に 実装が楽になるので有用ですが、適用する条件が必要になったり等、複雑化するようであれば、素直にエフェクトかカスタムハンドラーを作成したほうがいいです。 コードで示したように、staticメンバーや添付プロパティ (レンダリング以前の添付プロパティの変更が反映されているため、制御可能)で特定の条件に基づいてカスタマイズを適用できますが、条件定義とロジックの適用が分離してしまいます。

ここでは、デフォルトのマッピングの後にカスタムロジックを追加するAppendToMappingを使用しましたが、デフォルトのマッピング前に追加したり、デフォルトのマッピングを上書きすることも可能です。↓
ハンドラーを使用してコントロールをカスタマイズする

カスタムハンドラーを作成する

カスタムハンドラーを作成して、デフォルトのビューもしくは完全に新しいカスタムコントロールに適用する方法です。 ここでは、Entryの下線を非表示にするカスタムハンドラーでデフォルトハンドラーを上書きするアプローチと、長押しイベントとアニメーション表示に対応した LongPressBoxViewの作成を例に挙げます。

デフォルトハンドラーを継承して作成する

public partial class BaseEntryHandler : EntryHandler
{
    static readonly string IsVisibleUnderLine = "IsVisibleUnderLine";
    public static readonly BindableProperty IsVisibleUnderLineProperty = BindableProperty.CreateAttached(IsVisibleUnderLine, typeof(bool), typeof(BaseEntryHandler), false);
    public static bool GetIsVisibleUnderLine(BindableObject view)
    {
        return (bool)view.GetValue(IsVisibleUnderLineProperty);
    }

    public static void SetIsVisibleUnderLine(BindableObject view, bool isVisibleUnderline)
    {
        view.SetValue(IsVisibleUnderLineProperty, isVisibleUnderline);
    }
}

#if ANDROID
public partial class BaseEntryHandler : EntryHandler
{
    protected override void ConnectHandler(AppCompatEditText platformView)
    {
        base.ConnectHandler(platformView);
        // フラグが設定されていたら、Entryの下線を非表示にする
        if(!GetIsVisibleUnderLine(VirtualView as BindableObject))
        {
            platformView.BackgroundTintList = Android.Content.Res.ColorStateList.ValueOf(Android.Graphics.Color.Transparent);
        }
    }
}

#elif IOS
public partial class BaseEntryHandler : EntryHandler
{

}
#endif

var builder = MauiApp.CreateBuilder();
builder
    .UseMauiApp<App>()
    // 他なんか色々設定する
    .ConfigureMauiHandlers(handlers =>
    {
        // Entryのデフォルトハンドラーを上書きする
        handlers.AddHandler<Entry, BaseEntryHandler>();
    });

新しいハンドラーを作成してカスタムコントロールに適用する

// 長押しイベントとアニメーションに対応したBoxView
public class LongPressBoxView : View
{
    public static readonly BindableProperty BoxColorProperty = BindableProperty.Create(nameof(BoxColor), typeof(Color), typeof(LongPressBoxView), Colors.Blue);

    public Color BoxColor
    {
        get => (Color)GetValue(BoxColorProperty);
        set => SetValue(BoxColorProperty, value);
    }

    public event EventHandler? LongPressed;

    internal void RaiseLongPressed()
    {
        LongPressed?.Invoke(this, EventArgs.Empty);
    }
}

public partial class LongPressBoxViewHandler : ViewHandler<LongPressBoxView, Android.Views.View>
{
    public LongPressBoxViewHandler() : base(Mapper)
    {
        
    }

    // Viewのデフォルトマッパーを継承して、BoxColorプロパティのマッピングを追加
    readonly static PropertyMapper<LongPressBoxView, LongPressBoxViewHandler> Mapper =
        new PropertyMapper<LongPressBoxView, LongPressBoxViewHandler>(ViewHandler.ViewMapper)
        {
            [nameof(LongPressBoxView.BoxColor)] = MapBoxColor
        };

    public static void MapBoxColor(LongPressBoxViewHandler handler, LongPressBoxView view)
    {
        handler.UpdateBoxColor(view.BoxColor);
    }

    partial void UpdateBoxColor(Color color);
}

#if ANDROID
public partial class LongPressBoxViewHandler
{
    Android.Views.View? nativeView;

    protected override Android.Views.View CreatePlatformView()
    {
        nativeView = new Android.Views.View(Context);
        nativeView.SetBackgroundColor(this.VirtualView.BoxColor.ToPlatform());
        nativeView.SetOnLongClickListener(new LongClickListener(this.VirtualView, nativeView));
        return nativeView;
    }

    partial void UpdateBoxColor(Color color)
    {
        nativeView?.SetBackgroundColor(color.ToPlatform());
    }

    class LongClickListener(LongPressBoxView virtualView, Android.Views.View nativeView) : Java.Lang.Object, Android.Views.View.IOnLongClickListener
    {
        readonly LongPressBoxView virtualView = virtualView;
        readonly Android.Views.View nativeView = nativeView;

        public bool OnLongClick(Android.Views.View? v)
        {
            // アニメーション(拡大→元に戻す)
            var scaleUp = ObjectAnimator.OfPropertyValuesHolder(nativeView, PropertyValuesHolder.OfFloat("scaleX", 1.0f, 1.2f), PropertyValuesHolder.OfFloat("scaleY", 1.0f, 1.2f));
            scaleUp.SetDuration(100);

            var scaleDown = ObjectAnimator.OfPropertyValuesHolder(nativeView,PropertyValuesHolder.OfFloat("scaleX", 1.2f, 1.0f), PropertyValuesHolder.OfFloat("scaleY", 1.2f, 1.0f));
            scaleDown.SetDuration(100);

            var animatorSet = new AnimatorSet();
            animatorSet.PlaySequentially(scaleUp, scaleDown);
            animatorSet.Start();
            virtualView.RaiseLongPressed();
            return true;
        }
    }
}

#elif IOS

#endif

// MauiProgram.cs
var builder = MauiApp.CreateBuilder();
builder
    .UseMauiApp<App>()
    // 他なんか色々設定する
    .ConfigureMauiHandlers(handlers =>
    {
        // カスタムハンドラーをあてたカスタムコントロールを追加
        handlers.AddHandler<LongPressBoxView, LongPressBoxViewHandler>();
    });

// カスタムコントロールを使用する
new LongPressBoxView().HorizontalFill().Height(400).Invoke(x =>
{
    x.BoxColor = Colors.Red;
    x.LongPressed += (s,e) => System.Diagnostics.Debug.WriteLine("長押し!!!");
}),


MAUIにおけるネイティブビューカスタマイズの中では実装が複雑ですが、その分柔軟で深いカスタマイズが可能になります。初期化や破棄処理はConnectHandlerやDissconnectHandlerを オーバーライドしてください。ハンドラーを自作する場合でも、マッピングの全てが既存のビューと異なるようなケースは少ないと思います。確実に既存のビューのマッピング設定を継承し、 必要に応じて特定のマッピングを追加するなり変更するなりすれば問題は発生しづらいです。

既存のビューを拡張する形でカスタムロジックを追加したい
→ デフォルトハンドラーを継承したカスタムハンドラーで上書きする。

既存のビューと仕様が大きく異なる独自のカスタムコントロールを追加したい
→ カスタムハンドラーを作成してカスタムコントロールに適用する

さいごに

そもそもコードを共通化したいからクロスプラットフォームフレームワークを選ぶのに、プラットフォームコードの実装に時間を取られていては本末転倒ですよね。 プラットフォームを意識しない実装で複雑さを軽減するためにも、ライフサイクルイベントはMicrosoft.Maui.Controls.Windowクラスのクロスプラットフォームイベントを利用したり、 プラットフォームの差異をインタフェースで吸収させる等の工夫が求められます。

痒い所に手が届かないもどかしさは多少ありますが、C#という強力な言語で大部分のコードを共通化できるのはやっぱり魅力的です。.NET 9まで来て、致命的過ぎるバグも減ってきたので 少しずつ採用例が増えるといいな…!