.NET MAUI ButtonHandler移行:Xamarin.Forms ButtonRendererのOnAttachedToWindow・Touch対応とMaterialButton/Android.Widget.Button切り替えガイド

Xamarin.Forms の ButtonRenderer で「見た目調整+OnAttachedToWindow()+Touch(Down/Up/Cancel)」まで握っていた場合、.NET MAUI の Handler方式へ移行すると同じ感覚で書けずに詰まりがちです。Android で MaterialButton を活かす移行パターンと、Android.Widget.Buttonへ切り替える現実的な設計を、コード付きで整理します。

目次

Xamarin.FormsのButtonRendererが“何でもできた”理由と、MAUIで困るポイント

Xamarin.Forms の Renderer は「ネイティブコントロールを継承したクラス」を通じて実装するため、ネイティブのライフサイクルや入力イベントを override で直接握れるのが強みでした。特に Android では、OnElementChanged で見た目を初期化し、OnAttachedToWindow() で Touch を購読し、Down/Up/Cancel を起点に押下演出や状態管理をする実装が定番です。

一方 .NET MAUI の Handler は、Renderer の単純な置き換えではありません。MAUI ではコントロールは「VirtualView(抽象)」で、Handler はそれを「PlatformView(ネイティブ)」へマッピングする ブリッジです。Android の Button は既定で MaterialButton にマッピングされます。

Xamarin.Forms(Renderer).NET MAUI(Handler)移行の捉え方
OnElementChangedで初期化CreatePlatformView+ConnectHandler「ネイティブ生成」+「購読・初期セット」はConnect側へ寄せる
OnElementPropertyChangedで反映PropertyMapper(必要ならAppend)プロパティ反映はswitch文よりMapperが主戦場
OnAttachedToWindow()をoverrideHandler側に同等のoverrideは基本なし本当に必要ならネイティブクラス継承側でoverrideする
Control.TouchでDown/Up/CancelPlatformView.Touch購読、またはネイティブoverride購読はConnect、解除はDisconnectへ
Disposeで後始末DisconnectHandlerで解除・破棄リーク対策はDisconnectまでセットで設計

MAUI には Handler のライフサイクルとして HandlerChanging / HandlerChanged があり、ネイティブビューの準備・切り替えタイミングを掴めます。

ただし、Renderer の OnAttachedToWindow() と完全に同義ではありません。どうしても「Window に attach された瞬間」にやりたい処理(例:WindowToken依存、Attach前には無効なAPI、ViewTreeObserver登録など)があるなら、MAUI側で“相当メソッド”を探すより、ネイティブビュー側で attach を捕まえるのが安定します。

まず押さえる:MAUI AndroidのButtonはMaterialButtonが前提

.NET MAUI の Android 実装では、Button は既定で MaterialButton にマッピングされます。これはMaterialデザイン(リップルなど)やテーマとの整合を取りやすい一方、Xamarin.Forms時代に Android.Widget.Button 前提で作っていたカスタマイズとは衝突しやすいのが実情です。

さらに、MaterialButtonは背景を自前で管理する思想が強く、MAUI側から単純に背景を差し替えると警告ログが出るケースもあります(動作はしても、押下状態などの管理を自分で考える必要が出る、という意図の警告です)。

解決策A:MaterialButtonのまま移行する(OnAttachedToWindow / Touch を再現)

Rendererでやっていたことを「MAUI側に同等のoverrideを探す」方向に寄せると、泥沼になりがちです。ポイントはシンプルで、ネイティブのライフサイクルをoverrideしたいなら、Handlerではなく“ネイティブコントロール継承クラス”でやる、です。

全体像

  • Android側:MaterialButton を継承したカスタムボタンを作り、OnAttachedToWindow() / OnDetachedFromWindow() をoverrideする
  • Handler側:CreatePlatformView() でそのカスタムボタンを生成し、ConnectHandlerでTouch購読、DisconnectHandlerで解除する

Android側:MaterialButtonを継承してOnAttachedToWindowを握る

配置例:Platforms/Android/Views/TouchAwareMaterialButton.cs

#if ANDROID
using Android.Content;
using Android.Runtime;
using Android.Util;
using Android.Views;
using Google.Android.Material.Button;

namespace YourApp.Platforms.Android.Views;

public class TouchAwareMaterialButton : MaterialButton
{
public event EventHandler? AttachedToWindow;
public event EventHandler? DetachedFromWindow;


public TouchAwareMaterialButton(Context context) : base(context) { }
public TouchAwareMaterialButton(Context context, IAttributeSet attrs) : base(context, attrs) { }
public TouchAwareMaterialButton(Context context, IAttributeSet attrs, int defStyleAttr) : base(context, attrs, defStyleAttr) { }
protected TouchAwareMaterialButton(IntPtr javaReference, JniHandleOwnership transfer) : base(javaReference, transfer) { }

protected override void OnAttachedToWindow()
{
    base.OnAttachedToWindow();
    AttachedToWindow?.Invoke(this, EventArgs.Empty);

    // 例:Windowにattachされた後でないと安全でない処理をここへ
    // - ViewTreeObserverへ登録
    // - 画面表示タイミングでの計測依存処理
    // - WindowTokenが必要なAPI など
}

protected override void OnDetachedFromWindow()
{
    DetachedFromWindow?.Invoke(this, EventArgs.Empty);

    // 例:登録解除や後始末をここへ
    // - ViewTreeObserver解除
    // - タイマー停止 など

    base.OnDetachedFromWindow();
}


}
#endif

このクラスに「Renderer時代のOnAttachedToWindowでやっていたこと」を移します。Touch購読もここでやってしまう設計は可能ですが、後述のとおり 購読はHandler側のConnect/Disconnectに寄せた方が見通しが良いことが多いです。

Handler側:CreatePlatformViewでカスタムMaterialButtonを返す

配置例:Platforms/Android/Handlers/TouchAwareButtonHandler.cs

#if ANDROID
using Android.Views;
using Google.Android.Material.Button;
using Microsoft.Maui.Handlers;
using YourApp.Platforms.Android.Views;

namespace YourApp.Platforms.Android.Handlers;

// 既存のMAUI Buttonを“全部”置き換えたい場合は ButtonHandler を継承してもOK(MaterialButton前提のまま)
public class TouchAwareButtonHandler : ButtonHandler
{
protected override MaterialButton CreatePlatformView()
{
var context = MauiContext?.Context
?? Android.App.Application.Context;


    return new TouchAwareMaterialButton(context);
}

protected override void ConnectHandler(MaterialButton platformView)
{
    base.ConnectHandler(platformView);

    if (platformView is TouchAwareMaterialButton b)
    {
        // ネイティブattach/detachの通知が欲しい場合
        b.AttachedToWindow += OnAttachedToWindow;
        b.DetachedFromWindow += OnDetachedFromWindow;
    }

    // Touch購読(RendererのControl.Touch相当)
    platformView.Touch += OnPlatformTouch;
}

protected override void DisconnectHandler(MaterialButton platformView)
{
    // 解除(リーク防止)
    platformView.Touch -= OnPlatformTouch;

    if (platformView is TouchAwareMaterialButton b)
    {
        b.AttachedToWindow -= OnAttachedToWindow;
        b.DetachedFromWindow -= OnDetachedFromWindow;
    }

    base.DisconnectHandler(platformView);
}

private void OnAttachedToWindow(object? sender, EventArgs e)
{
    // attach時にだけ行いたい処理があればここへ(必要なら)
}

private void OnDetachedFromWindow(object? sender, EventArgs e)
{
    // detach時の後始末(必要なら)
}

private void OnPlatformTouch(object? sender, Android.Views.View.TouchEventArgs e)
{
    // Down / Up / Cancel を判定
    switch (e.Event?.ActionMasked)
    {
        case MotionEventActions.Down:
            // 押下演出例(ネイティブ側で視覚効果をつける)
            if (sender is Android.Views.View vDown)
                vDown.Alpha = 0.7f;
            break;

        case MotionEventActions.Up:
            if (sender is Android.Views.View vUp)
                vUp.Alpha = 1.0f;
            break;

        case MotionEventActions.Cancel:
            if (sender is Android.Views.View vCancel)
                vCancel.Alpha = 1.0f;
            break;
    }

    // trueにするとClickが発火しない等の副作用が出るため、
    // 既存のクリック挙動を維持したいなら基本は false(未処理)に寄せる
    e.Handled = false;
}


}
#endif

Androidの入力イベントは、Viewを継承して onTouchEvent() をoverrideするか、OnTouchListener等のリスナーで拾うのが基本です。MAUI移行でもこの原則は同じで、overrideしたいなら継承クラスで、購読で十分ならConnectHandlerで、という整理が最短ルートです。

Handler登録:すべてのButtonに適用するか、限定するか

Handlerの差し替えはグローバルです。同じ型のコントロールすべてに影響します。

「全ボタンを統一したい」なら Button に対して登録します。
「一部だけ特殊にしたい」なら MyButton派生クラスを作ってそれにだけ登録します。

using Microsoft.Maui.Controls;
using YourApp.Platforms.Android.Handlers;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();

        builder
            .UseMauiApp<App>();

        builder.ConfigureMauiHandlers(handlers =>
        {
#if ANDROID
            // 全ての Button に適用(影響範囲が広いので注意)
            handlers.AddHandler(typeof(Button), typeof(TouchAwareButtonHandler));
#endif
        });

        return builder.Build();
    }
}

「OnAttachedToWindow相当が欲しい」だけなら、MAUI側イベントで足りる場合もある

Rendererの OnAttachedToWindow() を「画面に載ったタイミングを知りたい」目的で使っていたなら、MAUI では VisualElement.Loaded / Unloaded(または IsLoaded)で置き換えできることがあります。

ただし Loaded は「プラットフォームのビジュアルツリーに追加された」ことを示すイベントで、Androidの OnAttachedToWindow() と完全一致ではありません。WindowToken依存など“attachそのもの”が必要なら、前述のようにネイティブ側で握る方が安全です。

見た目だけなら、Mapperの追加でRendererを卒業できるケースが多い

「角丸・余白・AllCapsオフ・背景色の扱い」など、ネイティブメソッドoverrideが不要な範囲は、Handlerの Mapper を追加するだけで移行できることが多いです。MAUIではプロパティ変更を Mapper が担う設計になっています。

#if ANDROID
using Microsoft.Maui.Handlers;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();

        ButtonHandler.Mapper.AppendToMapping("NoAllCaps", (handler, view) =>
        {
            // AndroidのMaterialButtonはTextView系なのでAllCaps等の調整ができる
            handler.PlatformView?.SetAllCaps(false);
        });

        return builder.Build();
    }
}
#endif

この方法は「OnAttachedToWindowのoverride」などには向きませんが、Rendererでやっていた“見た目調整”の多くはここで片付くため、移行コストを大きく下げられます。

解決策B:Android.Widget.Buttonを使いたい(MaterialButtonをやめたい)

結論から言うと、MAUIの既定 ButtonHandler はプラットフォームビュー型が MaterialButton前提なので、CreatePlatformView() だけ差し替えて Android.Widget.Button を返すことは型が合わずに成立しません。そこで発想を切り替え、プラットフォームビュー型から作り直すHandlerを自作します。

このルートのメリットは「Android側の型が自由」になること。デメリットは「MaterialButtonにある前提機能(形状/リップル/背景管理など)を自分で面倒を見る場面が増える」ことです。

おすすめ構成:MyButton(派生)+ViewHandler<MyButton, Android.Widget.Button>

影響範囲を限定しやすいので、まずは MyButton派生クラス を作って、その型にだけ独自Handlerを紐づける設計がおすすめです。

配置例:Controls/MyButton.cs

using Microsoft.Maui.Controls;

namespace YourApp.Controls;

// 既存XAMLを最小変更で済ませたいなら Button 派生にする
public class MyButton : Button
{
// 必要なら追加プロパティ(押下色、キャンセル時挙動など)をここに定義
}

配置例:Platforms/Android/Handlers/MyButtonHandler.cs

#if ANDROID
using Android.Views;
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Platform;
using YourApp.Controls;

namespace YourApp.Platforms.Android.Handlers;

public class MyButtonHandler : ViewHandler
{
public static IPropertyMapper Mapper =
new PropertyMapper(ViewMapper)
{
[nameof(MyButton.Text)] = MapText,
[nameof(MyButton.TextColor)] = MapTextColor,
[nameof(MyButton.BackgroundColor)] = MapBackgroundColor,
[nameof(MyButton.IsEnabled)] = MapIsEnabled,
// 必要な分だけ追加(Font, Padding, CornerRadius等)
};


public MyButtonHandler() : base(Mapper) { }

protected override Android.Widget.Button CreatePlatformView()
{
    var context = MauiContext!.Context;
    return new Android.Widget.Button(context);
}

protected override void ConnectHandler(Android.Widget.Button platformView)
{
    base.ConnectHandler(platformView);

    // RendererのControl.Touch相当
    platformView.Touch += OnTouch;

    // クリックはMAUI側へ流す(Clicked/Commandを発火)
    platformView.Click += OnClick;
}

protected override void DisconnectHandler(Android.Widget.Button platformView)
{
    platformView.Touch -= OnTouch;
    platformView.Click -= OnClick;

    base.DisconnectHandler(platformView);
}

private void OnTouch(object? sender, Android.Views.View.TouchEventArgs e)
{
    var action = e.Event?.ActionMasked;

    if (action == MotionEventActions.Down)
    {
        // MAUIのPressedイベントを発火(XAMLのPressedイベント等が動く)
        VirtualView?.SendPressed();
    }
    else if (action == MotionEventActions.Up)
    {
        VirtualView?.SendReleased();
        // ClickはplatformView.Click側でSendClickedするので、ここでは呼ばない
    }
    else if (action == MotionEventActions.Cancel)
    {
        // Cancelは「押下がキャンセルされた」状態
        VirtualView?.SendReleased();
    }

    // trueにするとClickが死ぬことがあるので、基本はfalse(デフォルト挙動に任せる)
    e.Handled = false;
}

private void OnClick(object? sender, EventArgs e)
{
    // Clickedイベント発火+Command実行
    VirtualView?.SendClicked();
}

private static void MapText(MyButtonHandler handler, MyButton view)
{
    handler.PlatformView.Text = view.Text ?? string.Empty;
}

private static void MapTextColor(MyButtonHandler handler, MyButton view)
{
    // TextColor未指定時の扱いはアプリ方針で調整
    handler.PlatformView.SetTextColor(view.TextColor.ToPlatform());
}

private static void MapBackgroundColor(MyButtonHandler handler, MyButton view)
{
    handler.PlatformView.SetBackgroundColor(view.BackgroundColor.ToPlatform());
}

private static void MapIsEnabled(MyButtonHandler handler, MyButton view)
{
    handler.PlatformView.Enabled = view.IsEnabled;
}


}
#endif

この実装で、Xamarin.Forms時代にRendererでやっていた「TouchのDown/Up/Cancel起点でMAUIイベント(Pressed/Released/Clicked)を動かす」ルートが作れます。Buttonには SendPressed / SendReleased / SendClicked が用意されています。

また、MAUI Button の仕様として「Released と同時に Clicked が起きるのが一般的だが、指がボタン外へ滑った場合などは Clicked が起きないことがある」点も押さえておくと、Cancel分岐の意味が腑に落ちます。

MauiProgramでMyButtonだけにHandlerを登録する

using YourApp.Controls;
using YourApp.Platforms.Android.Handlers;

public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();


    builder.ConfigureMauiHandlers(handlers =>
    {


#if ANDROID
handlers.AddHandler(typeof(MyButton), typeof(MyButtonHandler));
#endif
});


    return builder.Build();
}


}

この形にしておけば、通常の <Button /> は従来どおり MaterialButton、必要な箇所だけ <local:MyButton /> を使って Android.Widget.Button を採用、という混在ができます。

Android.Widget.Buttonでも押下リップル等を付けたい場合

MaterialButtonをやめる理由のひとつが「Materialの見た目を抑えたい」場合ですが、逆に「Widget.Buttonでも最低限の押下フィードバックは欲しい」ケースもあります。Androidのテーマ属性(selectableItemBackground)を使うと比較的簡単です。

#if ANDROID
using Android.Util;
using Microsoft.Maui.Platform;

private static void ApplySelectableBackground(Android.Widget.Button platformView)
{
    var context = platformView.Context;
    var outValue = new TypedValue();

    // テーマから selectableItemBackground を解決
    if (context.Theme?.ResolveAttribute(Android.Resource.Attribute.SelectableItemBackground, outValue, true) == true)
    {
        platformView.SetBackgroundResource(outValue.ResourceId);
    }
}
#endif

ただし背景を差し替えると、角丸・枠線・disabled表現などは自前で整える必要が出ます。ここは「どこまでネイティブに寄せるか」の設計判断ポイントです。

移行時の対応づけ:ButtonRendererでやっていたことをMAUIに落とすコツ

Rendererでの実装箇所移行先の候補おすすめ判断
OnElementChangedで初期設定CreatePlatformView / ConnectHandler見た目・購読はConnectに集約し、重複購読を避ける
OnAttachedToWindow()で“attach後だけ”の処理ネイティブ継承でOnAttachedToWindow override、または VisualElement.LoadedWindowTokenやAndroidのattach依存ならネイティブoverride、単にロードタイミングならLoadedでも可
Control_TouchでDown/Up/CancelPlatformView.Touch購読、またはネイティブonTouchEvent overrideClickを殺さないよう、Handled/戻り値の扱いに注意
プロパティ変更ごとの反映PropertyMapper(必要分だけ)まずはText/Color/Enabledなど“必須”から実装し、足りない分を追加

実務でよくある落とし穴と対策

  • 購読解除忘れでリーク
    Handlerはページ遷移や再生成で何度も張り替わる可能性があるため、ConnectHandlerで購読したものはDisconnectHandlerで必ず解除します。
  • Touchでe.Handled=trueにしてClickが動かない
    Down/Up/Cancelを見たいだけなら、基本は「観測」に徹してClick処理を妨げない設計にします。どうしてもTouchで完全制御する場合は、Click発火の責務も自分で持つ前提で整理します。
  • MaterialButtonの背景差し替えで押下状態が崩れる
    MaterialButtonは背景を自前管理し、背景差し替えは状態表現や属性の一部を無視し得ます。ログや挙動が気になる場合は、背景TintやshapeAppearanceの利用、またはWidget.Button採用を検討します。
  • 「全部をAndroid.Widget.Buttonにしたい」誘惑
    いきなり全ボタン差し替えにすると、テーマ・アクセシビリティ・状態表現の差分が一気に噴き出します。まずはMyButtonのように限定適用して、移行範囲をコントロールするのが安全です。

どちらを選ぶべきか:MaterialButton維持 vs Android.Widget.Buttonへ移行

観点MaterialButtonを維持(解決策A)Android.Widget.Buttonへ移行(解決策B)
移行コスト低〜中(既定の流れに乗りやすい)中〜高(PropertyMapperや見た目を自前で増やす)
OnAttachedToWindowの再現ネイティブ継承で素直に再現しやすい同様に可能だが、見た目・挙動差分が増えがち
押下リップル等デフォルトで乗る必要ならテーマ属性等で追加
既存のMAUI Buttonとの互換高い独自Handlerの実装次第(まずは限定適用が安全)
「Materialを避けたい」要件微調整で抑えられる場合もある徹底的に排除しやすい

まとめ:Rendererを“再現”するより、責務を分割して移行する

  • MAUIのHandlerはRendererの置き換えではなく、VirtualViewとPlatformViewをつなぐブリッジ
  • OnAttachedToWindow()のようなネイティブライフサイクルをoverrideしたいなら、ネイティブコントロール継承クラス側で握る
  • Touch購読は ConnectHandlerで追加し、DisconnectHandlerで解除する(リーク対策)
  • MaterialButtonをやめたいなら、ViewHandler<MyButton, Android.Widget.Button> のようにプラットフォームビュー型から作り直す

ButtonRendererでやっていたカスタムは「見た目(Mapperで済む領域)」と「ネイティブライフサイクル/入力(ネイティブ継承で握る領域)」に切り分けると、移行設計が一気に安定します。特に Android の OnAttachedToWindow や Touch(Down/Up/Cancel) を重視する場合は、最初からこの分割で組み直すのが近道です。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次