.NET MAUI の WebView で「一番下までスクロールしたらボタンを有効化したい」という要件は、利用規約画面などで非常によく登場します。しかし Xamarin.Forms 時代の Renderer のノリで実装しようとすると、MAUI ではクラッシュやレイアウト不具合に悩まされがちです。この記事では、Handler ベースで Android / iOS 両対応の最小構成かつ実戦投入できるコードと、初期描画が崩れる問題の原因・対策までまとめて解説します。
.NET MAUI の WebView で「最下部までスクロールしたらボタンを有効化」したい
まずは、よくある要件と今回のゴールを整理します。
- 利用規約・プライバシーポリシーなどを
WebViewで表示したい - ユーザーがいちばん下までスクロールするまでは「同意する」ボタンを非活性にしておきたい
- Android だけでなく iOS(WKWebView)でも同じ挙動にしたい
- 初回表示時にラベルやボタンが描画されない/スクロールしたら出てくる、といった不具合を潰したい
Xamarin.Forms の Renderer を MAUI プロジェクトに持ち込むと、よく次のような例外でクラッシュします。
Specified cast is not valid- Renderer が見つからない / キャストできない
これは MAUI が Renderer ではなく Handler ベースのアーキテクチャに変わったためで、基本的に Renderer は「過去の技術」と考えるのが安全です。以降では、MAUI らしい Handler アプローチで「スクロール終端を検知してボタンを有効化する」実装を組み立てていきます。
Renderer はもう使わない:MAUI では Handler でネイティブ WebView にアクセスする
MAUI では、Xamarin.Forms の Renderer に相当する仕組みをHandlerと呼びます。共通プロジェクト側の View(今回は ScrollWebView)と、各プラットフォームのネイティブビュー(Android の Android.Webkit.WebView、iOS の WKWebView)をつなぐ役割を持ちます。
ざっくりとした違いは次の通りです。
| 項目 | Renderer(Xamarin.Forms) | Handler(.NET MAUI) |
|---|---|---|
| 役割 | View ごとに Renderer クラスを継承してカスタマイズ | Handler 経由でネイティブビューにアクセス |
| 拡張方法 | カスタム Renderer を登録 | Mapper の拡張 / Handler プロパティ参照 |
| MAUI での推奨度 | 非推奨(互換目的のみ/動かないケース多い) | 正式サポート・推奨アプローチ |
| 今回やりたいこと | 旧コードを流用すると Specified cast is not valid | Handler 経由でネイティブ WebView のスクロールイベントを購読 |
つまり結論としては、 「スクロール終端を検知したいなら Renderer ではなく Handler を使う」 という方針が必須になります。
実装方針の全体像
今回の記事では、次のような構成をゴールとします。
- 共通プロジェクトに
ScrollWebViewというカスタム WebViewを定義する ScrollWebViewには、スクロール状態を表すIsBottomReached(BindableProperty)を持たせる- 各プラットフォームで Handler を通じてネイティブ WebView を取得し、スクロールイベントを購読する
- スクロールのたびに「コンテンツ高さ − ビュー高さ − しきい値」と現在位置を比較し、最下部かどうかを判定する
- XAML では
IsBottomReachedとボタンのIsEnabledをデータバインドする - WebView を ScrollView で包まず、Grid などで高さを明示する
プラットフォームごとのスクロールイベント対応表は次のとおりです。
| プラットフォーム | ネイティブ WebView | 利用するスクロールイベント | 備考 |
|---|---|---|---|
| Android | Android.Webkit.WebView | ScrollChange | ContentHeight * Scale でコンテンツ高さを算出 |
| iOS | WKWebView | ScrollView.Scrolled | ContentOffset.Y と AdjustedContentInset を考慮 |
共有カスタムコントロール:ScrollWebView(共通プロジェクト)
まずは、共通プロジェクトに配置する ScrollWebView クラスです。ここでは
- 最下部到達を通知する
IsBottomReached - 判定にゆとりを持たせる
BottomThreshold - Handler 変更時にプラットフォームイベントを接続・解除する仕組み
を定義しています。
// ScrollWebView.cs(共通プロジェクト)
public partial class ScrollWebView : WebView
{
// 「最下部まで到達したかどうか」を表すプロパティ
public static readonly BindableProperty IsBottomReachedProperty =
BindableProperty.Create(
nameof(IsBottomReached),
typeof(bool),
typeof(ScrollWebView),
false,
BindingMode.TwoWay
);
public bool IsBottomReached
{
get => (bool)GetValue(IsBottomReachedProperty);
set => SetValue(IsBottomReachedProperty, value);
}
// 判定に使う「ゆとり」(Android: dp, iOS: pt)
public static readonly BindableProperty BottomThresholdProperty =
BindableProperty.Create(
nameof(BottomThreshold),
typeof(double),
typeof(ScrollWebView),
40d // 40px / pt 程度をデフォルトに
);
/// <summary>判定のゆとり(Android: dp 基準、iOS: pt)</summary>
public double BottomThreshold
{
get => (double)GetValue(BottomThresholdProperty);
set => SetValue(BottomThresholdProperty, value);
}
protected override void OnHandlerChanging(HandlerChangingEventArgs args)
{
// Handler が切り替わるタイミングで必ず購読解除
DisconnectPlatformEvents();
base.OnHandlerChanging(args);
}
protected override void OnHandlerChanged()
{
base.OnHandlerChanged();
// 新しい Handler がつながったら購読開始
ConnectPlatformEvents();
}
// 各プラットフォームで実装する部分
partial void ConnectPlatformEvents();
partial void DisconnectPlatformEvents();
}
ここでは partial クラスと partial void メソッドを使って、共通ロジックとプラットフォーム固有ロジックをきれいに分離しています。Android / iOS 側では同じクラス名の ScrollWebView を部分クラスとして実装し、そこでネイティブイベントの購読・解除を行います。
Android 側:WebView.ScrollChange で最下部を検知する
つぎに、Android プロジェクト側の部分クラスです。Android.Webkit.WebView の ScrollChange イベントを購読し、スクロール位置を計算して IsBottomReached を更新します。
#if ANDROID
using Android.Webkit;
using Android.Views;
public partial class ScrollWebView
{
Android.Webkit.WebView? _native;
partial void ConnectPlatformEvents()
{
_native = Handler?.PlatformView as Android.Webkit.WebView;
if (_native != null)
{
_native.ScrollChange += OnScrollChange;
}
}
partial void DisconnectPlatformEvents()
{
if (_native != null)
{
_native.ScrollChange -= OnScrollChange;
}
_native = null;
}
void OnScrollChange(object? sender, View.ScrollChangeEventArgs e)
{
var v = _native!;
// ContentHeight は CSS px。Scale を掛けて実際のピクセル数に変換
int contentPx = (int)Math.Floor(v.ContentHeight * v.Scale);
// WebView の高さ分を引いた「スクロール可能最大値」を算出
int cutoff = contentPx - v.Height;
// しきい値を dp → px に変換
var density = v.Context.Resources.DisplayMetrics.Density;
int thresholdPx = (int)(BottomThreshold * density);
// 「最大値 − しきい値」以上スクロールされたら最下部とみなす
IsBottomReached = e.ScrollY >= (cutoff - thresholdPx);
}
}
#endif
ここで重要なポイントは次の 3 つです。
ContentHeightは CSS px 単位なので、Scaleを掛けて実際のピクセルに変換する- WebView の高さ(
v.Height)を引くことで、スクロールできる最大位置(cutoff)を求める - 「ギリギリまでスクロールしなくても OK」にするため
BottomThreshold分のゆとりを持たせる
特にスマホでは、指を離したあと少しだけ慣性スクロールが走ることが多く、「ピッタリ最下部」で判定しようとすると微妙に足りずボタンが有効にならないケースが多発します。BottomThreshold を 40–60 程度にしておくと、ユーザー体験がかなりマイルドになります。
iOS 側:WKWebView.ScrollView.Scrolled で最下部を検知する
続いて iOS プロジェクト側の実装です。こちらは WKWebView の ScrollView.Scrolled イベントでスクロールを監視します。
#if IOS
using WebKit;
using UIKit;
public partial class ScrollWebView
{
WKWebView? _wk;
partial void ConnectPlatformEvents()
{
_wk = Handler?.PlatformView as WKWebView;
if (_wk != null)
{
_wk.ScrollView.Scrolled += OnScrolled;
}
}
partial void DisconnectPlatformEvents()
{
if (_wk != null)
{
_wk.ScrollView.Scrolled -= OnScrolled;
}
_wk = null;
}
void OnScrolled(object? sender, EventArgs e)
{
var sv = _wk!.ScrollView;
// コンテンツの高さ(ズーム倍率を考慮)
double content = sv.ContentSize.Height * sv.ZoomScale;
// 表示領域の高さ
double view = sv.Bounds.Height;
// スクロール位置(セーフエリア下部のインセットも加味)
double offsetY = sv.ContentOffset.Y + sv.AdjustedContentInset.Bottom;
// 「コンテンツ高さ − 表示高さ − しきい値」を超えたら最下部とみなす
IsBottomReached = offsetY >= (content - view - BottomThreshold);
}
}
#endif
iOS では セーフエリアやスクロールインジケータの分だけスクロール位置がずれることがあるため、
AdjustedContentInset.Bottomを足したオフセットで判定する- こちらでも
BottomThresholdに少し余裕を持たせる
ことが安定動作のコツです。とくに iPhone のホームバーやノッチ周りでは、これを考慮しないと「最後までスクロールしたのに判定されない」という事象が起きやすくなります。
実際の使い方:利用規約を読ませる画面の XAML
ここまでで ScrollWebView 自体は完成です。あとは XAML で WebView とボタンを配置し、IsBottomReached をボタンの IsEnabled にバインドするだけで、「最下部までスクロールしたらボタンが有効化される」画面を作ることができます。
<Grid RowDefinitions="Auto,*,Auto" Padding="16">
<Label Text="利用規約を最後までお読みください" />
<local:ScrollWebView x:Name="Web"
Grid.Row="1"
BottomThreshold="40"
Source="https://example.com/terms.html" />
<Button Grid.Row="2"
Text="同意して続行"
IsEnabled="{Binding Source={x:Reference Web}, Path=IsBottomReached}" />
</Grid>
ここでのポイントは次の通りです。
RowDefinitions="Auto,*,Auto"とすることで、上ラベル・中央 WebView・下ボタンという 3 分割レイアウトを実現- 中央の WebView 行に
*を指定しているので、画面の残り高さをすべて WebView が受け持つ - ScrollView では包まず、Grid で高さを決めている点が重要
- ボタンの
IsEnabledをIsBottomReachedにバインドするだけで「最下部まで読み終えたら有効化」が実現できる
もし一部の端末で初期描画が不安定な場合は、コードビハインドなどで次のようにしてレイアウトを再測定しておくと安定します。
Web.Navigated += (_, __) =>
{
MainThread.BeginInvokeOnMainThread(() =>
{
Web.InvalidateMeasure();
});
};
「ラベル/ボタンが初回表示されない」「ScrollView で包むと検知しない」問題の原因
質問でよくあるのが次の 2 パターンです。
- ラベルやボタンが初回表示時に描画されないが、スクロールすると急に現れる
- WebView を ScrollView で包んだらスクロール終端の検知が発火しなくなった
この 2 つには共通の原因があります。それは「WebView 自体が内部スクロールを持っているのに、さらに ScrollView で包んで二重スクロール構造にしている」ことです。
二重スクロールになると、次のような問題が発生します。
- レイアウト計測が複雑になり、初期レイアウトの段階で高さが正しく確定しない
- スクロールイベントが外側 ScrollView に取られてしまい、WebView 側のスクロールイベントが発火しにくくなる
- 結果として「ラベル/ボタンが描画されない」「スクロール終端を検知できない」といった現象が出る
やりたいことと、推奨レイアウト・非推奨レイアウトを表にまとめると次のようになります。
| やりたいこと | 推奨レイアウト | 非推奨レイアウト |
|---|---|---|
| Web ページの最下部までスクロールさせたい | WebView を Grid の 1 行として配置し、高さを * で確保 | WebView を ScrollView で包む(二重スクロール) |
| 他の Label / Button と一緒にレイアウトしたい | Grid や VerticalStackLayout で WebView と他の要素を縦に並べる | ScrollView の子に WebView と他の要素をまとめて突っ込む |
| 画面全体をスクロールさせたい | WebView ではなくテキストなどを ScrollView に入れる | WebView を含めて全部 ScrollView に入れる |
WebView を ScrollView に入れないというルールを守るだけで、多くのレイアウトトラブルとスクロール検知の問題が自然と解消します。
初期描画が崩れるときの具体的な対処法
それでも端末や OS バージョンによって、以下のような症状が出ることがあります。
- ラベル・ボタンが真っ白で表示されない
- 画面回転後に WebView の高さが不自然になる
このような場合は、次の順番で対処していくと安定しやすくなります。
- レイアウト構造の見直し
Grid などで「上:ラベル」「中:WebView」「下:ボタン」とし、WebView 行の高さは必ず*かHeightRequestで明示します。 - Navigate 後の再計測
Web.NavigatedイベントでInvalidateMeasure()を呼び出し、レイアウトエンジンに「もう一度測り直して」と指示します。 - フェードインでチラつきを抑える
必要であれば、初期状態でOpacity="0"にしておき、Navigated後にアニメーションでOpacity=1へ持ち上げることで、描画のチラつきを隠すこともできます。
どうしても ScrollView が必要な画面(WebView 以外の長大なフォームなど)では、
- ScrollView に入れるのはテキストやエントリだけにする
- Web コンテンツは別画面に分ける/モーダルで開く
といった設計レベルでの分離を検討すると、結果的に保守性も UX も上がります。
JavaScript 連携による代替アプローチ(上級者向け)
ここまで紹介した方法は、あくまでネイティブ WebView のスクロールイベントだけで完結する方法です。外部サイトを表示する場合など、Web コンテンツ側を変更できないケースではこの方法が最も安全です。
一方で、自前でホストしている HTML であれば、JavaScript から「最下部まで到達した」というイベントを MAUI 側に通知させることもできます。以下はそのシンプルな例です。
// ページ内 JavaScript
window.addEventListener('scroll', () => {
const bottom =
window.innerHeight + window.scrollY >= document.body.scrollHeight - 40;
if (bottom) {
// iOS(WKWebView)
if (window.webkit?.messageHandlers?.maui) {
window.webkit.messageHandlers.maui.postMessage({ type: 'bottom' });
}
// Android(AddJavascriptInterface でブリッジした場合)
if (window.mauiBridge?.postMessage) {
window.mauiBridge.postMessage('bottom');
}
}
});
MAUI 側では、
- iOS:
WKUserContentController.AddScriptMessageHandlerでmauiハンドラを登録 - Android:
AddJavascriptInterfaceを使ってmauiBridgeオブジェクトを注入
といった仕組みを通じて、このメッセージを受け取り、IsBottomReached = true をセットします。
ただし JavaScript 連携には、
- 外部ドメインではセキュリティポリシー上、スクリプトが注入できない
- XSS やオープンリダイレクトへの配慮が必要
- 実装量が増え、デバッグも少し難しくなる
といった注意点もあるため、まずはネイティブスクロールイベント方式で十分かどうかを検討し、それでも足りない場合にのみ採用するのがおすすめです。
動かないときに確認したいチェックリスト
最後に、「スクロールしてもボタンが有効にならない」「ときどき判定がズレる」といったときに確認したいポイントをチェックリスト形式でまとめます。
| チェック項目 | 確認内容 |
|---|---|
| WebView を ScrollView で包んでいないか | もし包んでいる場合は Grid や StackLayout に置き換える |
| RowDefinitions / Height が適切か | WebView の行が * または十分な HeightRequest を持っているか確認 |
| イベント購読のタイミング | ConnectPlatformEvents() が OnHandlerChanged 後に呼ばれているか |
| イベント解除の有無 | DisconnectPlatformEvents() でイベント解除しているか(画面遷移後のリーク対策) |
| しきい値のサイズ | BottomThreshold が 0 のままになっていないか(40〜60 くらいから試す) |
| ナビゲーション完了後の再計測 | Navigated 後に InvalidateMeasure() を呼ぶと安定する端末もある |
これらを一つずつ潰していくことで、ほとんどの「動かない」ケースは解消できるはずです。
まとめ:この構成が「この要件に対する最適解」と言える理由
最後に、本記事で紹介した構成のポイントを再度整理します。
- Renderer ではなく Handler ベースでネイティブ WebView にアクセスする
- 共通プロジェクトに
ScrollWebViewを定義し、状態を表す BindableProperty(IsBottomReached) を持たせる - Android では
Android.Webkit.WebView.ScrollChange、iOS ではWKWebView.ScrollView.Scrolledを購読する - 判定は「コンテンツ高さ − ビュー高さ − しきい値」とスクロール位置の比較で行い、数十ピクセルのゆとりを持たせる
- レイアウトは WebView を ScrollView に入れず Grid などで高さを決める(二重スクロール禁止)
- ボタンの有効化は データバインディングで実現し、コードビハインドから直接触らない
この構成であれば、
- Android / iOS 両対応
- 利用規約・長文コンテンツにも使い回せる
- レイアウトトラブル(ラベル/ボタンの初期非表示)も解消できる
といったメリットがあり、.NET MAUI で「WebView の最下部までスクロールしたらボタンを有効化したい」という要件に対して、汎用性の高い解答になります。実プロジェクトでは、ScrollWebView をライブラリ化しておけば、以後は XAML で貼り付けるだけで同じ UX を再利用できるので、ぜひベースコンポーネントとして育ててみてください。

コメント