.NET MAUI の Map を表示した直後、ズームインし過ぎて「見たい範囲」や「地図上のピン全体」が見えない――そんな悩みは、地図の初期化タイミングが原因で起こりがちです。この記事では、Android(Google Maps)で採用されやすい “ハンドラー拡張” によって、起動時からズームアウトした広域表示/全ピン表示を安定して実現する方法を解説します。
.NET MAUI の Map が起動時にズームインし過ぎる症状
たとえば次のような状況で困っていませんか。
- アプリ起動直後(ページ表示直後)に Map が近距離に寄り過ぎて、広域が見えない
- ピンを複数立てているのに、最初は 一部のピンしか画面に入らない
Appearing/OnAppearingでMoveToRegionしても 効かない・上書きされる- 少し遅延(
Task.Delay)を入れると改善することもあるが、端末や回線で再発する
結論から言うと、「地図のネイティブ実体(GoogleMap)が準備できる前にカメラ操作している」ことが根本原因になりがちです。特に Android では、Map 表示後に内部処理が走ってカメラが再設定され、あなたのズームアウト指示が後から打ち消されるケースがあります。
原因は「タイミング」:Map は表示=準備完了ではない
.NET MAUI の Map はクロスプラットフォームのラッパーで、内部では各OSの地図コンポーネントが非同期に初期化されます。Android なら実体は Google Maps の GoogleMap で、これは GetMapAsync → OnMapReady の流れで “操作可能な状態” になります。
そのため、ページの Appearing でズームや表示範囲を設定しても、地図がまだ準備中だと
- そもそも命令が効かない
- 一瞬効いたように見えても、あとから内部処理でカメラが再調整されて上書きされる
といった現象が起こります。ズームインし過ぎる問題は「ズーム値の計算」よりも、いつ(どのイベントで)初期カメラを確定させるかが重要です。
よくある対処法と限界(うまくいかない理由)
| 試しがちな方法 | 内容 | うまくいかない主な理由 |
|---|---|---|
| OnAppearing で MoveToRegion | 広い MapSpan を指定してズームアウト | 地図が未初期化で無視される/内部処理に上書きされる |
| Task.Delay を挟む | 500ms〜2s 待ってから MoveToRegion | 端末・回線・初回起動でタイミングが揺れ、再発する |
| ピン追加の順序を変える | ピンを先に入れてから MoveToRegion | ピン追加後に内部で再フィットされ、期待と違うズームになることがある |
| CommunityToolkit だけで完結させる | クロスプラットフォームAPIのみで調整 | プラットフォーム実体の機能差が大きく、ズーム制御が十分に露出していない場合がある |
「毎回必ず初期表示を制御したい」なら、ネイティブ地図が確実に操作可能になった瞬間を捕まえる実装が必要になります。
解決の方向性:ネイティブ API に寄せて “初期カメラ” を確定する
今回の採用回答は、Android の Map を ハンドラー(Handler)拡張で差し替え、GoogleMap を直接操作します。ポイントは次の2つです。
- OnMapReady(地図準備完了)のタイミングでカメラ操作を行う
- 必要に応じて、全体が入る範囲(LatLngBounds)へ
MoveCameraで移動する
これにより「起動直後に勝手に寄る」「MoveToRegion が効いたり効かなかったりする」といったブレを抑えられます。
Android の採用回答:MapHandler 拡張で GoogleMap を直接操作する
全体像(どこを触るのか)
| やること | 置き場所の例 | 狙い |
|---|---|---|
| Map を継承したコントロールを用意(任意) | Controls/FitMap.cs | 「この Map だけ初期ズームを制御」を実現しやすい |
| Android のカスタムハンドラー作成 | Platforms/Android/Handlers/InitialZoomMapHandler.cs | GoogleMap の OnMapReady を捕まえて初期カメラを確定 |
| MauiProgram で Android のみ登録 | MauiProgram.cs | 他プラットフォームに影響を出さない |
任意:初期表示ポリシーを持てるカスタム Map(FitMap)
「世界全体を見せたい」「全ピンをフィットしたい」など、画面やページごとに方針が違うことはよくあります。そこで Map を継承した FitMap を作って、初期表示の条件を XAML から指定できるようにしておくと運用が楽です(必須ではありません)。
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Maps;
namespace MyApp.Controls;
///
/// 初期表示の方針(世界表示 / 全ピンフィット等)をXAMLから指定できるMap
///
public class FitMap : Map
{
public static readonly BindableProperty ShowWorldOnStartProperty =
BindableProperty.Create(
nameof(ShowWorldOnStart),
typeof(bool),
typeof(FitMap),
false);
public bool ShowWorldOnStart
{
get => (bool)GetValue(ShowWorldOnStartProperty);
set => SetValue(ShowWorldOnStartProperty, value);
}
public static readonly BindableProperty FitPinsOnStartProperty =
BindableProperty.Create(
nameof(FitPinsOnStart),
typeof(bool),
typeof(FitMap),
true);
public bool FitPinsOnStart
{
get => (bool)GetValue(FitPinsOnStartProperty);
set => SetValue(FitPinsOnStartProperty, value);
}
public static readonly BindableProperty FitPaddingDpProperty =
BindableProperty.Create(
nameof(FitPaddingDp),
typeof(double),
typeof(FitMap),
48d);
/// <summary>全ピンフィット時の余白(dp)</summary>
public double FitPaddingDp
{
get => (double)GetValue(FitPaddingDpProperty);
set => SetValue(FitPaddingDpProperty, value);
}
public static readonly BindableProperty SinglePinZoomProperty =
BindableProperty.Create(
nameof(SinglePinZoom),
typeof(float),
typeof(FitMap),
14f);
/// <summary>ピンが1つだけのときのズーム(Google Mapsのズームレベル)</summary>
public float SinglePinZoom
{
get => (float)GetValue(SinglePinZoomProperty);
set => SetValue(SinglePinZoomProperty, value);
}
}
Android:カスタムハンドラーで OnMapReady を捕まえる
ここが本題です。Android では GetMapAsync → OnMapReady を通して GoogleMap を受け取り、初期ズームを設定します。採用回答のポイントである SetMaxZoomPreference によるズーム上限の一時的な抑制と、LatLngBounds+MoveCamera を組み込みます。
さらに実務では、OnMapReady 直後に NewLatLngBounds を呼ぶと「MapView のサイズ未確定」で失敗することがあります。そこで下の例では OnMapLoaded(地図タイル読込完了)で初期カメラを適用して安定させています。
#if ANDROID
using Android.Gms.Maps;
using Android.Gms.Maps.Model;
using Microsoft.Maui.Maps.Handlers;
using MyApp.Controls;
namespace MyApp.Platforms.Android.Handlers;
///
/// Android(Google Maps)向けに、Map 起動直後のズーム/表示範囲を確定させるハンドラー
///
public class InitialZoomMapHandler : MapHandler, IOnMapReadyCallback
{
private GoogleMap? _googleMap;
private bool _initialCameraApplied;
protected override void ConnectHandler(global::Android.Gms.Maps.MapView platformView)
{
base.ConnectHandler(platformView);
// GoogleMap の準備完了を待つ
platformView.GetMapAsync(this);
}
protected override void DisconnectHandler(global::Android.Gms.Maps.MapView platformView)
{
_googleMap = null;
base.DisconnectHandler(platformView);
}
public void OnMapReady(GoogleMap googleMap)
{
_googleMap = googleMap;
if (_initialCameraApplied)
return;
// MapView のサイズが確定していないと NewLatLngBounds が失敗することがあるため、
// 「地図が読み込まれた」タイミングで初期カメラを適用する
googleMap.SetOnMapLoadedCallback(new MapLoadedCallback(() =>
{
ApplyInitialCamera(googleMap);
}));
}
private void ApplyInitialCamera(GoogleMap googleMap)
{
if (_initialCameraApplied)
return;
_initialCameraApplied = true;
var map = VirtualView as FitMap;
// 余白(dp → px)
var paddingDp = map?.FitPaddingDp ?? 48d;
var paddingPx = DpToPx(paddingDp);
// 優先順位:世界表示 → 全ピンフィット → フォールバックで世界表示
if (map?.ShowWorldOnStart == true)
{
MoveToWorld(googleMap, paddingPx);
return;
}
if (map?.FitPinsOnStart != false)
{
if (TryMoveToFitPins(googleMap, paddingPx, map?.SinglePinZoom ?? 14f))
return;
}
MoveToWorld(googleMap, paddingPx);
}
private void MoveToWorld(GoogleMap googleMap, int paddingPx)
{
// 採用回答の考え方:
// “勝手にズームイン”を抑えるため、初期設定中だけ上限を低くする(必要に応じて調整)
googleMap.SetMaxZoomPreference(1f);
// 極付近はタイルが不安定になりやすいので緯度は±85程度に抑える
var southWest = new LatLng(-85, -179);
var northEast = new LatLng(85, 179);
var bounds = new LatLngBounds(southWest, northEast);
googleMap.MoveCamera(CameraUpdateFactory.NewLatLngBounds(bounds, paddingPx));
// ユーザー操作でズームインできるよう、上限を元に戻す
googleMap.SetMaxZoomPreference(21f);
}
private bool TryMoveToFitPins(GoogleMap googleMap, int paddingPx, float singlePinZoom)
{
// FitMap は Map を継承しているので Pins が使える
var map = VirtualView as FitMap;
if (map?.Pins == null || map.Pins.Count == 0)
return false;
if (map.Pins.Count == 1)
{
var loc = map.Pins[0].Location;
googleMap.MoveCamera(CameraUpdateFactory.NewLatLngZoom(
new LatLng(loc.Latitude, loc.Longitude),
singlePinZoom));
return true;
}
var builder = new LatLngBounds.Builder();
foreach (var pin in map.Pins)
{
var loc = pin.Location;
builder.Include(new LatLng(loc.Latitude, loc.Longitude));
}
var bounds = builder.Build();
googleMap.MoveCamera(CameraUpdateFactory.NewLatLngBounds(bounds, paddingPx));
return true;
}
private int DpToPx(double dp)
{
var density = PlatformView?.Context?.Resources?.DisplayMetrics?.Density ?? 1f;
return (int)Math.Round(dp * density);
}
private sealed class MapLoadedCallback : global::Java.Lang.Object, GoogleMap.IOnMapLoadedCallback
{
private readonly Action _action;
public MapLoadedCallback(Action action) => _action = action;
public void OnMapLoaded() => _action();
}
}
#endif
MauiProgram.cs で Android のみハンドラーを登録
作成したハンドラーは ConfigureMauiHandlers に登録します。Android 条件付きにして、iOS/Windows 側へ影響が漏れないようにします。Map を使う場合は UseMauiMaps() も忘れずに。
using Microsoft.Maui.Controls.Maps;
using MyApp.Controls;
namespace MyApp;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseMauiMaps();
builder.ConfigureMauiHandlers(handlers =>
{
#if ANDROID
handlers.AddHandler(typeof(FitMap), typeof(MyApp.Platforms.Android.Handlers.InitialZoomMapHandler));
#endif
});
return builder.Build();
}
}
ページ側(XAML / C#)の使用例
カスタム Map(FitMap)を使う場合は XAML で配置し、ピンは通常どおり追加します。起動時に全ピンを見せたい場合、基本は 「ページ表示前〜表示直後」にピンが入っている状態にしておくと、初期フィットが狙い通りになりやすいです。
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
x:Class="MyApp.Pages.MapPage"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:MyApp.Controls">
<controls:FitMap
x:Name="MyMap"
ShowWorldOnStart="False"
FitPinsOnStart="True"
FitPaddingDp="48"
SinglePinZoom="14" />
using Microsoft.Maui.Controls.Maps;
using Microsoft.Maui.Devices.Sensors;
namespace MyApp.Pages;
public partial class MapPage : ContentPage
{
public MapPage()
{
InitializeComponent();
// 例:複数ピンを追加(ピンがある場合は起動時に全ピンが見える範囲に自動フィット)
MyMap.Pins.Add(new Pin
{
Label = "東京駅",
Location = new Location(35.681236, 139.767125)
});
MyMap.Pins.Add(new Pin
{
Label = "大阪駅",
Location = new Location(34.702485, 135.495951)
});
}
}
初期表示パターン:世界全体を最初から見せる
「とにかく起動時はズームアウトして、世界全体や広域を見せたい」場合は、LatLngBounds を広域(例:緯度±85、経度±179)で固定し、MoveCamera(NewLatLngBounds) でフィットさせるのが分かりやすい実装です。
- 緯度を±90にしない:極付近は投影・タイルの都合で不安定になりやすいため、±85程度に抑えると安全です
- padding(余白)を入れる:画面の端ギリギリを避け、UI(検索バー、ボトムシート等)とも干渉しにくくなります
採用回答のように SetMaxZoomPreference(1f) を使う場合は、「初期化中だけ上限を絞る → その後戻す」という形にしておくと、ユーザーが手動でズームインできる余地も残せます。
初期表示パターン:全ピンが入る範囲に自動フィットする
実務では「世界全体を見せたい」よりも、“ユーザーが必要な情報(ピン)を最初から見せたい”が目的になりがちです。そこで、ピン座標から LatLngBounds を組み立ててフィットする実装が自然です。
| ピンの状態 | おすすめの初期表示 | 理由 |
|---|---|---|
| 0件 | 世界表示 or デフォルト地域 | 対象がないのでフォールバックが必要 |
| 1件 | NewLatLngZoom で適度なズーム | Bounds が点だとズーム計算が不安定になりやすい |
| 2件以上 | NewLatLngBounds でフィット | 全ピンが確実に画面に入る |
上のハンドラー例の TryMoveToFitPins は、まさにこの方針で作っています。さらに現場で詰まりやすいのが次の論点です。
- ピン追加が後から行われる:API 取得後に Pins を追加する場合、初期化時点では 0件なのでフィットできません。データ到着後に「再フィット」する導線が必要です。
- ピンが広範囲(経度180度またぎ):単純な最小最大計算だと最短ルートでなく全周に近い Bounds になり得ます。国際日付変更線またぎを扱うなら別ロジックを検討してください。
- 同一点が多い:同じ座標に多数ピンがあると、フィットしても点に寄りすぎるため、クラスター表示や“最低スパン”の導入が有効です。
安定させるコツ:OnMapReady だけに頼らない
採用回答で「OnMapReady 内で MoveCamera する」だけでも改善することは多いですが、以下の理由で不安定になることがあります。
- MapView のレイアウトがまだ確定しておらず、
NewLatLngBoundsが例外を投げる - 地図タイルのロード後に内部でカメラが再設定される
- 画面回転や再表示で同じ処理が繰り返され、ユーザー操作を邪魔する
その対策として、上のハンドラー例では
SetOnMapLoadedCallbackを使い、地図が描画できる状態で初期カメラを適用_initialCameraAppliedのフラグで二重適用を防止- padding を dp → px 変換して端末差を吸収
を入れています。これだけでも「端末によって効いたり効かなかったり」をかなり減らせます。
iOS / Windows にはそのまま適用できない理由と、実務での考え方
ここは重要です。Android の採用回答は Google Maps を直接触るため、iOS / Windows にそのまま移植できません。理由は単純で、地図の実体が別物だからです。
| プラットフォーム | 地図の実体 | 初期表示を制御する代表的なフック | 考え方 |
|---|---|---|---|
| Android | Google Maps(GoogleMap) | GetMapAsync / OnMapReady | カスタムハンドラーでネイティブAPIを操作するのが確実 |
| iOS | MapKit(MKMapView) | カスタムハンドラーで MKMapView に対して region を設定 | “全ピンフィット”は ShowAnnotations などで実現する方が自然 |
| Windows | 実装により異なる(CommunityToolkit.Maui.Maps は WebView2 ベース等) | ライブラリごとのAPI | ズーム制御APIが限定的な場合があり、プラットフォーム別実装か別コンポーネント検討が必要 |
CommunityToolkit.Maui.Maps のように Windows 側が WebView2 ベースの場合、ズームは WebView の仕組みに依存し、「ネイティブのカメラAPIのように細かく制御できない」・「公開APIが足りず扱いにくい」ケースがあります。Windows でも同じ体験を揃えたい場合は、最初から「プラットフォーム差が出る前提」で設計する方が安全です。
実務的には、次のように設計すると後で苦しみにくいです。
- 「どの範囲を見せたいか(世界/全ピン/特定地域)」のロジックは共通化する(ピン座標から bounds を作る、など)
- 「どうやって表示範囲を当てるか」はプラットフォーム別(Android: GoogleMap、iOS: MKMapView、Windows: 採用ライブラリ)で分ける
- クロスプラットフォームAPIで足りない部分は、無理に共通化せず “差分として閉じ込める”
トラブルシューティング:よくある詰まりポイント
| 症状 | 原因の候補 | 対処 |
|---|---|---|
| 初期ズームが効かない | OnAppearing など早すぎるタイミングで操作 | Android は OnMapReady / OnMapLoaded で MoveCamera する |
| NewLatLngBounds で例外が出る | MapView のサイズが 0 のタイミング | OnMapLoaded まで待つ、または PlatformView.Post で遅延実行 |
| ピンが後から追加されると全体表示にならない | 初期化時点の Pins が 0 件 | データ取得後に “再フィット” を呼ぶ導線を用意 |
| 初期表示が毎回戻って操作しづらい | 画面再表示で初期カメラが再適用されている | 一度だけ適用するフラグを入れる(状態復元方針も検討) |
| 世界表示はできたが、ユーザーがズームインできない | SetMaxZoomPreference を低いままにしている | 初期設定後に上限を戻す(または ZoomTo を使い上限制限を使わない) |
まとめ:目的は “ズームアウト” ではなく “最初から見せたいものを見せる”
.NET MAUI の Map 初期表示でズームが意図せず寄ってしまう問題は、ズーム値そのものよりも 地図の初期化タイミングが本質です。Android では MapHandler 拡張で GoogleMap を直接操作し、OnMapReady(必要なら OnMapLoaded)で初期カメラを確定させると、起動直後から安定して広域表示/全ピン表示ができます。
また、実務では「世界表示」よりも「全ピンフィット」が成果に直結しやすいので、ピン座標から bounds を作る設計をベースに、プラットフォーム別のネイティブAPIで“当てる”実装を組み立てるのが堅実です。

コメント