.NET MAUI iOS WebViewの高さをHTMLに自動調整する方法|Xamarin.Forms RendererからHandler移行

.NET MAUI で iOS の WebView(WKWebView)を使うと、「HTML コンテンツの実表示高さに合わせて WebView の高さを自動調整したい」という要望がよく出ます。Xamarin.Forms では iOS カスタム Renderer(WkWebViewRenderer)で実現できましたが、MAUI では Renderer の代わりに Handler(ハンドラー)を拡張するのが基本です。本記事では、背景透過(白フラッシュ対策)・WebView 内スクロール無効化・読み込み完了後の高さ反映まで、移行でつまずきやすいポイントを含めて実装例をまとめます。

目次

背景:Xamarin.Forms の iOS Renderer でやっていたこと

Xamarin.Forms(iOS)では、WkWebViewRenderer を継承したカスタム Renderer を作り、ネイティブの WKWebView を直接触って次のような調整をしていたケースが多いはずです。

やりたいことXamarin.Forms(iOS Renderer)での典型例狙い
HTML の表示高さに WebView を合わせたいWKNavigationDelegate.DidFinishNavigation で webView.ScrollView.ContentSize.Height を取得し、HeightRequest を更新「WebView 自体をスクロールさせず、外側の ScrollView でページ全体をスクロール」できる
ダークテーマ等で白フラッシュを抑えたいOpaque=false/背景透過(BackgroundColor=Clear)読み込み中の白いチラつきや背景色の不一致を防ぐ
WebView 内のスクロールを無効にしたいScrollEnabled=false(必要なら Bounces=false なども)スクロールの二重化・ジェスチャ競合を防ぐ

これを .NET MAUI に移行すると、「Renderer の継承」という前提が崩れるため、同じやり方をそのまま当てはめると詰まります。

.NET MAUI の基本:Renderer ではなく Handler を拡張する

.NET MAUI では、コントロールのネイティブ実装(iOS なら WKWebView)に手を入れる場合、基本方針は次のいずれかです。

  • Mapper(マッピング)を追加して WebViewHandler の処理を拡張(軽量で導入が簡単)
  • カスタム Handler を作成して差し替え(ライフサイクル管理がしやすく、複雑な要件向き)

今回の「Xamarin.Forms の Renderer 移行」で最短ルートになりやすいのが、WebViewHandler.Mapper.AppendToMapping(...) を使う方法です。Renderer でやっていた「WKWebView のプロパティ設定」「NavigationDelegate 設定」を、Handler のマッピング内で行います。

観点Xamarin.Forms.NET MAUI移行時の考え方
拡張ポイントCustom Renderer(iOS / Android 別)Handler / Mapper「ネイティブを触る場所」が Handler 側に移動
ネイティブ WebViewWKWebView(Renderer 内)WKWebView(handler.PlatformView)MAUI の Handler が提供する PlatformView を操作
読み込み完了フックWKNavigationDelegate / イベントWKNavigationDelegate を設定同じ WKNavigationDelegate だが「設定場所」が違う

実装の全体像:CustomWebView + iOS の WKNavigationDelegate で高さ反映

やることは大きく分けて 3 つです。

  • 共通プロジェクトに 自動高さ調整を行う WebView(CustomWebView) を用意する
  • MauiProgram.cs で WebViewHandler.Mapper.AppendToMapping を追加し、iOS の WKWebView に設定を入れる
  • iOS 側で WKNavigationDelegate(DidFinishNavigation) を実装して、ContentSize.Height を HeightRequest に反映する

ポイントは「Renderer の代わりに Handler に寄せる」ことと、「iOS の delegate は弱参照扱いになりやすいので、.NET 側で参照を保持する」ことです。

手順1:自動高さ調整用の CustomWebView を作る

まずは共通側(例:Controls フォルダ)にカスタム WebView を作ります。HTML をバインドできるようにしておくと、画面側がシンプルになります。

using System;
using Microsoft.Maui.Controls;

namespace MyApp.Controls;

public class CustomWebView : WebView
{
    public static readonly BindableProperty HtmlProperty =
        BindableProperty.Create(
            nameof(Html),
            typeof(string),
            typeof(CustomWebView),
            default(string),
            propertyChanged: OnHtmlChanged);

    public string Html
    {
        get => (string)GetValue(HtmlProperty);
        set => SetValue(HtmlProperty, value);
    }

    // iOS の WKNavigationDelegate を GC から守るための保持先(プラットフォームごとに使い分け可能)
    internal object? PlatformDelegateKeeper { get; set; }

    internal void ApplyHeight(double height)
    {
        if (height <= 0) return;

        // 微小な差分でレイアウトがガタつくのを避ける
        if (HeightRequest > 0 && Math.Abs(HeightRequest - height) < 0.5)
            return;

        HeightRequest = height;

        // 再計測を促す
        InvalidateMeasure();

        // 親が計測を抑制している場合に備えて親にも通知(効くケースがある)
        if (Parent is VisualElement parent)
            parent.InvalidateMeasure();
    }

    private static void OnHtmlChanged(BindableObject bindable, object oldValue, object newValue)
    {
        var view = (CustomWebView)bindable;
        var html = newValue as string ?? string.Empty;

        // 端末幅に合わせてレイアウトが崩れにくいよう viewport を入れるのが安定(HTML 側で既に入っているなら不要)
        if (!html.Contains("name=\"viewport\"", StringComparison.OrdinalIgnoreCase))
        {
            html = "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">" + html;
        }

        view.Source = new HtmlWebViewSource
        {
            Html = html
        };
    }
}

ここでは、ApplyHeight を内部メソッドとして用意し、iOS 側から HeightRequest を反映できるようにしています。また、後述する iOS の delegate が GC で回収されるのを防ぐため、PlatformDelegateKeeper を用意しています(この一手間が「なぜか動いたり動かなかったりする」を減らします)。

手順2:MauiProgram.cs で WebViewHandler のマッピングを追加する

次に、MAUI の起動時に WebViewHandler を拡張します。builder.ConfigureMauiHandlers(...) の中で、WebViewHandler.Mapper.AppendToMapping を呼び出し、iOS の WKWebView に設定を足します。

using Microsoft.Maui.Handlers;
using MyApp.Controls;

namespace MyApp;

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

        builder
            .UseMauiApp<App>();

        builder.ConfigureMauiHandlers(handlers =>
        {
            WebViewHandler.Mapper.AppendToMapping("CustomWebViewAutoHeight", (handler, view) =>
            {
                if (view is not CustomWebView customWebView)
                    return;

#if IOS
                var wkWebView = handler.PlatformView as WebKit.WKWebView;
                if (wkWebView is null)
                    return;

                // 背景透過(白フラッシュ対策)
                wkWebView.Opaque = false;
                wkWebView.BackgroundColor = UIKit.UIColor.Clear;
                wkWebView.ScrollView.BackgroundColor = UIKit.UIColor.Clear;

                // WebView 内スクロール無効化(外側でスクロールさせる想定)
                wkWebView.ScrollView.ScrollEnabled = false;
                wkWebView.ScrollView.Bounces = false;
                wkWebView.ScrollView.ShowsVerticalScrollIndicator = false;
                wkWebView.ScrollView.ShowsHorizontalScrollIndicator = false;

                // 読み込み完了で高さを反映する delegate を設定
                var navDelegate = new MyApp.Platforms.iOS.AutoHeightNavigationDelegate(customWebView);
                wkWebView.NavigationDelegate = navDelegate;

                // iOS の delegate は弱参照になりやすいので、CustomWebView 側で保持して GC 回収を防ぐ
                customWebView.PlatformDelegateKeeper = navDelegate;
#endif
            });
        });

        return builder.Build();
    }
}

ここでやっていることは Xamarin.Forms の Renderer と同じですが、実行する場所が「Renderer」から「Handler の Mapper」へ移動しています。

なぜ delegate を保持する必要があるのか

iOS の delegate プロパティは「弱参照」扱いのものが多く、wkWebView.NavigationDelegate = new ... しただけだと、.NET 側で強参照がなくなった瞬間に GC で回収されて delegate が呼ばれなくなることがあります。Xamarin.Forms では Renderer 自体が生存しているため問題になりにくかったのですが、Mapper で一時オブジェクトを作る場合は意識しておくと安定します。

手順3:iOS の WKNavigationDelegate で ContentSize.Height を HeightRequest に反映する

最後に、iOS 専用コードとして WKNavigationDelegate を実装します。配置場所は Platforms/iOS 配下が分かりやすいです。

using System;
using System.Threading.Tasks;
using Foundation;
using Microsoft.Maui.Dispatching;
using MyApp.Controls;
using WebKit;

namespace MyApp.Platforms.iOS;

public sealed class AutoHeightNavigationDelegate : WKNavigationDelegate
{
    private readonly WeakReference<CustomWebView> _viewRef;

    public AutoHeightNavigationDelegate(CustomWebView view)
    {
        _viewRef = new WeakReference<CustomWebView>(view);
    }

    public override void DidFinishNavigation(WKWebView webView, WKNavigation navigation)
    {
        base.DidFinishNavigation(webView, navigation);

        // 非同期で高さ更新(描画完了・画像読み込みなどのタイミング差を吸収)
        _ = UpdateHeightAsync(webView);
    }

    private async Task UpdateHeightAsync(WKWebView webView)
    {
        if (!_viewRef.TryGetTarget(out var view))
            return;

        // 実運用では「一発で決まらない」ことがあるため、段階的に再計測するのが安定
        await ApplyOnceAsync(webView, view, delayMs: 50);
        await ApplyOnceAsync(webView, view, delayMs: 200);
        await ApplyOnceAsync(webView, view, delayMs: 500);
    }

    private static async Task ApplyOnceAsync(WKWebView webView, CustomWebView view, int delayMs)
    {
        await Task.Delay(delayMs);

        // WKWebView のスクロールビューが持つ実コンテンツサイズ(iOS のポイント単位)
        var height = (double)webView.ScrollView.ContentSize.Height;

        // UI スレッドで反映(Dispatcher 経由が MAUI らしい)
        view.Dispatcher.Dispatch(() =>
        {
            view.ApplyHeight(height);
        });
    }
}

DidFinishNavigation の直後は ContentSize.Height が 0 に近い値だったり、画像読み込み後に伸びたりすることがあります。そこで「少し待って再計測」を複数回入れておくと、実際の表示に追随しやすくなります。

JavaScript で高さを取る方法もある(ただし使い分けが必要)

状況によっては webView.ScrollView.ContentSize.Height よりも、JavaScript で scrollHeight を取得した方が安定することがあります。例えば、WebKit の計測タイミングや CSS の影響で ContentSize が想定より小さい場合です。

ただし JavaScript の戻り値は HTML 側の「CSS ピクセル」で返ることが多く、ズームや viewport の条件でスケールが絡む可能性があります。まずは ContentSize.Height を第一候補にし、問題が出たら JS 版を検討する流れが現実的です。

// 例:JS で高さを取る(必要に応じて換算が必要なケースあり)
var result = await webView.EvaluateJavaScriptAsync("document.documentElement.scrollHeight");
if (double.TryParse(result?.ToString(), out var jsHeight))
{
    view.Dispatcher.Dispatch(() => view.ApplyHeight(jsHeight));
}

XAML 側の配置例:外側スクロール + 内側 WebView は「高さ自動」で効かせる

自動高さ調整をする場合、レイアウトは「WebView 自体はスクロールさせない」「親(ページ全体)のスクロールに任せる」が基本形です。

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

        <Label Text="記事タイトル" FontSize="22" />

        <controls:CustomWebView
            Html="{Binding ArticleHtml}"
            VerticalOptions="Start"
            HorizontalOptions="Fill" />

        <Button Text="次へ" />

    </VerticalStackLayout>
</ScrollView>

ここで重要なのは VerticalOptions をむやみに Fill や FillAndExpand にしないことです。WebView 側が「余白を埋める」挙動になると、HeightRequest 更新とレイアウト意図が衝突し、結果として高さが反映されない/変に固定される原因になります。

「HeightRequest は取れているのに反映されない」原因はレイアウト側にあることが多い

同じコードでも「値は更新されているのに見た目が変わらない」ことがあります。多くの場合、親レイアウトが子要素のサイズを自由に伸縮させない構造になっています。下表は、よくあるパターンと推奨の整理です。

親レイアウトのパターン起きがちな問題推奨設定・考え方
Grid の Row を *(スター)で固定行が「残り領域」を占有し、HeightRequest が効きにくいWebView を置く行は Auto にする/Grid をやめて VerticalStackLayout に寄せる
WebView に VerticalOptions="FillAndExpand"高さが「コンテンツ」ではなく「余白埋め」優先になり、伸縮が不安定Start を基本にして必要な分だけ伸びる設計にする
WebView を ScrollView の内側に入れている(内側スクロール ON)スクロールが二重になり、高さ調整の目的が崩れるWebView 内スクロールは無効化し、外側 ScrollView に一本化する
親が固定高さ(HeightRequest / Height)子の高さ更新が見た目に反映されない親側の固定を外すか、Auto 行・自動サイズにする

つまずきポイント別:動作しない/反映されないときの対処集

移行スレッドなどで頻出する「詰まりポイント」を、原因のあたりを付けやすい形でまとめます。

症状確認ポイント具体的な対処
HeightRequest の値は更新されているのに表示が変わらない親レイアウトがサイズ変更を許しているかGrid の Row/Column を Auto にする VerticalOptions="Start" を基本にする 必要なら WebView と親に InvalidateMeasure() を入れる
初回表示だけ効かず、戻ってきたら効くdelegate 設定タイミング・描画タイミングDidFinishNavigation 内で Task.Delay を入れて再計測(複数回が効く) 必要に応じて wkWebView.Reload() を試す(再描画トリガ) IsVisible=false → true の再表示で描画が進むケースもある
突然効くようになった/効かなくなったHandler 拡張の適用・ビルド状態Clean / Rebuild(クリーン&リビルド)でハンドラーの差分が安定することがある AppendToMapping が確実に呼ばれているかログで確認する
白フラッシュが残る背景色設定の順序Opaque=false だけでなく WKWebView.BackgroundColor と ScrollView.BackgroundColor を Clear にする 可能なら「読み込み前」に設定が入るよう Mapper で行う

実務で効く小技:高さが“後から”変わる HTML に対応する

HTML は「読み込み完了=高さ確定」とは限りません。例えば次の要素があると、DidFinishNavigation 後に高さが伸びることがあります。

  • 外部画像(遅延ロード含む)
  • Web フォントの読み込み
  • JavaScript で後から DOM を追加する UI

そのため、記事のような HTML を表示する用途では、次のいずれかを入れておくと安定します。

  • 段階的に再計測する(本記事のサンプルのように 50ms/200ms/500ms など)
  • 高さが 0 や極小ならリトライする(数回だけ)
  • より凝るなら、JS で window.onload や ResizeObserver を使ってメッセージをネイティブに通知する

ただし、凝った仕組みほど保守コストも増えます。まずは「段階的再計測」で体感の問題が潰れるケースが多いので、そこから始めるのがおすすめです。

コピペで動かすための最小構成まとめ

最後に、ファイル配置のイメージが掴みやすいように、どこに何を置くかを表にしておきます。

ファイル配置場所の例役割
CustomWebView.csControls/CustomWebView.csHTML バインドと高さ反映(ApplyHeight)を持つ
MauiProgram.csルート(既存)WebViewHandler の Mapper を拡張し、iOS に設定を注入
AutoHeightNavigationDelegate.csPlatforms/iOS/AutoHeightNavigationDelegate.csDidFinishNavigation で ContentSize.Height を WebView に反映
使用側ページ(XAML)Views/AnyPage.xamlCustomWebView を配置(外側 ScrollView と組み合わせる)

この構成にしておけば、Xamarin.Forms の iOS カスタム Renderer でやっていた「背景透過」「スクロール無効」「読み込み完了後の高さ調整」を、.NET MAUI の流儀(Handler 拡張)でほぼ同等に移植できます。特に“反映されない問題”は、コードよりもレイアウト設定が原因のことが多いので、配置(Grid の Auto 化、VerticalOptions の見直し、親の固定高さの排除)まで含めて調整すると安定します。

この記事を書いた人

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

コメント

コメントする

目次