.NET MAUI WebViewで最下部スクロールを検知してボタンを有効化する実装手順(iOS/Android対応)

.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 validHandler 経由でネイティブ WebView のスクロールイベントを購読

つまり結論としては、 「スクロール終端を検知したいなら Renderer ではなく Handler を使う」 という方針が必須になります。

実装方針の全体像

今回の記事では、次のような構成をゴールとします。

  1. 共通プロジェクトに ScrollWebView というカスタム WebViewを定義する
  2. ScrollWebView には、スクロール状態を表す IsBottomReached(BindableProperty)を持たせる
  3. 各プラットフォームで Handler を通じてネイティブ WebView を取得し、スクロールイベントを購読する
  4. スクロールのたびに「コンテンツ高さ − ビュー高さ − しきい値」と現在位置を比較し、最下部かどうかを判定する
  5. XAML では IsBottomReached とボタンの IsEnabled をデータバインドする
  6. WebView を ScrollView で包まず、Grid などで高さを明示する

プラットフォームごとのスクロールイベント対応表は次のとおりです。

プラットフォームネイティブ WebView利用するスクロールイベント備考
AndroidAndroid.Webkit.WebViewScrollChangeContentHeight * Scale でコンテンツ高さを算出
iOSWKWebViewScrollView.ScrolledContentOffset.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 つです。

  1. ContentHeight は CSS px 単位なので、Scale を掛けて実際のピクセルに変換する
  2. WebView の高さ(v.Height)を引くことで、スクロールできる最大位置(cutoff)を求める
  3. 「ギリギリまでスクロールしなくても 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 の高さが不自然になる

このような場合は、次の順番で対処していくと安定しやすくなります。

  1. レイアウト構造の見直し
    Grid などで「上:ラベル」「中:WebView」「下:ボタン」とし、WebView 行の高さは必ず * か HeightRequest で明示します。
  2. Navigate 後の再計測
    Web.Navigated イベントで InvalidateMeasure() を呼び出し、レイアウトエンジンに「もう一度測り直して」と指示します。
  3. フェードインでチラつきを抑える
    必要であれば、初期状態で 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 を再利用できるので、ぜひベースコンポーネントとして育ててみてください。

この記事を書いた人

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

コメント

コメントする

目次