flutter_map_vector_tiles 2.3.0
flutter_map_vector_tiles: ^2.3.0 copied to clipboard
Vector tiles for flutter_map: a clean, self-contained MapLibre-style vector tile layer with isolate-based decoding, raster tile caching and screen-space label collision.
πΊοΈ flutter_map_vector_tiles #
Vector tiles for flutter_map.
A clean, self-contained rewrite of the ideas behind
vector_map_tiles β
built for flutter_map β₯ 8 and modern Flutter (Impeller).
Render MapLibre / Mapbox GL styles (MapTiler, OpenFreeMap, OpenMapTiles, Stadia, Protomaps, β¦) straight from MVT vector tile sources β as a plain flutter_map layer. flutter_map keeps owning the camera, gestures and all your other layers; this package only draws the map.
β¨ Why this package? #
| π¦ One package | MVT decoding, style engine and renderer in a single dependency β no renderer/cache/executor satellites |
| π Smooth interaction | Geometry is rasterized once per tile into GPU-resident images (Picture.toImageSync); pan, zoom and rotate are just textured quads |
| π Crisp labels | Text & icons are drawn per-frame in screen space: upright under rotation, sharp at fractional zoom, with one global collision pass β no duplicated or clipped labels at tile seams |
| π«οΈ No white flashes | New tiles fade in while ancestor imagery is kept underneath; fast zoom-ins render instantly from already-decoded parent tiles |
| ποΈ Correct MapLibre zoom semantics | The default TileOffset.maplibre renders 512px-convention styles exactly as their authors designed them |
| π§΅ Isolate pipeline | Tiles are decoded & trimmed on a worker-isolate pool (a yielding event-loop queue on web), viewport-centre first; cancellation is a state, never an exception in your crash reporting |
| πΎ Deterministic caching | LRU memory caches with byte budgets + a size-capped disk cache with no index files to corrupt; every ui.Image is disposed on eviction |
| βοΈ Works offline | The style bundle and recently viewed tiles are cached on disk (native platforms): places you visited keep rendering with no network at all |
| π All six platforms | Android, iOS, macOS, Linux, Windows and web β see Web support for what differs in the browser |
| π‘οΈ Tolerant style reader | Unknown layer types and exotic expressions degrade per-layer with a warning β one weird layer never kills your whole map |
π Quick start #
1. Install #
dependencies:
flutter_map: ^8.2.0
flutter_map_vector_tiles: ^2.3.0
2. Load a style & drop in the layer #
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_vector_tiles/flutter_map_vector_tiles.dart' as vt;
import 'package:latlong2/latlong.dart';
// Load the style once β MapTiler shown, any MapLibre style URL works.
final style = await vt.StyleReader(
uri: 'https://api.maptiler.com/maps/streets-v2/style.json?key={key}',
apiKey: myMapTilerKey,
).read();
// Use it like any other flutter_map layer:
FlutterMap(
options: MapOptions(
initialCenter: style.center ?? const LatLng(48.137, 11.575),
initialZoom: style.zoom ?? 12,
maxZoom: 21,
),
children: [
vt.VectorTileLayer(
theme: style.theme,
tileProviders: style.providers,
rasterSources: style.rasterSources,
sprites: style.sprites,
),
// Show what the style's sources ask for β most providers require it.
SimpleAttributionWidget(
source: Text(style.attributions.map((a) => a.text).join(' Β· ')),
),
// ...your markers, polylines, etc.
],
);
3. Clean up #
@override
void dispose() {
style.dispose(); // releases HTTP clients & sprite images
super.dispose();
}
βΆοΈ A runnable app lives in example/:
cd example
flutter run --dart-define=MAPTILER_KEY=yourKey
π Tested style providers #
| Provider | Style URL shape | Notes |
|---|---|---|
| π’ MapTiler | https://api.maptiler.com/maps/<mapId>/style.json?key={key} |
works with custom map styles |
| π’ OpenFreeMap | https://tiles.openfreemap.org/styles/liberty |
free, no key needed |
| π’ Stadia Maps | https://tiles.stadiamaps.com/styles/osm_bright.json?api_key={key} |
|
| π’ ArcGIS / Esri | https://β¦/VectorTileServer/resources/styles/root.json |
relative ../../ sources, tile/{z}/{y}/{x} templates and sprites resolve correctly β verified against World_Basemap_v2 |
| π’ Self-hosted (TileServer GL, Martin, β¦) | any MapLibre style.json |
verified against the MapLibre demo tiles; relative tile templates supported |
| π’ Protomaps hosted API | https://api.protomaps.com/styles/v5/light/en.json?key={key} |
verified against the v5 light style; the style embeds the key in an absolute β¦/tiles/v4/{z}/{x}/{y}.mvt?key=β¦ template. On web, allow-list your origin per key in the Protomaps account portal β localhost is exempt |
| π’ PMTiles archives | pmtiles://https://β¦/planet.pmtiles source URLs in any style |
single-file archives served via HTTP range requests β verified against the Protomaps sample archives; gzip-internal archives only (brotli/zstd are rejected) |
The reader is tolerant either way: unsupported layer types, paint properties and expressions are skipped per-layer with a warning β one weird layer never kills the whole style.
βοΈ Configuration #
Everything has sensible defaults β override what you need:
vt.VectorTileLayer(
theme: style.theme,
tileProviders: style.providers,
rasterSources: style.rasterSources, // satellite/hybrid imagery
sprites: style.sprites,
tileOffset: vt.TileOffset.maplibre, // 512px style convention (default)
concurrency: 3, // decoding isolates
diskCacheMaximumSizeInBytes: 50 * 1024 * 1024,
diskCacheTtl: const Duration(days: 14),
memoryCacheMaxBytes: 24 * 1024 * 1024,
rasterCacheMaxBytes: 64 * 1024 * 1024,
tileFadeDuration: const Duration(milliseconds: 150),
labelFadeDuration: const Duration(milliseconds: 150),
showLabels: true,
logger: const vt.Logger.console(), // see style warnings in debug
)
| Parameter | Default | What it does |
|---|---|---|
tileOffset |
TileOffset.maplibre |
zoom relation between map and style β see below π |
concurrency |
3 |
worker isolates decoding tiles off the UI thread (ignored on web) |
diskCacheMaximumSizeInBytes |
50 MB | 0 disables disk caching (no effect on web) |
diskCacheTtl |
14 days | freshness window: younger tiles skip the network; older ones still paint instantly and are refreshed in the background β β οΈ respect your tile provider's terms |
cachePath |
app support dir | supply your own directory path to control/clear it (ignored on web) |
memoryCacheMaxBytes |
24 MB | decoded tile budget per source (the caches are shared process-wide; the most recently mounted layer's value wins) |
rasterCacheMaxBytes |
64 MB | finished-tile budget: zooming back to a recent level (or reopening the same style) paints instantly instead of re-rendering. GPU texture bytes β ~1 MB per tile at devicePixelRatio 2, ~2.25 MB at 3, so the default holds β2 phone-screen zoom levels at dpr 2 (β1 at dpr 3). 0 disables |
tileFadeDuration |
150 ms | Duration.zero disables fade-in |
labelFadeDuration |
150 ms | fade-in of newly appearing labels/icons (masks the pop at the zoom where symbols start); Duration.zero restores the instant pop |
showLabels |
true |
disables the whole symbol pass when false; toggling it re-lays-out the tiles already on screen |
StyleReader options worth knowing:
apiKeyβ substituted for{key}in the style URI and every URL the style references. Formapbox://URIs (style ids, sprite bases, tileset sources β expanded toapi.mapbox.comautomatically) it becomes the access token.headersβ extra HTTP headers sent with the style, TileJSON and sprite requests and forwarded to the created tile providers, for header-authenticated services (e.g.Authorization).
ποΈ Understanding TileOffset #
MapLibre renders 512px tiles, so at the same visual scale a MapLibre zoom is one lower than flutter_map's. Styles from MapTiler & friends are authored against that convention.
TileOffset.maplibre(default) β text sizes, road widths and layer zoom ranges match the style author's intent exactly.TileOffset.noneβ evaluates the style at flutter_map's zoom directly; everything appears one zoom earlier/larger (the legacyvector_map_tilesdefault, if you need visual parity with it).
βοΈ Offline behaviour #
Everything you looked at recently keeps working without network:
- Style bundle β
StyleReadercaches style.json, TileJSON and sprites on disk (stale-while-revalidate): the cached copy is served instantly β including fully offline β and refreshed in the background once older thanrefreshAfter(12 h default). Opt out withStyleReader(cache: false). - Tiles β served from the disk cache while fresh; once older than
diskCacheTtlthey still paint instantly and are revalidated in the background (stale-while-revalidate): changed tiles cross-fade to the new imagery, and when the network is unavailable the old tile simply stays. Stale tiles are only ever deleted by the size cap (oldest first), never by age alone. - Durable location β both caches default to the application support directory, which the OS doesn't purge (unlike the temp directory).
This is a visited-places cache, not region pre-download. For
guaranteed offline regions, bundle tiles and serve them through the
VectorTileProvider interface (e.g. MBTiles/PMTiles) alongside an
asset:// style.
Disk caching β and with it the offline behaviour above β is native-only; see Web support for what applies in the browser.
π Web support #
The layer runs on Flutter web with the CanvasKit/Skwasm renderer β the
default since Flutter 3.29. (Do not force the removed HTML renderer on
Flutter 3.27/3.28: it lacks Picture.toImageSync.) What differs from
native:
- No persistent cache β
cachePath,diskCacheTtl,diskCacheMaximumSizeInBytesandStyleReader(cache: β¦)are no-ops. Tiles and the style bundle rely on the in-memory caches plus the browser's own HTTP cache instead. - Decoding runs on the event loop β a yielding queue replaces the
worker-isolate pool (
concurrencyis ignored). - CORS β the browser fetches style.json, TileJSON, sprites and tiles
directly, so every host involved must send
Access-Control-Allow-Origin. MapTiler, OpenFreeMap and Stadia do; self-hosted tile servers need it configured. PMTiles archive hosts additionally need range requests to pass CORS (RangeinAccess-Control-Allow-Headerswhen preflighted). - PMTiles gunzip uses the browser's native
DecompressionStream(available in every browser that runs Flutter web).
π Custom tile sources #
No style URL? Any {z}/{x}/{y} MVT endpoint works β build the theme
yourself and wire providers manually:
vt.VectorTileLayer(
theme: vt.ThemeReader(logger: const vt.Logger.console()).read(myStyleJson),
tileProviders: vt.TileProviders({
'openmaptiles': vt.NetworkVectorTileProvider(
urlTemplate: 'https://tiles.example.com/{z}/{x}/{y}.pbf?key=$key',
maximumZoom: 14, // the source's max β higher zooms overzoom this data
),
}),
)
PMTiles single-file archives work out of the box: styles with
pmtiles://https://β¦/planet.pmtiles source URLs just load, or open an
archive directly:
final provider = await vt.PmTilesVectorTileProvider.open(
'https://tiles.example.com/planet.pmtiles',
);
// β vt.TileProviders({'mySource': provider})
There's also MemoryVectorTileProvider (tests, bundled offline regions)
and a small VectorTileProvider interface for anything else
(MBTiles, β¦).
π¨ Style support #
Layer types: background, fill (incl. fill-pattern), line
(incl. line-pattern, dashes, casing), symbol (incl. curved line
text, text-variable-anchor / text-radial-offset), circle, and
raster β raster sources inside vector styles (satellite/hybrid
imagery) draw at their layer position with raster-opacity,
brightness/contrast/saturation/hue-rotate matching MapLibre's shader
math (fill-extrusion renders as flat fill; hillshade, heatmap and
sky are skipped with a log line).
Icons: SDF sprite sheets ("sdf": true) are thresholded and tinted
per icon-color, icon-halo-color and icon-halo-width β dark
MapLibre styles ship their icons this way. Ordinary sprites are drawn
with the colours baked into the sheet.
Road labels curve glyph-by-glyph along their line with MapLibre
semantics: text-max-angle rejects labels on sharp bends,
text-keep-upright flips reading direction, and
text-rotation-alignment: viewport keeps shield text horizontal.
Nearly straight windows are drawn as a single rotated string for speed;
scripts with contextual shaping (Arabic, Indic, β¦) fall back to straight
placement so glyphs are never mis-joined.
Expressions: the practical MapLibre set β get/has, comparisons,
all/any/case/match/coalesce, step/interpolate (linear,
exponential, cubic-bezier), math, string & color operators, let/var,
legacy filters, legacy {stops} functions and {token} templates.
ποΈ Architecture #
style.json ββΊ StyleReader ββΊ compiled Theme (expressions β closures)
camera ββΊ visible display tiles ββΊ data tiles (shared, LRU-cached)
bytes ββ disk cache ββ network ββΊ isolate: decode + trim
PreparedTile ββΊ rasterize once ββΊ GPU image ββΊ textured quad per frame
symbols ββββββΊ per-frame screen-space label pass (global collision)
finished tiles (image + symbols) ββΊ shared LRU ββΊ instant re-crossings
Profiling: the render pipeline emits DevTools timeline events
(VT render pump, VT rasterize, VT symbols, VT labels).
On web the disk cache tier is absent and decoding runs on a yielding event-loop queue instead of isolates; everything else is identical.
The full rendering model and the reasoning behind each departure from
vector_map_tiles is
documented in doc/ARCHITECTURE.md. π
π vector_map_tiles #
vector_map_tiles (stable) |
this package | |
|---|---|---|
| Packages | 3 (vector_map_tiles, vector_tile_renderer, executor_lib) + stash caching |
1 |
| Labels | baked into tile rasters / per-tile collision | screen-space pass, global collision, upright text |
| Zoom flicker | white flash on fast zoom (#147) | ancestor retention + provisional rendering |
| Cancellation | CancellationException reaches crash reporting (#205) |
a state, never an exception |
| Style zoom | evaluated at flutter_map zoom (1 off vs. MapLibre) | TileOffset.maplibre default |
| Rasters | async image encode | Picture.toImageSync (stays on GPU) |
π Troubleshooting #
- Blank map, no errors β pass
logger: const vt.Logger.console()to bothStyleReaderandVectorTileLayer; most often the style's source ids don't match yourTileProviderskeys, or your API key is invalid (HTTP 403s are logged, keys redacted). - Labels/roads look bigger than in MapLibre β you're probably using
TileOffset.nonewith a 512px-convention style; use the default. - Stale data after changing styles β the disk cache keys by URL; a
changed
{key}or map id is a different URL, so usually nothing to do. SupplycachePathif you want to wipe it yourself. - Blank map on web β open the browser console; missing
Access-Control-Allow-Originheaders on the style or tile host block every request (see Web support).
π€ Contributing #
Issues and PRs are welcome! Please run
dart analyze && flutter test before submitting β the suite covers the
MVT decoder, expression engine, caches, grid math and tile store. Run
flutter test --platform chrome too when touching anything
platform-sensitive (requires Chrome).
π License #
BSD 3-Clause Β© 2026 Jonas Grunau