Migration Guide
Upgrading to 0.28.0
0.28.0 makes Android, iOS and web behave the same, where possible. Most apps compile unchanged: raise the minimum versions, then go through the sections below in order, skipping those that do not apply to your app.
Update your pubspec.yaml, then run flutter pub upgrade maplibre_gl:
Using an AI coding agent?
Give it this page and ask it to apply the changes to your app and list what it could not check.
Minimum versions
- Flutter 3.44 and Dart 3.12. Apps on an older Flutter stay on 0.27.x.
- iOS 15.5: raise
IPHONEOS_DEPLOYMENT_TARGETin Xcode and, with CocoaPods,platform :ios, '15.5'inios/Podfile. - Android API 24: raise a lower
minSdkinandroid/app/build.gradle.kts, orbuild.gradlein older apps.
Flutter 3.44 builds the iOS plugin with Swift Package Manager, and MapLibre iOS no longer publishes to CocoaPods. If your app turned Swift Package Manager off, turn it back on. Add-to-app modules keep using CocoaPods.
Error codes
Every platform now reports the same error codes. Two changes matter when upgrading.
Calls that now throw
Calls that did nothing, or crashed, on some platforms now throw. Add a catch where your app can hit them, or the error goes unhandled. The ones most apps meet:
STYLE_NOT_READY: a source, layer or image call made before the style loaded. Move it toonStyleLoadedCallback.LAYER_NOT_FOUND_ERROR,SOURCE_NOT_FOUND,LAYER_ALREADY_EXISTS,SOURCE_ALREADY_EXISTS: an id that is missing, or already in use.INVALID_GEOJSON,INVALID_EXPRESSION,INVALID_ARGUMENT: data, a filter or a property value MapLibre cannot use.- Android, iOS: a call on a disposed map throws
MAP_DISPOSEDinstead of aMissingPluginException. - iOS, Web:
setOfflinethrows anUnsupportedError: call it on Android only. - Web: the offline calls and the HTTP header calls throw an
UnsupportedError: guard them withkIsWeb.
The full list says when each code fires.
Renamed codes
If your app compares PlatformException.code, update these:
| Before | Platform | 0.28.0 |
|---|---|---|
layerNotFound |
iOS | LAYER_NOT_FOUND_ERROR |
invalidExpression |
iOS | INVALID_EXPRESSION |
invalidLayerType |
iOS | INVALID_LAYER_TYPE |
sourceAlreadyExists, duplicateSource |
iOS | SOURCE_ALREADY_EXISTS |
layerAlreadyExists, duplicateLayer |
iOS | LAYER_ALREADY_EXISTS |
styleNotFound |
iOS | STYLE_NOT_READY |
sourceNotFound, noSuchSource |
iOS | SOURCE_NOT_FOUND |
invalidSourceType |
iOS | UNSUPPORTED_SOURCE_TYPE, or INVALID_ARGUMENT for bad source properties |
parseError |
iOS | INVALID_GEOJSON |
| Ambient cache errors | Android | AMBIENT_CACHE_ERROR |
| Map language errors | Android | LOCALIZATION_ERROR |
SetHttpHeadersError, SetMaxConcurrentRequestsError |
Android | HTTP_CLIENT_ERROR |
Offline per-call codes, such as DeleteRegionError |
Android, iOS | REGION_NOT_FOUND for an unknown id, INVALID_ARGUMENT for a bad value, OFFLINE_DATABASE_ERROR otherwise |
The other offline codes only change case, such as RegionDeleted to REGION_DELETED. Errors lists every offline code and when it fires.
Android: screen points in logical pixels
On Android screen points are in logical pixels, as on iOS and web: toScreenLocation, toScreenLocationBatch, toLatLng, the queries, and the point of onMapClick, onMapLongClick, onFeatureTapped and onFeatureDrag. Remove any Android-only scaling:
final point = await controller.toScreenLocation(latLng);
// Before
final ratio = Platform.isAndroid ? MediaQuery.of(context).devicePixelRatio : 1.0;
final left = point.x / ratio;
// After (every platform)
final left = point.x;
Code that only passes a point from one call to another, such as the onMapClick point to queryRenderedFeatures, works unchanged.
Android: numeric feature ids
On Android numeric GeoJSON feature ids stay numbers, as on iOS and web. Read feature['id'] as a num and compare it with 2, not "2":
// Source data: {'type': 'Feature', 'id': 2, ...}
final features = await controller.queryRenderedFeatures(point, ['pois'], null);
// Before (Android)
final isSelected = features.first['id'] == '2';
await controller.setLayerFilter('pois', ['==', ['id'], '2']);
// After (every platform)
final isSelected = features.first['id'] == 2;
await controller.setLayerFilter('pois', ['==', ['id'], 2]);
GeoJSON loaded from a URL or declared in the style is the exception: on Android and web only its integer ids are sure to match.
Return types
setLayerFilter returns Future<void> and throws on failure instead of returning false:
// Before
final ok = await controller.setLayerFilter('pois', filterJson);
if (!ok) showError();
// After
try {
await controller.setLayerFilter('pois', filter);
} on PlatformException catch (e) {
showError(e.code); // LAYER_NOT_FOUND_ERROR, INVALID_LAYER_TYPE or INVALID_EXPRESSION
}
setOffline, setOfflineTileCountLimit and deleteOfflineRegion return Future<void>: drop any use of their value, which was always null.
Camera
- Awaited
animateCameraandeaseCameracomplete when the camera arrives, withfalsewhen interrupted. Code after them now runs after the animation. - Android, iOS: they also complete with
falsewhen the map is disposed mid-animation: checkmountedbefore callingsetState. - Android: a positive
xinCameraUpdate.scrollBymoves the view east, as on iOS and web: remove any Android-only sign flip. - Android:
setCameraBoundspaddingis in logical pixels, as on iOS and web: remove any scaling by the pixel ratio. - iOS, Web:
cameraTargetBoundskeeps the camera target inside the bounds, as on Android, so the map no longer zooms in to fit them. - Web:
onCameraMoveandcontroller.cameraPositionneedtrackCameraPosition: true, as on Android and iOS: set it if you read them.
Feature taps and drags
Taps and drags on annotations and draggable features work the same on every platform. Check flows that depend on them:
- A drag starts only after the finger or mouse moves a little, so a tap on a draggable feature stays a tap.
- A second finger ends the drag with an
endevent and lets the map pinch. - Android: a drag no longer fires
onMapLongClick. - Android, iOS: taps and drags pick the nearest feature within 8 logical pixels, so a tap next to a feature can now select it.
How the map looks
- Android, iOS: fill annotations without a
fillOutlineColorget an outline in their fill color: setfillOutlineColorto keep the old look. - Android, iOS:
line-patternimages are drawn about 1.7 times smaller, the size MapLibre GL JS uses. - iOS: an
iconImagenaming an asset with2.0xor3.0xvariants is sized likeImage.asset: remove any iOS-onlyiconSizethat made up for it. - iOS:
#rgbaand#rrggbbaacolors read the alpha last, as in CSS and on Android and web. Move the alpha of an#aarrggbbcolor to the end:
// Before (iOS): red at 50% opacity
fillColor: '#80ff0000',
// After (every platform)
fillColor: '#ff000080',
Offline and HTTP headers
- Android: the
setCustomHeadersfilter matches anywhere in the URL, as on iOS: anchor it with^and$to match the whole URL. - Android, iOS: offline region metadata is stored as
jsonEncodewrites it, so aUint8Listreads back as a list of numbers.
Tests and platform packages
The plugin talks to Android and iOS through typed Pigeon messages instead of method channels. App code is not affected, but tests and platform packages are:
- Mocks of the plugin's method channels no longer intercept anything: install a fake
MapLibrePlatformandMapLibreGlobalPlatforminstead. MapLibreMethodChannelandMapLibreGlobalMethodChannelare removed: extendMapLibrePlatformorMapLibreGlobalPlatform.onCameraMoveStartedPlatformno longer takesnull: pass what started the move.
// Before
class FakeMapPlatform extends MapLibreMethodChannel { ... }
tearDown(() => MapLibreGlobalPlatform.instance = MapLibreGlobalMethodChannel());
onCameraMoveStartedPlatform(null);
// After: MapLibreGlobalPlatform comes from maplibre_gl_platform_interface
import 'package:maplibre_gl_platform_interface/maplibre_gl_platform_interface.dart';
class FakeMapPlatform extends MapLibrePlatform {
// Override what the test calls; any other call throws.
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
MapLibrePlatform.createInstance = FakeMapPlatform.new;
final previous = MapLibreGlobalPlatform.instance;
addTearDown(() => MapLibreGlobalPlatform.instance = previous);
onCameraMoveStartedPlatform(CameraMoveReason.programmatic); // or .gesture
Android: platform view mode
useHybridComposition and translucentTextureSurface still work, but are deprecated in favor of androidView:
// Before
MapLibreMap.useHybridComposition = true;
MapLibreMap(translucentTextureSurface: true);
// After
MapLibreMap(androidView: const AndroidMapView(surface: AndroidMapSurface.textureView));
MapLibreMap(androidView: const AndroidMapView(surface: AndroidMapSurface.translucentTextureView));
Despite its name, useHybridComposition never selected Hybrid Composition: that is composition: AndroidComposition.hybridComposition. See Platform view mode (Android).
Web: browsers without WebGL2
On a browser without WebGL2 the map fails to create instead of showing up blank: the controller future completes with an error and onMapCreated is not called. To support those browsers, load a MapLibre GL JS 5 build: see Web: the engine moves to MapLibre GL JS 6.
Upgrading to 0.27.1
A patch release: bug fixes only, no API changes, nothing to do beyond taking the new version.
Android apps that worked around the 0.27.0 build failure, by setting android.builtInKotlin=true or applying the Kotlin plugin to :maplibre_gl from their root build.gradle, can drop that again (#1008).
Upgrading to 0.27.0
No breaking API changes, so nothing stops compiling. Two platforms do need action: Android apps need one code change, described under Android: style content, and web apps should delete two tags from web/index.html, described under Web: remove the script and stylesheet tags.
Update your pubspec.yaml:
Then run flutter pub upgrade maplibre_gl. See the CHANGELOG for the full list of changes. Minimum SDK versions are unchanged from 0.26.x; see Minimum versions.
Android: style content
A map on Android now survives its host activity being destroyed and recreated, whether by the "Don't keep activities" developer option, by a configuration change such as rotation, or under memory pressure. The MapView is rebuilt and the camera position is restored for you.
What is not restored automatically is style content: sources, layers, images and runtime style switches. Apply those inside onStyleLoadedCallback, which fires again after each recreation, so the content comes back with the new map:
MapLibreMapController? _controller;
MapLibreMap(
onMapCreated: (controller) => _controller = controller,
onStyleLoadedCallback: () async {
// Runs on the first style load and again after every activity recreation.
await _controller?.addGeoJsonSource('route', routeGeoJson);
await _controller?.addLineLayer(
'route',
'route-line',
const LineLayerProperties(lineColor: '#ff0000'),
);
},
)
If your app adds that content in onMapCreated or initState instead, move it into onStyleLoadedCallback. Without the move, the map comes back after a recreation with the base style only, and your own layers missing.
Web: remove the script and stylesheet tags
The plugin now loads MapLibre GL JS itself, pinned to the exact build it is tested against, before the first map is built. The two tags every web app had to carry are no longer needed: delete the <script> tag that loads maplibre-gl.js and the <link> tag that loads maplibre-gl.css from your web/index.html.
If you leave them in, nothing breaks today: an existing maplibregl global is reused as it is. But your pinned copy then silently overrides the version the plugin is tested against, on this upgrade and every future one.
Two setups need more than deleting the tags:
- A Content-Security-Policy that blocks the CDN, or a self-hosted copy of the library: point the plugin at your copy with
MapLibreMap.webLibrarySource. See Self-hosting MapLibre GL JS. - Your own JS interop into MapLibre GL JS, for example registering a protocol with
addProtocol: themaplibreglglobal no longer exists at page parse time, so awaitMapLibreMap.ensureWebLibraryLoaded()first. See Calling MapLibre GL JS yourself.
Web: the engine moves to MapLibre GL JS 6
The web build now runs on MapLibre GL JS 6, which ships as an ES module. The plugin imports it for you, so most apps need nothing. Three setups do:
- You self-host the library. Point
MapLibreJsSource.urlsat the.mjsbuild rather than.js, and serve the wholedistdirectory: the library resolves its worker relative to its own URL. Pointing at the old.jsbuild still works, because the plugin keeps the global that build defines, but you stay on version 5 while the plugin's interop is written against 6. - The page loads the library itself (
MapLibreJsSource.preloaded). An ES module defines no global, so the page has to publishglobalThis.maplibreglexplicitly. A page that still loads a version 5 bundle keeps working as before. - Your users are not all on WebGL2. Version 6 requires it and dropped the WebGL1 fallback, so a browser without WebGL2 now shows no map where version 5 showed a slow one. Where WebGL2 is missing altogether, as on Safari and iOS before 15, Flutter's own renderer still runs on WebGL1, so only the map goes missing and pointing
MapLibreJsSource.urlsat a version 5 build brings it back; the plugin logs as much. Where WebGL2 exists but no context can be created, a blocklisted GPU driver for example, Flutter does not fall back either and the whole app stays blank, which no library version changes. See Requirements.
Content-Security-Policy rules do not change: version 5 also built its Web Worker from a blob: URL, so worker-src 'self' blob: was already required and still is. What changes goes in your favour: version 6 only needs blob: when the library
is loaded cross-origin, so a self-hosted same-origin copy can now drop it. See Content-Security-Policy.
queryRenderedFeatures can also return a different set of features. Version 6 slices vector tiles instead of overscaling them, which upstream turned on by default because it fixes label placement, and that changes both rendering and query results. The plugin follows that default; there is no per-app switch for it. If it costs you something concrete, please open an issue.
Behaviour changes
None of these need a code change, but they are the places where code that worked before starts behaving differently.
- Web:
onMapIdlenow fires. If you worked around it never running on web, that workaround can go. - Web:
queryCameraPosition(),updateContentInsets()and the newsetPadding()no longer throwUnimplementedError, so any guard you put around them for web is no longer needed. - Android, iOS: symbol annotations now render their text in
Noto Sans Regularinstead of the oldOpen Sans Regular,Arial Unicode MS Regulardefault, which most glyph servers do not host. If your symbols were invisible, they appear now; if they were visible, the typeface changes. The font belongs to the annotation layer, so a different one means a symbol style layer withtextFontset. See Markers. - Android: feature state works instead of throwing, so a
kIsWebguard around those calls can go.promoteIdis still ignored outside web, so features must carry a top-levelidin the GeoJSON for feature state to key off. - Android, iOS: the offline download errors
mapboxTileCountLimitExceededandmapboxInvalidRegionDefinitionare nowtileCountLimitExceededandinvalidRegionDefinition. Code that matches onPlatformException.codefromdownloadOfflineRegionneeds the new strings; nothing else changes. - Android, iOS: downloading an area that is already downloaded replaces the existing region rather than adding a duplicate. On iOS the replacement keeps the region id the app already knows; on Android the SDK assigns the id when it creates a region, so the replacement gets a new one. Read the id back from the returned
OfflineRegionafter every download.
Android apps on AGP 9
Nothing to do, on AGP 8 or AGP 9. The plugin applies the Kotlin Gradle Plugin only where nothing else compiles its Kotlin: on AGP 9 with built-in Kotlin enabled, AGP's own default, it applies nothing and AGP compiles it; where built-in Kotlin is disabled, which is what Flutter 3.44 and later write into android/gradle.properties as android.builtInKotlin=false, it applies KGP itself.
0.27.0 got that wrong and failed to configure with Could not find method kotlin() on AGP 9 whenever built-in Kotlin was disabled. It is fixed in 0.27.1. If you worked around it by setting android.builtInKotlin=true, or by applying kotlin-android to the module from your root android/build.gradle, remove the workaround: on a multi-plugin app the flag puts every other KGP-applying plugin into a conflicting configuration.
Upgrading from earlier 0.26 releases
No structural changes to the public API. Run flutter pub upgrade maplibre_gl and check the CHANGELOG for any deprecation notices.
Release history
What each release was about, most recent first. Full details live in the CHANGELOG, also rendered on pub.dev.
| Version | Highlights |
|---|---|
| 0.28.0 | Behavioral parity across Android, iOS and web: shared error codes, layer filters, camera updates, queries and feature drag. Newer MapLibre on every platform. Breaking: setLayerFilter returns Future<void> and iOS error codes are renamed; see Upgrading to 0.28.0. |
| 0.27.1 | Patch: fixes the Android build failure on Flutter 3.44 templates, an iOS crash on the custom-header path, and offline regions whose bounds sit on whole degrees. No API changes. |
| 0.27.0 | Platform gaps closed (feature state on Android, cluster inspection, offline export/import), faster start-up with preWarm(), a newer web renderer, documentation site. No breaking API changes; see Upgrading to 0.27.0. |
| 0.26.2 | Enforces the Flutter 3.29 minimum in the package constraints; fixes redundant map updates on rebuild and doubleClickZoomEnabled on Android and iOS. |
| 0.26.1 | Android stability fixes after 0.26.0, including crashes on older hardware and disposal races. |
| 0.26.0 | Milestone release: WASM-compatible web build, a new example app, many long-standing fixes. Breaking: initialCameraPosition and requestMyLocationLatLng() became nullable. |
| 0.25.0 | Logo visibility and position options, web parity work (getStyle(), getSourceIds()), clearer annotation-manager errors. |
| 0.24.1 | Fixes double-fired annotation taps and add-before-style-load failures. |
| 0.24.0 | Breaking: feature interaction callbacks stabilized with the feature id and a nullable Annotation, unblocking taps on style-layer features. |
| 0.23.0 | Breaking: Maplibre renamed to MapLibre with lowerCamelCase enums. Runtime style switching, hover interactions, heatmaps. |
| 0.22.0 | Breaking: MapLibre Native updates on Android and iOS, bringing PMTiles support. |
| 0.21.0 | clearAmbientCache(), LatLngBounds.contains(), Android location engine options. Breaking: onFeatureTap gained the layerId. |