Indiko.Maui.Controls.MapBox
1.8.0
dotnet add package Indiko.Maui.Controls.MapBox --version 1.8.0
NuGet\Install-Package Indiko.Maui.Controls.MapBox -Version 1.8.0
<PackageReference Include="Indiko.Maui.Controls.MapBox" Version="1.8.0" />
<PackageVersion Include="Indiko.Maui.Controls.MapBox" Version="1.8.0" />
<PackageReference Include="Indiko.Maui.Controls.MapBox" />
paket add Indiko.Maui.Controls.MapBox --version 1.8.0
#r "nuget: Indiko.Maui.Controls.MapBox, 1.8.0"
#:package Indiko.Maui.Controls.MapBox@1.8.0
#addin nuget:?package=Indiko.Maui.Controls.MapBox&version=1.8.0
#tool nuget:?package=Indiko.Maui.Controls.MapBox&version=1.8.0
Indiko.Maui.Controls.MapBox
Native Mapbox map control for .NET MAUI (Android + iOS), built on the Mapbox Maps SDK v11
with self-maintained bindings via a thin native facade (IndikoMapboxKit).
MapView (cross-platform MAUI View)
└── MapViewHandler (per platform)
└── IndikoMapboxKit facade (Kotlin / Swift @objc)
└── Mapbox Maps SDK v11 (native)
Every feature is exposed twice: as a classic .NET event for code-behind usage and as a
bindable ICommand for MVVM — pick whichever fits your app. All events/commands are raised
on the UI thread; command parameters carry the same EventArgs object the event delivers.
Features
- 🗺️ All Mapbox styles (Standard, Streets, Dark, Satellite, … or any custom style URI)
- 🎥 Camera control: declarative
Cameraproperty, animatedFlyTo, liveCameraChanged - 🔲 FitBounds: fit the camera to a bounding box or to all map content — manually or automatically (
AutoFitBounds) - 📍 Markers (point annotations) with per-marker color, custom icon, title, payload and click events
- ✋ Draggable markers with a drag-end event and automatic model sync
- ➰ Polylines and polygons with styling and click events
- 🧩 GeoJSON sources with fill/line/circle/symbol style layers (survive style switches automatically)
- 🔵 Point clustering with managed layers and tap-to-expand zoom
- 💬 View annotations — any MAUI view anchored to a coordinate, gestures included
- 📴 Offline regions (style pack + tiles) with download progress events
- 🧭 Compass on rotation, scale bar, user-location puck, per-gesture configuration
- 🎛️ Ornament visibility: compass, scale bar, Mapbox logo and attribution individually switchable
- 🏔️ 3D terrain (Mapbox DEM + sky atmosphere) with configurable exaggeration — switchable, style-switch-safe
- 🎯 Follow-puck mode: camera follows the user's position — switchable, with state-change events
- 🔭
GetVisibleBounds()returns the exact viewport bounding box (e.g. for offline downloads) - ⏳ Imperative calls (
AddGeoJsonSource,AddLayer, …) issued before the handler is attached are queued and replayed automatically - 👆 Click-consumed semantics:
MapClickedfires only for taps on empty map
Screenshots
| iOS | Android | |
|---|---|---|
| Map, markers, polyline & polygon | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/ios-map-shapes.png" width="260" alt="iOS map with markers and shapes" /> | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/android-map-shapes.png" width="260" alt="Android map with markers and shapes" /> |
| GeoJSON sources & layers (survive style switches) | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/ios-geojson.png" width="260" alt="iOS GeoJSON layers" /> | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/android-geojson-dark.png" width="260" alt="Android GeoJSON layers on dark style" /> |
| Clustering with tap-to-expand | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/ios-clustering.png" width="260" alt="iOS clustering" /> | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/android-cluster-expand.png" width="260" alt="Android cluster expanded after tap" /> |
| View annotations (MAUI views on the map) | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/ios-view-annotation.png" width="260" alt="iOS view annotation bubble" /> | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/android-view-annotation.png" width="260" alt="Android view annotation with tapped MAUI gesture" /> |
| Follow-puck mode | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/ios-follow-puck.png" width="260" alt="iOS follow puck" /> | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/android-follow-puck.png" width="260" alt="Android follow puck" /> |
| Offline regions (style pack + tiles with progress) | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/ios-offline.png" width="260" alt="iOS offline region downloaded" /> | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/android-offline-airplane.png" width="260" alt="Android offline region downloaded" /> |
| AutoFitBounds / draggable markers | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/ios-autofit.png" width="260" alt="iOS auto fit bounds" /> | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/android-marker-dragged.png" width="260" alt="Android marker dragged to a new position" /> |
| 3D terrain (DEM + sky atmosphere) | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/ios-terrain.png" width="260" alt="iOS 3D terrain over the Lauterbrunnen valley" /> | <img src="https://raw.githubusercontent.com/0xc3u/Indiko.Maui.Controls.MapBox/main/docs/images/android-terrain.png" width="260" alt="Android 3D terrain over the Lauterbrunnen valley" /> |
Compatibility
Versions
| Component | Version |
|---|---|
| .NET / MAUI | net10.0-android, net10.0-ios (MAUI 10) |
| Minimum OS | Android 11 (API 30) · iOS 14.2 |
| Mapbox Maps SDK Android | 11.30.1 (pinned) |
| Mapbox Maps SDK iOS | 11.26.0 (pinned) |
Feature matrix vs. the official Mapbox Maps SDK v11
✅ supported · 🔶 partial · ❌ not yet — feature requests and PRs are welcome.
| Mapbox SDK area | Status | This control's API / notes |
|---|---|---|
| Map display & styles | ||
Style URIs (Standard, Classic, custom mapbox://styles/…) |
✅ | StyleUri, MapStyles constants |
| Runtime style switching | ✅ | runtime sources/layers/clusters are re-applied automatically |
| Standard style configuration (light preset, theme, 3D objects) | ❌ | style renders with its defaults |
| Style JSON strings | ❌ | URIs only |
| Camera | ||
| Set camera (center, zoom, bearing, pitch) | ✅ | Camera property (one-way), CurrentCamera (live) |
Animated camera (flyTo) |
✅ | FlyTo(camera, durationMs) |
Fit to bounding box (cameraForCoordinateBounds) |
✅ | FitBounds, FitBoundsToContent, switchable AutoFitBounds |
| Camera padding / anchor offsets | 🔶 | uniform FitBoundsPadding only |
Visible viewport bounds (coordinateBoundsForCamera) |
✅ | GetVisibleBounds() |
| Annotations | ||
| Point annotations (markers) | ✅ | Annotations collection: color, title, Tag payload, click events |
| Draggable point annotations | ✅ | IsDraggable + AnnotationDragged (model auto-synced) |
| Custom marker icons (PNG/JPEG bytes) | ✅ | IconData + IconWidth/IconHeight (dp) + IconAnchor; shared textures |
| Polyline / polygon annotations | ✅ | Polylines / Polygons collections with click events |
| Circle annotations | 🔶 | via GeoJSON circle layers, not as annotation objects |
| View annotations (native views on the map) | ✅ | ViewAnnotations — MAUI views incl. working gesture recognizers |
| Data-driven styling | ||
| GeoJSON sources (add / live-update / remove) | ✅ | AddGeoJsonSource (add-or-update semantics) |
| Fill / line / circle layers | ✅ | AddLayer(MapLayer) with color, opacity, width, radius, insert position |
| Symbol layers (data-driven labels + icons) | ✅ | MapLayerType.Symbol: TextField property labels, halo, icon bytes, overlap |
| Heatmap, fill-extrusion, raster, hillshade, sky layers | ❌ | |
| Vector / raster / image sources | ❌ | GeoJSON only |
| Expressions | 🔶 | used internally (cluster steps); not exposed as API |
| Clustering | ✅ | AddClusteredSource — managed layers, counts, tap-to-expand zoom |
| Gestures & interaction | ||
| Map tap / long-press with coordinates | ✅ | events + commands; taps on markers/shapes/clusters are consumed |
| Per-gesture enable/disable | ✅ | ScrollEnabled, ZoomEnabled, RotateEnabled, PitchEnabled |
queryRenderedFeatures |
🔶 | used internally (cluster tap); not exposed as API |
| Location | ||
| Location puck | ✅ | ShowUserLocation (2D puck with bearing) |
| Follow-puck viewport | ✅ | FollowPuck (+ zoom/bearing options), state synced back on user pan |
| Custom puck appearance | ❌ | |
| Offline | ||
| Style pack + tile region download | ✅ | DownloadOfflineRegion with progress/completed events |
| Cancel / delete regions | ✅ | RemoveOfflineRegion |
| List regions, size estimates | ❌ | |
| Ornaments | ||
| Compass (auto-shows on rotation, tap resets north) | ✅ | SDK default behavior, switchable via ShowCompass |
| Scale bar | ✅ | SDK default behavior, switchable via ShowScaleBar |
| Ornament visibility | ✅ | ShowCompass, ShowScaleBar, ShowMapboxLogo, ShowAttribution (logo/attribution: check your Mapbox plan/ToS) |
| Ornament position / margins | ❌ | SDK default positions |
| Other | ||
Lifecycle events (MapReady, StyleLoaded, CameraChanged) |
✅ | events + bindable commands (full MVVM parity) |
| Snapshotter (static map images) | ❌ | |
| 3D terrain (raster-DEM + sky atmosphere) | ✅ | TerrainEnabled + TerrainExaggeration |
| Globe, custom projections | ❌ | whatever the chosen style ships by default |
| Telemetry opt-out | ✅ | UseMapbox(token, enableTelemetry: false) — see Telemetry |
Getting started
dotnet add package Indiko.Maui.Controls.MapBox
Register the handler and your Mapbox public access token (pk.… from
https://account.mapbox.com) in MauiProgram.cs:
using Indiko.Maui.Controls.MapBox;
builder
.UseMauiApp<App>()
.UseMapbox("pk.YOUR_MAPBOX_ACCESS_TOKEN");
Telemetry
The Mapbox Maps SDK collects location telemetry by default — it sends device locations and map-usage events to Mapbox, and Mapbox requires apps to offer an opt-out (the built-in attribution "i" button provides one under Make Mapbox Maps Better).
If your app must not send locations to Mapbox at all, switch it off for every user:
builder.UseMapbox("pk.YOUR_MAPBOX_ACCESS_TOKEN", enableTelemetry: false);
Only Mapbox's billing turnstile event remains. Worth knowing before you fill in an App Store privacy questionnaire or a Google Play Data Safety form: with telemetry on, precise location counts as collected and shared with a third party.
Add the map to a page:
<ContentPage xmlns:map="clr-namespace:Indiko.Maui.Controls.MapBox;assembly=Indiko.Maui.Controls.MapBox"
xmlns:mapModels="clr-namespace:Indiko.Maui.Controls.MapBox.Models;assembly=Indiko.Maui.Controls.MapBox">
<map:MapView x:Name="Map" StyleUri="{x:Static mapModels:MapStyles.Streets}">
<map:MapView.Camera>
<mapModels:MapCameraPosition Latitude="47.3769" Longitude="8.5417" Zoom="11" />
</map:MapView.Camera>
</map:MapView>
</ContentPage>
The MVVM samples below use CommunityToolkit.Mvvm
([RelayCommand], [ObservableProperty]), but any ICommand implementation works.
Map styles
StyleUri accepts the constants from MapStyles (Standard, StandardSatellite, Streets,
Outdoors, Light, Dark, Satellite, SatelliteStreets) or any custom
mapbox://styles/{user}/{styleId} URI. StyleLoaded fires after every style switch.
Runtime content (GeoJSON layers, clusters) is re-applied automatically after a switch.
Event-driven
Map.StyleUri = MapStyles.Dark;
Map.StyleLoaded += (_, _) => Debug.WriteLine("style is live");
MVVM
<map:MapView StyleUri="{Binding StyleUri}"
StyleLoadedCommand="{Binding StyleLoadedCommand}" />
[ObservableProperty]
private string styleUri = MapStyles.Streets;
[RelayCommand]
private void StyleLoaded() => IsStyleReady = true;
Camera & FlyTo
Camera sets the position instantly (one-way). FlyTo animates. CurrentCamera always holds
the live position; CameraChanged fires continuously while the user pans/zooms/rotates.
Event-driven
Map.MapReady += (_, _) => Map.FlyTo(new MapCameraPosition(46.9480, 7.4474, zoom: 12), durationMs: 2000);
Map.CameraChanged += (_, e) => ZoomLabel.Text = $"Zoom {e.Camera.Zoom:F1}";
MVVM
<map:MapView x:Name="Map"
Camera="{Binding StartCamera}"
MapReadyCommand="{Binding MapReadyCommand}"
CameraChangedCommand="{Binding CameraChangedCommand}" />
public MapCameraPosition StartCamera { get; } = new(47.3769, 8.5417, zoom: 11);
[RelayCommand]
private void CameraChanged(CameraChangedEventArgs e) => CurrentZoom = e.Camera.Zoom;
FlyTo is imperative by design. In MVVM, expose the MapView to the ViewModel via a slim
interface, or call it from the view in response to a ViewModel message.
FitBounds
Fit the camera to a bounding box, to the current content, or keep it fitted automatically.
FitBoundsPadding (default 40 dp) controls the uniform edge padding.
Event-driven / imperative
// explicit bounding box (e.g. a GPS track's extent):
Map.FitBounds(new MapBounds(minLat: 47.32, minLng: 8.46, maxLat: 47.43, maxLng: 8.62),
padding: 60, durationMs: 800);
// fit to everything currently on the map (annotations, polylines, polygons, view annotations):
Map.FitBoundsToContent();
// helper: compute bounds from any positions
var bounds = MapBounds.FromPositions(track.Points.Select(p => (p.Lat, p.Lng)));
MVVM — switchable auto mode
<map:MapView Annotations="{Binding Pins}"
Polylines="{Binding Routes}"
AutoFitBounds="{Binding IsAutoFitEnabled}"
FitBoundsPadding="60" />
[ObservableProperty]
private bool isAutoFitEnabled = true;
While AutoFitBounds is enabled, every change to Annotations/Polylines/Polygons/
ViewAnnotations re-fits the camera to the full content; turning it off returns camera
control to the user. Explicit FlyTo/Camera calls are not overridden — auto-fit only
reacts to content changes.
Markers (annotations)
Annotations is a bindable ObservableRangeCollection<MapAnnotation> — add/remove/clear and
the map updates. AddRange/ReplaceRange avoid per-item notifications for bulk updates.
Each marker has Id, Latitude, Longitude, Title, Color (hex) and a free Tag payload.
Tapping a marker raises AnnotationClicked (and suppresses MapClicked).
Event-driven
Map.Annotations.Add(new MapAnnotation
{
Latitude = 47.3769, Longitude = 8.5417,
Title = "Zürich HB", Color = "#E74C3C", Tag = stationModel,
});
Map.AnnotationClicked += (_, e) => ShowDetails((Station)e.Annotation.Tag!);
Custom icons — provide PNG/JPEG bytes (e.g. from a MAUI asset); size is in device-independent units and renders identically on both platforms. Markers sharing the same bytes share one map texture.
using var stream = await FileSystem.OpenAppPackageFileAsync("paw.png");
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer);
Map.Annotations.Add(new MapAnnotation
{
Latitude = 47.36, Longitude = 8.53,
IconData = buffer.ToArray(),
IconWidth = 44, // dp; height derived from aspect ratio
IconAnchor = MapIconAnchor.Center, // or Bottom (pin-style, default)
});
MVVM
<map:MapView Annotations="{Binding Pins}"
AnnotationClickedCommand="{Binding PinTappedCommand}" />
public ObservableRangeCollection<MapAnnotation> Pins { get; } = [];
public void LoadStations(IEnumerable<Station> stations) =>
Pins.ReplaceRange(stations.Select(s => new MapAnnotation
{
Latitude = s.Lat, Longitude = s.Lng, Title = s.Name, Tag = s,
}));
[RelayCommand]
private void PinTapped(AnnotationClickedEventArgs e) =>
SelectedStation = (Station)e.Annotation.Tag!;
Draggable markers
Set IsDraggable on a marker and the user can move it: on Android by dragging the icon
directly, on iOS via long-press + drag. The final position is written back to the
annotation's Latitude/Longitude automatically, and AnnotationDragged reports it.
Drags never surface as map clicks or long-presses.
Event-driven
Map.Annotations.Add(new MapAnnotation
{
Latitude = 47.3769, Longitude = 8.5417,
Title = "Route point 3", IsDraggable = true, Tag = routePoint,
});
Map.AnnotationDragged += (_, e) =>
UpdateRoutePoint((RoutePoint)e.Annotation.Tag!, e.Latitude, e.Longitude);
MVVM
<map:MapView Annotations="{Binding RoutePoints}"
AnnotationDraggedCommand="{Binding PointMovedCommand}" />
[RelayCommand]
private void PointMoved(AnnotationDraggedEventArgs e) =>
Route.MovePoint((RoutePoint)e.Annotation.Tag!, e.Latitude, e.Longitude);
Map clicks & long-presses
MapClicked / MapLongPressed deliver the geographic coordinate of the tap.
Click-consumed semantics: taps that hit a marker, polyline, polygon or cluster are consumed
by that element — MapClicked only fires for taps on empty map.
Event-driven
Map.MapClicked += (_, e) => Map.Annotations.Add(new MapAnnotation
{
Latitude = e.Latitude, Longitude = e.Longitude, Color = "#27AE60",
});
Map.MapLongPressed += (_, e) => Map.FlyTo(new MapCameraPosition(e.Latitude, e.Longitude, 14), 1500);
MVVM
<map:MapView MapClickedCommand="{Binding MapClickedCommand}"
MapLongPressedCommand="{Binding MapLongPressedCommand}" />
[RelayCommand]
private void MapClicked(MapClickedEventArgs e) =>
Waypoints.Add(new MapAnnotation { Latitude = e.Latitude, Longitude = e.Longitude });
Polylines
Polylines is a bindable ObservableRangeCollection<MapPolyline>; each polyline has Points
(at least two MapPositions), Color, Width, Opacity, Title and Tag. Tapping one
raises PolylineClicked.
Event-driven
Map.Polylines.Add(new MapPolyline
{
Points = [new(47.3769, 8.5417), new(47.3660, 8.5450), new(47.3550, 8.5530)],
Color = "#E67E22", Width = 5, Title = "Lakeside trail",
});
Map.PolylineClicked += (_, e) => Toast($"Route: {e.Polyline.Title}");
MVVM
<map:MapView Polylines="{Binding Routes}"
PolylineClickedCommand="{Binding RouteTappedCommand}" />
public ObservableRangeCollection<MapPolyline> Routes { get; } = [];
[RelayCommand]
private void RouteTapped(PolylineClickedEventArgs e) => SelectedRoute = e.Polyline;
Polygons
Polygons works the same way: Points (at least three, ring closes automatically),
FillColor, FillOpacity, StrokeColor, Title, Tag, plus PolygonClicked.
Event-driven
Map.Polygons.Add(new MapPolygon
{
Points = [new(47.366, 8.541), new(47.366, 8.556), new(47.333, 8.562), new(47.331, 8.545)],
FillColor = "#9B59B6", FillOpacity = 0.35, StrokeColor = "#6C3483", Title = "Zone A",
});
Map.PolygonClicked += (_, e) => Toast($"Zone: {e.Polygon.Title}");
MVVM
<map:MapView Polygons="{Binding Zones}"
PolygonClickedCommand="{Binding ZoneTappedCommand}" />
[RelayCommand]
private void ZoneTapped(PolygonClickedEventArgs e) => SelectedZone = e.Polygon;
GeoJSON sources & style layers
For data-driven rendering beyond individual shapes: add a GeoJSON source once, style it with
fill/line/circle layers. Calling AddGeoJsonSource again with the same id replaces only the
data — perfect for live updates. Sources and layers survive style switches automatically
(the native facade re-applies them after every style load).
These APIs are imperative (AddGeoJsonSource, RemoveGeoJsonSource, AddLayer,
RemoveLayer) — call them from code-behind, or hand the MapView to your ViewModel behind a
slim interface.
Map.AddGeoJsonSource("live-vehicles", featureCollectionJson);
Map.AddLayer(new MapLayer
{
Id = "vehicle-dots", SourceId = "live-vehicles",
Type = MapLayerType.Circle, Color = "#C0392B", CircleRadius = 8,
});
Map.AddLayer(new MapLayer
{
Id = "route-line", SourceId = "live-vehicles",
Type = MapLayerType.Line, Color = "#16A085", LineWidth = 4,
});
// live update — only the data is replaced:
timer.Tick += (_, _) => Map.AddGeoJsonSource("live-vehicles", FetchLatestGeoJson());
MapLayer options: Type (Fill | Line | Circle | Symbol), Color, Opacity,
LineWidth, CircleRadius, BelowLayerId (insert position in the style).
Symbol layers render data-driven labels (and optionally icons) from feature properties:
Map.AddLayer(new MapLayer
{
Id = "station-labels", SourceId = "stations",
Type = MapLayerType.Symbol,
TextField = "name", // label = feature.properties.name
TextSize = 13, TextColor = "#1F2937",
TextHaloColor = "#FFFFFF", TextHaloWidth = 1.6,
IconData = iconBytes, // optional per-feature icon (label moves below it)
IconWidth = 28,
AllowOverlap = true,
});
Clustering
One call creates a clustered source plus three managed layers (cluster circles that grow with the point count, the count label, unclustered points). Tapping a cluster automatically zooms to its expansion level — handled inside the native facade.
Map.AddClusteredSource(new MapClusterSource
{
SourceId = "stations",
GeoJson = stationsFeatureCollection, // point features
ClusterRadius = 50, ClusterMaxZoom = 14,
ClusterColor = "#2563EB", ClusterTextColor = "#FFFFFF",
PointColor = "#DC2626", PointRadius = 6,
});
Map.AddGeoJsonSource("stations", updatedGeoJson); // live data update, cluster config stays
Map.RemoveClusteredSource("stations"); // removes source + all managed layers
View annotations (MAUI views on the map)
Anchor any MAUI view to a coordinate — it is rendered natively, moves with the map, and its gesture recognizers keep working. The native view-annotation system requires a fixed size.
Event-driven
var bubble = new Border
{
Background = Color.FromArgb("#1F2937"),
StrokeShape = new RoundRectangle { CornerRadius = 12 },
Content = new Label { Text = "🚉 Zürich HB", TextColor = Colors.White },
};
var tap = new TapGestureRecognizer();
tap.Tapped += (_, _) => ShowStationSheet();
bubble.GestureRecognizers.Add(tap);
Map.ViewAnnotations.Add(new MapViewAnnotation
{
Latitude = 47.3779, Longitude = 8.5403,
Content = bubble, Width = 150, Height = 44,
});
MVVM
<map:MapView ViewAnnotations="{Binding Bubbles}" />
public ObservableRangeCollection<MapViewAnnotation> Bubbles { get; } = [];
// Content can be built from a DataTemplate-style factory in the VM layer:
Bubbles.Add(new MapViewAnnotation
{
Latitude = poi.Lat, Longitude = poi.Lng,
Content = _bubbleFactory.Create(poi), // returns a MAUI View with bound commands
Width = 150, Height = 44,
});
Offline regions
DownloadOfflineRegion downloads the style pack and the map tiles for a bounding box in
one call. Progress and completion are reported per region id; downloaded regions render
automatically without network (the map reads the shared tile store).
Event-driven
Map.OfflineRegionProgress += (_, e) => DownloadBar.Progress = e.Progress; // 0.0 … 1.0
Map.OfflineRegionCompleted += (_, e) =>
Status.Text = e.Success ? "Available offline ✓" : $"Failed: {e.ErrorMessage}";
Map.DownloadOfflineRegion(new MapOfflineRegion
{
Id = "zurich",
StyleUri = MapStyles.Streets,
MinLatitude = 47.32, MinLongitude = 8.46,
MaxLatitude = 47.43, MaxLongitude = 8.62,
MinZoom = 6, MaxZoom = 14, // higher MaxZoom = more detail, much more data
});
Map.RemoveOfflineRegion("zurich"); // cancels a running download / deletes tiles
MVVM
<map:MapView OfflineRegionProgressCommand="{Binding DownloadProgressCommand}"
OfflineRegionCompletedCommand="{Binding DownloadCompletedCommand}" />
[ObservableProperty]
private double downloadProgress;
[RelayCommand]
private void DownloadProgress(OfflineRegionProgressEventArgs e) => DownloadProgress = e.Progress;
[RelayCommand]
private void DownloadCompleted(OfflineRegionCompletedEventArgs e) =>
IsOfflineReady = e.Success;
Follow-puck mode
When enabled, the camera continuously follows the user-location puck (shown implicitly) using
Mapbox's native viewport engine. Panning the map ends the mode natively — the FollowPuck
property is synced back and FollowPuckChanged fires, so a "follow" button always reflects
the real state. Your app must request location permission itself.
Event-driven
var status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
if (status == PermissionStatus.Granted)
Map.FollowPuck = true; // camera flies to the puck and stays on it
Map.FollowPuckChanged += (_, e) =>
FollowButton.Text = e.IsActive ? "Following ✓" : "Follow me";
MVVM
<map:MapView FollowPuck="{Binding IsFollowing, Mode=TwoWay}"
FollowPuckZoom="16"
FollowPuckTrackBearing="False"
FollowPuckChangedCommand="{Binding FollowChangedCommand}" />
[ObservableProperty]
private bool isFollowing;
[RelayCommand]
private void FollowChanged(FollowPuckChangedEventArgs e) => IsFollowing = e.IsActive;
FollowPuckZoom (default 16) sets the follow zoom; FollowPuckTrackBearing rotates the map
with the puck's heading instead of keeping north up.
User location, gestures & compass
<map:MapView ShowUserLocation="True"
ScrollEnabled="True" ZoomEnabled="True"
RotateEnabled="True" PitchEnabled="False" />
ShowUserLocationshows the native location puck (your app must request location permission itself).- The four gesture switches toggle pan / zoom (pinch, double-tap, quick-zoom) / rotate / pitch individually — all bindable.
- A compass ornament appears automatically (top-right) whenever the map is rotated away from north and hides again when facing north; tapping it resets the bearing to 0. It indicates the map's north, not the device's magnetometer heading.
3D terrain
Renders real elevation (Mapbox DEM) with a sky atmosphere. The terrain survives style switches automatically. Tilt the camera (pitch) to actually see the relief.
XAML / MVVM:
<map:MapView TerrainEnabled="{Binding Is3D}"
TerrainExaggeration="1.5" />
Code / event-driven:
map.TerrainEnabled = true; // adds DEM source + sky atmosphere
map.TerrainExaggeration = 1.5; // 1.0 = realistic
map.FlyTo(new MapCameraPosition(46.60, 7.91, 12, bearing: 0, pitch: 65), 2500);
Terrain tiles are streamed from
mapbox.mapbox-terrain-dem-v1and are billed like map tiles. Offline regions do not include DEM tiles.
Ornaments
All four map ornaments are individually switchable (default: all visible).
XAML / MVVM — plain bindable properties:
<map:MapView ShowCompass="{Binding CompassVisible}"
ShowScaleBar="False"
ShowMapboxLogo="True"
ShowAttribution="True" />
Code / event-driven:
map.ShowScaleBar = false; // hide the scale bar
map.ShowCompass = true; // compass keeps its adaptive show-on-rotation behavior
⚠️
ShowMapboxLogoandShowAttributioncan hide the Mapbox logo and attribution button, but doing so may require a Mapbox plan that permits it — compliance is the responsibility of the consuming app.
Visible bounds
GetVisibleBounds() returns the currently visible viewport as a MapBounds — the exact
counterpart to FitBounds, useful e.g. to download the visible region for offline use:
var bounds = map.GetVisibleBounds(); // null while the handler is not attached yet
if (bounds is not null)
{
map.DownloadOfflineRegion(new MapOfflineRegion
{
Id = "visible-region",
StyleUri = map.StyleUri,
MinLatitude = bounds.MinLatitude,
MinLongitude = bounds.MinLongitude,
MaxLatitude = bounds.MaxLatitude,
MaxLongitude = bounds.MaxLongitude,
MinZoom = 6,
MaxZoom = 14,
});
}
In an MVVM setup, expose it to the view model via a delegate (the view model must not reference the view):
// page
viewModel.VisibleBoundsProvider = () => Map.GetVisibleBounds();
// view model
public Func<MapBounds?>? VisibleBoundsProvider { get; set; }
Deferred imperative calls
Imperative methods (AddGeoJsonSource, AddLayer, AddClusteredSource, FlyTo,
FitBounds, DownloadOfflineRegion, …) can be called before the control's handler is
attached — for example from a page constructor or OnAppearing. The calls are queued
and replayed in order as soon as the handler connects; runtime sources and layers
additionally survive every style switch. No guards or MapReady gymnastics needed:
public MyPage()
{
InitializeComponent();
Map.AddGeoJsonSource("points", "{…}"); // safe — queued until the handler attaches
Map.AddLayer(new MapLayer { Id = "points-circles", SourceId = "points", Type = MapLayerType.Circle });
}
GetVisibleBounds() is the one exception: it needs a live map and returns null before
the handler is attached.
Events ↔ Commands
Every event has a bindable command counterpart; command parameters are the event args.
| Event | Command | Args |
|---|---|---|
MapReady |
MapReadyCommand |
– |
StyleLoaded |
StyleLoadedCommand |
– |
MapClicked |
MapClickedCommand |
MapClickedEventArgs |
MapLongPressed |
MapLongPressedCommand |
MapClickedEventArgs |
AnnotationClicked |
AnnotationClickedCommand |
AnnotationClickedEventArgs |
PolylineClicked |
PolylineClickedCommand |
PolylineClickedEventArgs |
PolygonClicked |
PolygonClickedCommand |
PolygonClickedEventArgs |
CameraChanged |
CameraChangedCommand |
CameraChangedEventArgs |
OfflineRegionProgress |
OfflineRegionProgressCommand |
OfflineRegionProgressEventArgs |
OfflineRegionCompleted |
OfflineRegionCompletedCommand |
OfflineRegionCompletedEventArgs |
Sample app
samples/Indiko.Maui.Controls.MapBox.Sample demonstrates every feature. Copy
MapboxToken.cs.template to MapboxToken.cs (git-ignored) and insert your pk.… token.
Building from source
# 1. Native facades — run once after cloning (fetches Mapbox binaries) and
# whenever native/ changes; only the small facade artifacts are committed
./scripts/build-android-native.sh # Kotlin facade AAR + com.mapbox.* deps
./scripts/build-ios-native.sh # Swift facade XCFramework + Mapbox dynamic frameworks
# 2. .NET library
dotnet build src/Indiko.Maui.Controls.MapBox.sln -c Release
# 3. Sample app
dotnet build samples/Indiko.Maui.Controls.MapBox.Sample.sln -c Release
Prerequisites: .NET 10 with maui workload, Xcode, XcodeGen (brew install xcodegen),
JDK 21 (for Gradle), Android SDK.
The Mapbox artifact downloads (Maven / SPM binaries) are currently public. Should Mapbox
re-enable authentication, provide a secret download token (sk.…, scope DOWNLOADS:READ)
via MAPBOX_DOWNLOADS_TOKEN in ~/.gradle/gradle.properties and ~/.netrc.
Pinned Mapbox versions
| Platform | Version | Pinned in |
|---|---|---|
| Android | 11.30.1 | native/android/indikomapboxkit/build.gradle.kts |
| iOS | 11.26.0 | native/ios/IndikoMapboxKit/project.yml |
Releases are automated via Semantic Release — conventional commits on main produce the
version tag, CHANGELOG and NuGet package.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0-android36.0 is compatible. net10.0-ios26.0 is compatible. |
-
net10.0-android36.0
- GoogleGson (>= 2.13.1.1)
- Microsoft.Maui.Controls (>= 10.0.20)
- Square.OkHttp3 (>= 4.12.0.4)
- Xamarin.AndroidX.AppCompat (>= 1.7.1.1)
- Xamarin.Kotlin.StdLib (>= 2.2.0.1)
- Xamarin.KotlinX.Coroutines.Android (>= 1.10.2.1)
-
net10.0-ios26.0
- Microsoft.Maui.Controls (>= 10.0.20)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.