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()をoverride | Handler側に同等のoverrideは基本なし | 本当に必要ならネイティブクラス継承側でoverrideする |
Control.TouchでDown/Up/Cancel | PlatformView.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.Loaded | WindowTokenやAndroidのattach依存ならネイティブoverride、単にロードタイミングならLoadedでも可 |
Control_TouchでDown/Up/Cancel | PlatformView.Touch購読、またはネイティブonTouchEvent override | Clickを殺さないよう、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) を重視する場合は、最初からこの分割で組み直すのが近道です。

コメント