.NET MAUIで「ScrollView内にWebView+下に別コンテンツ(音声フレーム・フッター画像など)」という縦スクロール画面を作ると、AndroidだけHTML更新後にWebViewの高さが合わず、下に空白が出たり途中で見切れたりすることがあります。この記事では、原因の整理から、WebViewHandlerとAndroidネイティブのWebViewClient.OnPageFinishedを使ってHeightRequestを更新し、更新のたびに正しい高さへ追従させる実装を解説します。
現象:初回は正しいのに、コンテンツ更新で余白・見切れが出る
対象の画面構成は、次のような「縦に積む」UIです。
- 背景画像付きのWebView(APIから取得したHTMLを表示)
- その下にオーディオ用のフレーム
- 一番下にフッター画像
全体はScrollView内で縦方向に並べ、WebViewにはHtmlWebViewSourceでHTMLを流し込み、CSSでカスタムフォント(Poppins)や見出し色、html, body { height: 100%; overflow-x: hidden; }などを指定している、というケースです。
このとき、初回ロードは問題が出にくい一方で、APIでHTMLを差し替えると以下のような崩れが発生します。
- WebViewの下に不自然な空白が出る(高さが過大)
- 逆に途中で切れて全部表示されない(高さが不足)
- 要するに「初回に決まったWebViewの高さ」が更新後も残り続ける
MAUI側のNavigatedイベントでJavaScriptから高さ(scrollHeight)を取ってHeightRequestを更新しようとしても、次のようにハマりがちです。
Navigatedが初回しか発火しない/発火するがタイミングが早い- 別メソッドで再計測すると、フォント適用が不安定になったり上部に余白が出たりしてレイアウトが崩れる
なぜAndroidだけ起きやすいのか(ScrollView × WebViewの相性)
本質は「WebViewの中身の高さ(コンテンツ高)を外側レイアウトが正しく再計測できていない」ことです。特に次の条件が重なると、Androidで顕著になりやすいです。
| 条件 | 起きやすい問題 | 理由(Androidで顕著になりやすい点) |
|---|---|---|
| WebViewをScrollView内に置く | WebViewが「画面に収まる高さ」で測られ、コンテンツ高に追従しない | スクロールコンテナの計測は「利用可能サイズ」を前提に進みやすく、WebViewの中身の最終高を自動で取りに行かない |
| HTML更新(Source差し替え)を繰り返す | 初回に近い計測値が残る/イベントが想定通り動かない | HTMLの差し替えが「新しいナビゲーション」として扱われないケースや、描画完了前にイベントが来るケースがある |
| 外部フォント・画像・CSS適用が遅延する | 高さが小さく取れて見切れる/遅れて伸びて余白になる | フォント適用や画像ロードで行の高さ・段組みが変わり、scrollHeightが時間差で変化する |
つまり「MAUI(XAML)側のイベントだけで高さを合わせようとする」と、更新時の描画タイミング差に負けやすく、Androidで空白・見切れが出やすい、という構図です。
解決の基本方針:AndroidネイティブのOnPageFinishedで計測→HeightRequest更新
更新のたびに安定して高さを合わせるなら、Androidネイティブ側のWebViewClient.OnPageFinishedをフックし、ページロード完了のタイミングで「実際のコンテンツ高」を計測してMAUIのHeightRequestに反映するのが効果的です。
MAUIはHandlers(WebViewHandler)を通してネイティブViewにアクセスできます。そこで、AndroidのAndroid.Webkit.WebViewにカスタムWebViewClientを差し込みます。
| アプローチ | 狙い | メリット | 注意点 |
|---|---|---|---|
| MAUIのNavigatedで計測 | 「遷移した」タイミングで高さ取得 | 実装が軽い | 更新時に発火しない/描画前で高さが変わる(フォント・画像) |
| AndroidのOnPageFinishedで計測 | 「ロード完了」寄りで高さ取得 | 更新時に安定しやすい、空白・見切れが減る | タイミングの微調整(リトライ/遅延)が必要な場合がある |
実装:WebViewHandlerにカスタマイズを追加してWebViewClientを差し込む
ここからは、WordPressにそのまま貼れるように、実装の流れを具体例付きでまとめます。ポイントは「既存WebViewの挙動は壊さず、追加でクライアントを差し込む」ことです。
前提:XAML側の配置例(ScrollView内にWebView+下に別コンテンツ)
最小構成のイメージは次のような形です(実際は背景画像やフレームなどが追加されます)。
<ScrollView>
<VerticalStackLayout Spacing="0">
<!-- 背景画像などを重ねたい場合はGridで包んでもOK -->
<WebView x:Name="mywebview"
VerticalOptions="Start"
HorizontalOptions="Fill" />
<Frame Margin="16" Padding="12">
<Label Text="Audio Player Area" />
</Frame>
<Image Source="footer.png"
Aspect="AspectFill" />
</VerticalStackLayout>
</ScrollView>
この形だと「WebView自身がスクロールする」のではなく、「外側のScrollViewが全体をスクロールし、WebViewは中身の高さ分だけ伸びる」状態にしたくなります。そのためにHeightRequestを中身に合わせて更新します。
手順:Handlerのマッピングを追加する(AndroidだけカスタムWebViewClientを設定)
カスタムをどこで注入するかは2パターンあります。
- 各ページのコンストラクタでAppendToMappingする(手軽だが、ページ生成のたびに追加されがち)
- MauiProgram.csで一度だけ設定する(おすすめ)
運用を考えると、MauiProgram.csで一度だけ設定し、対象を限定したい場合は「AutoHeight用のカスタムWebView」を用意して判定するのが安全です。
まずは、必要ならカスタムコントロールを用意します(必須ではありませんが、全WebViewに影響させたくない場合に便利です)。
// AutoHeightWebView.cs(任意)
using Microsoft.Maui.Controls;
namespace YourApp.Controls;
public class AutoHeightWebView : WebView
{
// 将来、リトライ回数や遅延をプロパティ化したい場合の拡張ポイント
}
次にMauiProgram.csでWebViewHandlerにマッピングを追加します。
// MauiProgram.cs(CreateMauiApp内など)
using Microsoft.Maui.Handlers;
using YourApp.Controls;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
// ... 省略 ...
#if ANDROID
WebViewHandler.Mapper.AppendToMapping("AutoHeightMapping", (handler, view) =>
{
// 対象を限定(AutoHeightWebViewだけに適用)
if (view is not AutoHeightWebView mauiWebView)
return;
if (handler.PlatformView is not Android.Webkit.WebView platformWebView)
return;
// JS評価を使うので念のため有効化(環境によっては既に有効)
platformWebView.Settings.JavaScriptEnabled = true;
platformWebView.Settings.DomStorageEnabled = true;
// カスタムWebViewClientを差し込む(下で定義)
platformWebView.SetWebViewClient(new AutoHeightWebViewClient(mauiWebView, platformWebView));
});
#endif
return builder.Build();
}
}
既存のMAUIの挙動に「追加で」処理を差し込むのが、AppendToMappingの大きな利点です。Renderer時代のように全面差し替えをしなくても、必要な部分だけ拡張できます。
手順:Custom WebViewClientを作る(OnPageFinishedで高さを計測して反映)
核心部分です。AndroidのOnPageFinishedで高さ計測を行い、MAUIのHeightRequestへ設定します。
実運用では「フォントや画像の読み込みで高さがあとから伸びる」ことがあるため、1回だけ測るより、短い間隔で数回リトライして“安定した最大値”に寄せると失敗が減ります。
#if ANDROID
using System.Globalization;
using Android.Webkit;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Dispatching;
namespace YourApp.Platforms.Android;
public class AutoHeightWebViewClient : WebViewClient
{
private readonly WebView _mauiWebView;
private readonly Android.Webkit.WebView _platformWebView;
public AutoHeightWebViewClient(WebView mauiWebView, Android.Webkit.WebView platformWebView)
{
_mauiWebView = mauiWebView;
_platformWebView = platformWebView;
}
public override void OnPageFinished(Android.Webkit.WebView view, string url)
{
base.OnPageFinished(view, url);
// WebViewのUIスレッドで動かす
view.Post(async () =>
{
// 以前の高さが残っていると、計測が不安定になったり余白が積み上がることがある
MainThread.BeginInvokeOnMainThread(() => _mauiWebView.HeightRequest = 0);
// 高さが変動しやすい(フォント・画像)ので数回リトライ
const int maxAttempts = 6;
const int intervalMs = 200;
double best = 0;
for (int i = 0; i < maxAttempts; i++)
{
await Task.Delay(intervalMs);
var h = await TryGetContentHeightAsync();
if (h > best)
{
best = h;
// 途中でも更新しておくと、体感の“見切れ時間”が減る
MainThread.BeginInvokeOnMainThread(() =>
{
_mauiWebView.HeightRequest = best;
_mauiWebView.InvalidateMeasure();
});
}
// 十分安定してきたら早めに抜ける(微差は無視)
if (i >= 2 && Math.Abs(h - best) < 1)
break;
}
});
}
private async Task<double> TryGetContentHeightAsync()
{
// body/documentElementの複数候補から最大を取る(HTML/CSSのクセに強い)
var js =
"(function(){" +
"var body=document.body; var html=document.documentElement;" +
"var h=Math.max(" +
"body?body.scrollHeight:0," +
"body?body.offsetHeight:0," +
"html?html.clientHeight:0," +
"html?html.scrollHeight:0," +
"html?html.offsetHeight:0);" +
"return h;" +
"})()";
string result;
try
{
result = await _mauiWebView.EvaluateJavaScriptAsync(js);
}
catch
{
return 0;
}
if (string.IsNullOrWhiteSpace(result))
return 0;
// Androidは返り値が "1234" のようにクォートされる場合がある
result = result.Trim().Trim('"');
if (!double.TryParse(result, NumberStyles.Any, CultureInfo.InvariantCulture, out var height))
return 0;
if (height <= 0)
return 0;
// 端末やズーム条件でズレる場合はscale補正(通常は不要なことも多い)
var scale = _platformWebView.Scale;
if (scale > 0f && Math.Abs(scale - 1f) > 0.01f)
height *= scale;
return height;
}
}
#endif
この実装で意識している点は次のとおりです。
- OnPageFinishedで動かす:MAUI側のNavigatedより「ロード完了」に近いタイミングを取りやすい
- HeightRequestを一度0に戻す:古い高さが残って余白が積み上がるのを防ぐ
- 複数回計測して最大値に寄せる:フォント・画像の遅延適用で後から高さが変わっても追従しやすい
- InvalidateMeasureを呼ぶ:ScrollView配下の再計測を促し、表示の反映を安定させる
手順:XAMLでAutoHeightWebViewを使う
先ほどのAutoHeightWebViewを使う場合、XAMLは次のようにします。
<!-- xmlns:local="clr-namespace:YourApp.Controls" を追加しておく -->
<local:AutoHeightWebView x:Name="mywebview"
VerticalOptions="Start"
HorizontalOptions="Fill" />
既存のWebViewのまま全体適用したいなら、MauiProgramのif判定(AutoHeightWebView限定)を外せばOKです。ただし、アプリ内に複数のWebViewがある場合は、想定外の影響を避けるため限定適用を推奨します。
手順:HTMLを更新する側の注意(毎回“新しいSource”を作る)
更新時のハマりを減らすため、HTMLを差し替えるときは、同じHtmlWebViewSourceインスタンスの中身だけを変えるより、毎回新しいHtmlWebViewSourceを作ってSourceに入れる方が安定することが多いです。
using Microsoft.Maui.Controls;
void SetHtml(string html)
{
var source = new HtmlWebViewSource
{
Html = html
};
mywebview.Source = source;
}
更新のたびに高さ計測が走る設計にしている場合、「更新したのにイベントが来ない」「再描画されない」系のブレを減らすのに効きます。
CSS側のポイント:height: 100%は基本的に正しい(ただし万能ではない)
質問の前提にあるように、次の指定は方向性として正しいです。
html, body {
font-family: 'PoppinsLight', Arial, sans-serif;
margin: 0;
padding: 5;
line-height: 1.6;
overflow-x: hidden;
height: 100%;
}
ただし、HTMLの内容によってはscrollHeightに影響するクセがあります。例えば、以下のような要素は「見た目の高さ」と「DOMが持つ高さ」がズレることがあります。
position: fixedの要素(高さに含まれない/含まれ方が変わる)- 親要素の高さ固定(
height固定)と内部スクロール - 画像の遅延読み込み(ロード完了後に高さが伸びる)
そのため、CSSを整えつつ、アプリ側で複数回計測して最大値に寄せる戦略が相性良いです。
「Navigatedが初回しか動かない/タイミングが早い」問題を整理する
MAUIのWebView.Navigatedは便利ですが、Androidの実装都合・更新方法・キャッシュ状態によっては、次のようなズレが起こり得ます。
- HtmlWebViewSourceの差し替えが、内部的に「ナビゲーション」として扱われないケースがある
- Navigatedが発火しても、フォントや画像がまだ適用されておらず、scrollHeightが後から変わる
- 再計測を別メソッドでやると、計測→再レイアウト→再描画の順序が噛み合わず、上部余白やフォントの適用ブレが出ることがある
一方、OnPageFinishedはAndroid WebViewが「ページロードが完了した」と判断するタイミングに寄りやすく、更新のたびに入口を作りやすいのが強みです(完全に描画が終わる保証ではないため、リトライで補強します)。
高さ計測をさらに安定させる実戦テクニック
テクニック:リトライ回数・間隔を“固定”ではなく“目的”で調整する
端末性能やHTMLの重さはバラつきます。「500ms待てばOK」と決め打ちすると、軽い端末では無駄に待ち、重い端末では足りない、という両方の不満が出ます。そこで、次の考え方が有効です。
- 短い間隔で数回測る(200ms×6回など)
- 「伸びなくなった」または「差が小さい」時点で早めに抜ける
- 最大値に寄せる(途中で小さく測れても、最終的に見切れを回避しやすい)
テクニック:scrollHeight取得式を強める(bodyだけに頼らない)
HTMLの構造・CSSによって、bodyのscrollHeightが期待通りにならないケースがあります。そこで、次のように複数候補の最大値を取ると、レイアウトのクセに強くなります。
(function(){
var body=document.body;
var html=document.documentElement;
return Math.max(
body?body.scrollHeight:0,
body?body.offsetHeight:0,
html?html.clientHeight:0,
html?html.scrollHeight:0,
html?html.offsetHeight:0
);
})()
テクニック:結果がクォートされる/小数が返るのに備える
AndroidのEvaluateJavascriptは戻り値が"1234"のようにクォート付きで返ることがあります。また、小数点が含まれる場合もあるため、次の2点を入れておくと安定します。
Trim('"')でクォートを除去InvariantCultureで数値パース(端末ロケール依存を避ける)
テクニック:ズーム・スケールの影響が疑わしい場合はScale補正
通常はそのままでも合いますが、端末・表示設定・WebViewのスケール条件によっては微妙にズレることがあります。その場合は、Android WebViewのScaleを掛ける(または逆補正する)ことで改善するケースがあります。
ただし、環境によって「補正が不要」なこともあるため、まずは補正なしで動かし、ズレが出る端末があるときだけ適用するのが現実的です。
更新頻度が高い場合の対策:レイアウト負荷とチラつきを抑える
日次コンテンツのように更新頻度が高い場合、更新ごとに何度もHeightRequestを変えると、端末によっては再レイアウトが増えてチラつくことがあります。そんなときは次の工夫が効きます。
| 工夫 | 内容 | 効果 |
|---|---|---|
| しきい値を設ける | 高さ差が小さいとき(例:±2px未満)は更新しない | 無駄な再レイアウトを削減、チラつき軽減 |
| 最大値更新だけにする | 計測値が前回より大きいときだけ反映 | 縮む揺れを避け、見切れを最優先で防ぐ |
| 初期高さを仮で入れる | 更新直後は小さめの仮値を入れ、後で確定 | 真っ白期間を減らし体感を改善 |
記事のコード例は「最大値に寄せる」方針なので、見切れを最優先で潰しやすい設計になっています。
任意:Source変更時にWebViewClientを再設定したい場合
基本的には一度SetWebViewClientすれば維持されますが、画面遷移やHandler再生成の条件が絡むと「気づいたら外れていた」ように見えるケースもゼロではありません。より保険をかけたい場合は、Source変更時に再設定する仕組みを用意できます。
例えば、ページ側でPropertyChangedを拾って、Sourceが変わったタイミングで再設定する例です(AutoHeightWebViewを使っている場合は、その型だけ対象にするのがおすすめです)。
// XAML
<local:AutoHeightWebView x:Name="mywebview"
PropertyChanged="mywebview_PropertyChanged" />
// Code-behind
using System.ComponentModel;
private void mywebview_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
#if ANDROID
if (e.PropertyName == "Source")
{
if (mywebview?.Handler?.PlatformView is Android.Webkit.WebView platformWebView)
{
platformWebView.SetWebViewClient(
new YourApp.Platforms.Android.AutoHeightWebViewClient(mywebview, platformWebView)
);
}
}
#endif
}
この再設定は「必須」ではありませんが、画面構成が複雑なアプリほど保険として効くことがあります。
よくある落とし穴チェックリスト(空白・見切れが残る場合)
| 症状 | 原因の候補 | 対処 |
|---|---|---|
| 更新後に下の空白が増える | 以前のHeightRequestが残り続けている/更新のたびに足し込んでいる | 計測前にHeightRequestを0へ戻す、最大値更新に寄せる |
| 途中で切れる(見切れる) | フォント・画像適用前に測っている | リトライ回数を増やす、間隔を調整する、最大値更新にする |
| 上部に謎の余白が出る | 再計測のタイミングで再レイアウトが連鎖し、描画が揺れている | 更新頻度を間引く(しきい値)、InvalidateMeasureの呼び出し位置を見直す |
| scrollHeightが0やnullになる | HTMLがまだ構築されていない/JSが無効 | OnPageFinishedで実行、JavaScriptEnabledを確認、少し遅延して再試行 |
| 端末によって高さがズレる | WebViewのScale/表示倍率の影響 | Scale補正を検討(まずは特定端末でのみ切り分け) |
まとめ:AndroidのWebViewは“更新のたびに高さを測り直す”設計にする
Androidでだけ発生するWebViewの空白・見切れは、「コンテンツ更新後のWebViewの高さが自動更新されない(または計測が早すぎる)」ことが主因です。MAUIのNavigatedだけに頼ると、更新時に発火しなかったり、フォント・画像ロード前で高さがズレたりして不安定になりがちです。
解決策としては、次の流れが最短ルートになります。
- WebViewHandlerのマッピングを追加し、AndroidネイティブWebViewにカスタムWebViewClientを差し込む
- WebViewClient.OnPageFinishedでJavaScript(scrollHeight)を使ってコンテンツ高を計測する
- 計測値をHeightRequestへ反映し、必要なら短い間隔で数回リトライして最大値に寄せる
- HTML更新時は新しいHtmlWebViewSourceを作ってSourceへ設定し、更新が確実に反映されるようにする
この構成にすると、コンテンツ更新のたびにWebViewの高さが適切に再計測され、ScrollView内のレイアウトが安定しやすくなります。Androidだけ崩れる、更新後だけおかしい、といった症状の再現率が高いケースほど効果が出やすいので、まずはOnPageFinished起点の計測に切り替えてみてください。

コメント