.NET MAUI WebViewの余白問題を解決|ScrollView内でHTML差し替え時に高さを自動調整する方法(Android)

.NET MAUI で「画面全体をスクロールさせたい」ために ScrollView の中へ WebView を入れたところ、初回は正常でも HTML を差し替えた2回目以降に WebView の下へ不自然な余白が残ることがあります。これは WebView の高さがコンテンツ変更に追従せず、古い測定結果が残るのが主因です。Android での実装例(ハンドラー+WebViewClient)と、安定させるための落とし穴をまとめます。

目次

起きている現象を整理する

まず、今回の症状は「WebView がスクロールできない」「HTML が表示されない」といった単純な問題ではなく、「画面全体スクロール(ScrollView)を成立させるために WebView をコンテンツ高に合わせて伸縮させたい」のに、伸縮がうまくいかず余白が残るというタイプです。

よくある画面構成は次のようなものです。

  • タイトル表示の Label
  • 聖人画像の Image(Grid 内でレイアウト)
  • HTML 表示用の WebView
  • その下に「前へ/次へ/再生」などの音声プレーヤー UI(Border)
  • 上記をすべて ScrollView に入れて画面全体を縦スクロール

そして症状としては、次の流れで発生します。

  • 初回:HTML を WebView に読み込む → レイアウトは正常
  • 2回目以降:別日のデータを読み込み、WebView に新しい HTML をセット → WebView 下にブランクスペース(不自然な空白)が残る
  • MAUI の Navigated イベント等で対処しようとすると、今度は コンテンツが表示されない・更新が不安定になることがある
項目初回差し替え後見た目の結果
HTML の長さ長い(例:本文が多い)短い(例:本文が少ない)短くなった分が「余白」として残る
WebView の高さ長い HTML に合わせて確保される再計算されず古い高さのままWebView 自体が縮まらず、下に空白が見える
スクロール意図WebView 内だけをスクロールしたいのではなく、画面全体を ScrollView でスクロールしたい

原因:WebView の高さが「HTML 差し替え」に自動追従しない

結論から言うと、.NET MAUI の WebView は内部 HTML の高さが変わっても、自動で HeightRequest(または測定結果)を再計算してくれる保証がありません。特に ScrollView 内に WebView を置く構成では、この問題が表面化しやすくなります。

ScrollView は子要素の「必要な高さ」を測って全体のスクロール量を決めます。一方で WebView はネイティブ(Android なら Android.Webkit.WebView)上に HTML を描画するため、描画が終わるタイミングや画像の遅延読み込みなど、「高さが確定する瞬間」がレイアウト測定のタイミングとズレやすいという性質があります。

その結果、初回表示でたまたま適切に確保された高さが、そのまま「次の HTML」にも使い回され、短い HTML に差し替えても WebView の背丈が縮まず、中身のない領域=余白が下に残ります。

つまり今回のポイントは次の1点に集約できます。

HTML コンテンツが変わったときに WebView の高さ(HeightRequest)を明示的に更新し、ScrollView のレイアウトを再測定させる必要がある。

対策の全体像

最も安定しやすい方針は、Android のネイティブ WebView 側で「ページ読み込み完了」を確実に捉え、そこから HTML 全体の高さを取得して MAUI の WebView に反映することです。

やりたいこと実現手段狙い
読み込み完了のタイミングを取るAndroid の WebViewClient.OnPageFinishedMAUI の Navigated より「HTML が描画に近い段階」を捉えやすい
HTML 全体の高さを取得するEvaluateJavaScript で scrollHeight を読むWebView の実コンテンツ高を数値化する
WebView の高さを更新するHeightRequest を再設定(必要なら一度 0 に戻す)ScrollView の再レイアウトを促し、余白を消す

そして .NET MAUI では「カスタムレンダラー」ではなく、ハンドラー(Handler)で同等のカスタマイズができます。これにより、従来よりも実装の置き場所が明確になり、保守もしやすくなります。

実装例:Android で HTML 差し替え後も高さを自動調整する

前提:XAML のレイアウト例

画面全体を ScrollView でスクロールさせる場合、WebView は「中身の高さに合わせて伸びる」必要があります。概念的には次のような XAML 構成です(必要な部分だけ抜粋)。

<ScrollView>
  <VerticalStackLayout Spacing="12" Padding="16">

    <Label x:Name="TitleLabel"
           FontSize="22"
           FontAttributes="Bold" />

    <Grid>
      <Image x:Name="SaintImage"
             Aspect="AspectFill"
             HeightRequest="220" />
    </Grid>

    <WebView x:Name="ContentWebView" />

    <Border StrokeThickness="1"
            Padding="12">
      <Grid ColumnDefinitions="Auto,*,Auto">
        <Button Text="前へ" Grid.Column="0" />
        <Button Text="再生" Grid.Column="1" />
        <Button Text="次へ" Grid.Column="2" />
      </Grid>
    </Border>

  </VerticalStackLayout>
</ScrollView>

この構成で「WebView 内だけスクロール」ではなく「画面全体スクロール」を成立させるには、WebView の高さが正しく追従することが重要になります。

重要:ハンドラーのマッピング追加は「一度だけ」にする

質問文のように Page のコンストラクタで AppendToMapping を呼ぶ方法も動きますが、ページが作り直されるたびにマッピングが積み上がると、意図せず処理が複数回走る原因になります。実運用では MauiProgram.cs で一度だけ追加するのが安全です。

using Microsoft.Maui.Handlers;

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

        builder
            .UseMauiApp<App>();

#if ANDROID
        WebViewHandler.Mapper.AppendToMapping("AutoHeight", (handler, view) =>
        {
            if (handler.PlatformView is global::Android.Webkit.WebView platformWebView
                && view is Microsoft.Maui.Controls.WebView mauiWebView)
            {
                platformWebView.SetWebViewClient(new AutoHeightWebViewClient(mauiWebView, platformWebView));
            }
        });
#endif

        return builder.Build();
    }
}

ポイントは次の通りです。

  • Android のときだけネイティブ WebView に WebViewClient を設定する
  • MAUI の WebView(mauiWebView)を保持しておき、後で HeightRequest を更新できるようにする
  • 「ページごとに追加」ではなく「アプリ起動時に一度だけ追加」に寄せる

Custom WebViewClient:OnPageFinished で高さを測って反映する

次が本体です。OnPageFinished は「ページ読み込みが完了した」タイミングとして扱いやすく、ここで HTML 高さを取得して WebView の HeightRequest に反映します。

#if ANDROID
using System.Globalization;
using Android.Webkit;
using Microsoft.Maui.Dispatching;
using Microsoft.Maui.ApplicationModel;

public class AutoHeightWebViewClient : WebViewClient
{
    private readonly Microsoft.Maui.Controls.WebView _mauiWebView;
    private readonly Android.Webkit.WebView _platformWebView;

    public AutoHeightWebViewClient(Microsoft.Maui.Controls.WebView mauiWebView,
                                   Android.Webkit.WebView platformWebView)
    {
        _mauiWebView = mauiWebView;
        _platformWebView = platformWebView;
    }

    public override async void OnPageFinished(Android.Webkit.WebView view, string url)
    {
        // 1) 一度リセットして「縮む方向」も反映されるようにする
        MainThread.BeginInvokeOnMainThread(() => _mauiWebView.HeightRequest = 0);

        // 2) HTML の描画・画像読み込みのズレを吸収(端末・HTML量により調整)
        await Task.Delay(500);

        // 3) 高さを JS で取得(body と html の大きい方を使うと安定しやすい)
        string js = @"
            (function() {
                var body = document.body, html = document.documentElement;
                return Math.max(
                    body ? body.scrollHeight : 0,
                    html ? html.scrollHeight : 0,
                    body ? body.offsetHeight : 0,
                    html ? html.offsetHeight : 0
                ).toString();
            })();
        ";

        string raw = await _mauiWebView.EvaluateJavaScriptAsync(js);

        // EvaluateJavaScriptAsync の戻りが ""1234"" のようにクォートされる場合があるため整形
        var cleaned = (raw ?? string.Empty).Trim().Trim('\"');

        if (double.TryParse(cleaned, NumberStyles.Any, CultureInfo.InvariantCulture, out double newHeight)
            && newHeight > 0)
        {
            // 必要に応じて密度変換が必要になるケースがあります。
            // JS の高さ(CSS px)がそのまま期待通りなら newHeight を直接使ってOK。
            // もし「端末によって高さが合わない」場合は次の density 変換を試してください。
            //
            // var density = view.Resources?.DisplayMetrics?.Density ?? 1f;
            // var heightDip = newHeight / density;

            MainThread.BeginInvokeOnMainThread(() =>
            {
                _mauiWebView.HeightRequest = newHeight;
            });
        }

        base.OnPageFinished(view, url);
    }
}
#endif

この実装で狙っている効果は次の通りです。

  • HTML を差し替えて短くなっても、HeightRequest を 0 → 実測値へ更新するので縮む
  • ScrollView は子要素の高さが変わったため、レイアウトが再計算され、WebView 下の不自然な余白が消える
  • MAUI のイベント(Navigated)だけに頼らず、Android ネイティブ側の「描画に近いタイミング」を使うため、表示の安定性が上がりやすい

HTML 差し替え時の実装例(データ再読み込み)

「別日データを読み込む」処理では、WebView の Source を更新するだけでなく、縮む方向が反映されるように先に HeightRequest をリセットしておくと安定しやすくなります。

private void SetHtmlToWebView(string html)
{
    // 先に縮む余地を作る
    ContentWebView.HeightRequest = 0;

    // 毎回 new した HtmlWebViewSource を渡す(同一インスタンス使い回しを避ける)
    ContentWebView.Source = new HtmlWebViewSource
    {
        Html = html
    };
}

また、HTML 内の CSS が原因で余白が見えているケースも混ざりやすいので、アプリ側で HTML を生成できるなら、最低限次のような初期化を入れると「測定がブレにくい」ことが多いです。

<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
  html, body { margin: 0; padding: 0; }
  body { padding: 10px; box-sizing: border-box; }
  img { max-width: 100%; height: auto; }
</style>

「Navigated を使うと表示されない」問題で確認すべきこと

WebView の高さ調整を MAUI の Navigated イベントでやろうとして、逆に「コンテンツが表示されない」「真っ白になる」などの挙動になることがあります。ここは 原因が複数あり得るので、よくあるパターンと対策を表にまとめます。

よくある原因起きがちな症状対策
Navigated の時点で DOM の高さがまだ確定していない高さが 0 や小さい値で固定され、結果的に見えなくなるAndroid は WebViewClient の OnPageFinished を使い、必要なら Delay を入れる
Navigated 内で Source を再設定してループしている再読み込みが止まらない/途中で表示が崩れるイベント内では Source を触りすぎない。高さ調整だけに責務を限定する
例外が内部で握りつぶされている何も表示されず、原因が分かりにくいEvaluateJavaScriptAsync の戻り値・例外をログ出力し、null/空文字を許容する
HeightRequest=0 のまま次の更新が走らないWebView が 0 高になり、何も見えない「必ず次で再設定される」設計にする。OnPageFinished で値が取れない場合のフォールバックを用意

特に重要なのは、イベントの選び方です。MAUI の Navigated は「ナビゲーション完了」ですが、HTML/CSS/画像の描画完了とは必ずしも一致しません。結果として「測った高さが小さい」→「WebView が小さくなる/見えない」につながります。

高さ取得が安定しないケースと改善策

OnPageFinished で測っても、次のような要因で高さが後から伸びることがあります。

  • HTML 内の画像が遅延ロード(ネットワークやキャッシュ状況でタイミングが変わる)
  • Web フォントのロード後に行高や改行位置が変わる
  • JS で後から要素が追加される(広告、埋め込み、動的生成など)

この場合は「読み込み完了1回だけ」では足りず、高さが落ち着くまで再測定すると安定します。実装の発想としては次のどれかが現実的です。

改善策向いているケースポイント
複数回測定(数百msおきに数回)画像が少し遅れて増える程度「2回連続で同じ高さなら確定」などで打ち切ると動作が軽い
window.onload 後に高さを返す画像・フォントのロードが主因HTML 生成側を触れるなら有効。外部サイトは難しい
MutationObserver で DOM 変化を監視動的に要素が増減する実装は少し重いが、動的ページには強い

「祈りの文章や解説など、基本的に静的 HTML」を表示する用途なら、まずは OnPageFinished + 少し待つで十分なことが多いです。もし端末やコンテンツによってズレる場合のみ、測定回数を増やす方向で調整すると、過剰な複雑化を避けられます。

高さ取得手段の比較:JS か ContentHeight か

Android でコンテンツ高さを取る方法はいくつかあります。実運用で迷いやすいので、特徴を比較します。

方法例メリットデメリット/注意
JavaScript で scrollHeight を取るdocument.body.scrollHeightHTML 側の「実寸」に近い値が取りやすい戻り値の整形が必要なことがある。遅延ロードで変化する場合あり
ネイティブの ContentHeight を使うwebView.ContentHeight * webView.ScaleJS を使わず完結し、シンプルタイミングによって 0 や古い値を掴むことがある。Scale を考慮しないとズレる
レイアウト完了後の measuredHeight を参照ViewTreeObserver 等ネイティブのレイアウト基準で取れるWebView の「中身」ではなく WebView 自体の高さであり、目的とズレやすい

今回の「ScrollView の中で WebView をコンテンツ高に合わせたい」という目的には、JS で scrollHeight を取得する方式が理解しやすく、調整もしやすい選択です。まずはここから始め、端末依存のズレがある場合に density 変換や ContentHeight 方式を検討するのが現実的です。

見落としがちな実装上の注意点

AppendToMapping をページごとに呼ばない

繰り返しになりますが、ページ生成のたびに AppendToMapping を呼ぶと、マッピングが積み上がり同じ処理が複数回実行される可能性があります。結果として、以下のような「直したつもりが新しい不具合」が出やすくなります。

  • OnPageFinished が二重三重に走り、高さがバタつく
  • 意図しないタイミングで HeightRequest=0 が入り、ちらつく
  • ページ遷移を繰り返すと重くなる

アプリ全体で一度だけ登録するのが基本です。

取得した高さが文字列で返る前提で扱う

EvaluateJavaScriptAsync の戻りはプラットフォーム差や実装差で、クォート付き文字列として返ってくることがあります。今回のサンプルのように Trim('\"') で剥がしてからパースすると事故が減ります。

HTML/CSS の余白が本当に「原因」になっていないか切り分ける

「WebView 下の空白」に見えても、実際は HTML の margin-bottom や、最後の要素の padding が原因で余白が存在している場合があります。切り分けとしては次が簡単です。

  • HTML の body に margin:0; padding:0; を一時的に入れてみる
  • WebView の背景色を薄い色に、下の Border の背景色も別の色にして、どこが空いているか視覚化する
  • 最後の要素(p や div)に下余白が入っていないか確認する

パフォーマンスと UX:ScrollView + WebView を採用する前に知っておきたいこと

ScrollView の中に WebView を入れて「WebView をコンテンツ高に伸ばす」構成は、要件に合えば便利ですが、長文・画像多め・端末性能低めの条件で負荷が出やすいのも事実です。実運用で快適にするためのコツをまとめます。

観点おすすめ理由
画像幅を 100% にし、必要なら縮小して配信WebView 内の画像が大きいとレイアウト確定が遅れ、高さ計測もブレやすい
HTML余計な CSS/JS を減らす描画が早いほど OnPageFinished 後の Delay を短くでき、体感が良くなる
再読み込み頻度日付切替など「必要な時だけ」更新毎回 WebView を再構築すると負荷が上がりやすい
フォントWeb フォントを多用しないロード後に行高が変わり、計測値がズレる原因になりやすい

もし「本文はテキスト中心で、HTML は装飾のためだけ」という場合は、HTML をパースして Label / FormattedString で表示するほうが、スクロールやアクセシビリティの面で有利なこともあります。一方で、ルビ・段組・画像混在・リンクなどが必要なら WebView が現実的です。要件に合わせて選ぶのが正解です。

代替案:WebView にスクロールを任せる設計も検討する

今回の要件は「画面全体をスクロールしたい」ですが、場合によっては WebView 側にスクロールを任せたほうがシンプルで安定することもあります。

  • 案A:上部(タイトル・画像)+下部(プレーヤー)を固定し、中央の WebView のみをスクロールさせる
  • 案B:WebView を ScrollView から外し、ページ全体を Grid で組み、WebView を VerticalOptions="Fill" で埋める

この方式だと「WebView の高さをコンテンツに合わせる」という発想自体が不要になり、今回の余白問題も発生しにくくなります。ただし、質問の前提(WebView 内だけスクロールしたいわけではない)とは設計が変わるため、UI/UX の意図に合うかは検討が必要です。

実装チェックリスト

最後に、同種のトラブルを再発させないためのチェック項目をまとめます。

チェック項目OK の目安
ハンドラーのマッピングは一度だけ追加MauiProgram.cs で登録している(ページ生成のたびに追加していない)
HTML 差し替え前に HeightRequest をリセット縮むケースでも余白が残らない
高さ取得値の整形(クォート除去等)端末や OS バージョンが変わってもパースできる
画像やフォントで高さが変わる場合の考慮Delay 調整、複数回測定など「揺れを吸収する策」がある
CSS の余白を切り分けWebView の外の余白か、HTML 内の余白かが判別できている

まとめ

ScrollView 内に WebView を置いて「画面全体をスクロール」させる場合、HTML を差し替えたときに WebView の高さが自動追従せず、過去の高さが残って下部に不自然な余白が出ることがあります。対策は、ページ読み込み完了後に HTML の実高さを取得し、HeightRequest を更新することです。

Android ではハンドラーでネイティブ WebView に WebViewClient を設定し、OnPageFinished で JavaScript を使って scrollHeight を測り、HeightRequest に反映すると、差し替え後も安定してレイアウトが更新されます。さらに「登録は一度だけ」「値の整形」「遅延ロード対策」「CSS余白の切り分け」を押さえると、実機差のトラブルを大きく減らせます。

この記事を書いた人

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

コメント

コメントする

目次