Change Building Color Based on Zoom Level in Flutter
This tutorial shows how to dynamically change the color of 3D buildings as the user zooms in and out — useful for data visualization, theming, or emphasizing detail at different scales.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
There is no mapController.addFillExtrusionLayer(...) or mapController.setPaintProperty(...) on the real SDK. Layers are added through StyleController.addLayer(FillExtrusionStyleLayer(...)), and there is no imperative "update a paint property" call at all — StyleController only has addLayer/removeLayer. The good news is that MapLibre's style spec supports zoom-driven paint values natively via an interpolate expression on fill-extrusion-color, so zoom-based coloring needs no per-frame Dart code once the layer is declared — the renderer re-evaluates the expression as the camera zoom changes.
Zoom-Dependent Building Colors
Change 3D building colors as the user zooms in, using a single declarative interpolate expression:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class BuildingColorZoomScreen extends StatefulWidget {
@override
_BuildingColorZoomScreenState createState() =>
_BuildingColorZoomScreenState();
}
class _BuildingColorZoomScreenState extends State<BuildingColorZoomScreen> {
MapController? mapController;
double currentZoom = 15.0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Building Color by Zoom')),
body: Stack(
children: [
MapMetricsView(
options: MapOptions(
initCenter: Position(2.3376, 48.8606), // Louvre area (lng, lat)
initZoom: 15.0,
initPitch: 55.0,
initBearing: 30.0,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
),
onMapCreated: (MapController controller) {
mapController = controller;
},
onStyleLoaded: _add3DBuildings,
onEvent: (MapEvent event) {
// Only used to drive the on-screen zoom badge below — the
// building color itself updates automatically via the
// 'interpolate' expression, no Dart-side work required.
if (event is MapEventCameraIdle) {
final zoom = mapController?.camera?.zoom;
if (zoom != null) {
setState(() => currentZoom = zoom);
}
}
},
),
// Zoom level indicator
Positioned(
top: 16,
left: 16,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _getColorForZoom(currentZoom),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'Zoom: ${currentZoom.toStringAsFixed(1)}',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
// Color legend
Positioned(
bottom: 16,
left: 16,
child: Card(
child: Padding(
padding: EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Zoom Levels',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
SizedBox(height: 4),
_zoomRow(Colors.grey, '< 14 (far)'),
_zoomRow(Colors.blue, '14-15 (district)'),
_zoomRow(Colors.teal, '15-16 (neighborhood)'),
_zoomRow(Colors.orange, '16-17 (block)'),
_zoomRow(Colors.deepOrange, '> 17 (building)'),
],
),
),
),
),
],
),
);
}
Widget _zoomRow(Color color, String label) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 1),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 14, height: 14, color: color),
SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 11)),
],
),
);
}
Color _getColorForZoom(double zoom) {
if (zoom < 14) return Colors.grey;
if (zoom < 15) return Colors.blue;
if (zoom < 16) return Colors.teal;
if (zoom < 17) return Colors.orange;
return Colors.deepOrange;
}
Future<void> _add3DBuildings(StyleController style) async {
await style.addLayer(
const FillExtrusionStyleLayer(
id: '3d-buildings',
sourceId: 'composite', // must already exist in the loaded style
minZoom: 13,
layout: {'source-layer': 'building'},
paint: {
// The style spec's own zoom-interpolation replaces any need to
// listen for camera events and push a new color imperatively.
'fill-extrusion-color': [
'interpolate',
['linear'],
['zoom'],
13, '#9e9e9e',
14, '#2196f3',
15, '#009688',
16, '#ff9800',
17, '#ff5722',
],
'fill-extrusion-opacity': 0.7,
'fill-extrusion-height': ['get', 'height'],
'fill-extrusion-base': ['get', 'min_height'],
},
),
);
}
}Theme-Based Building Colors
Let users switch between color themes for buildings. Unlike the zoom case, this is a discrete choice the user makes, so it does need imperative code — and because there is no setPaintProperty, switching themes means removing the layer and re-adding it with the new paint:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class BuildingThemeScreen extends StatefulWidget {
@override
_BuildingThemeScreenState createState() => _BuildingThemeScreenState();
}
class _BuildingThemeScreenState extends State<BuildingThemeScreen> {
MapController? mapController;
StyleController? styleController;
String activeTheme = 'default';
final Map<String, Map<String, String>> themes = {
'default': {'color': '#aaaaaa', 'name': 'Default'},
'night': {'color': '#1a237e', 'name': 'Night Mode'},
'warm': {'color': '#e65100', 'name': 'Warm Sunset'},
'forest': {'color': '#1b5e20', 'name': 'Forest'},
'ice': {'color': '#b3e5fc', 'name': 'Ice Blue'},
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Building Themes')),
body: Column(
children: [
Container(
padding: EdgeInsets.all(8),
child: Wrap(
spacing: 6,
children: themes.entries.map((entry) {
final hexColor = entry.value['color']!;
final color = Color(
int.parse(hexColor.replaceFirst('#', '0xFF')));
return ChoiceChip(
avatar: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
label: Text(entry.value['name']!),
selected: activeTheme == entry.key,
onSelected: (selected) async {
if (selected) {
setState(() => activeTheme = entry.key);
await _applyTheme(hexColor);
}
},
);
}).toList(),
),
),
Expanded(
child: MapMetricsView(
options: MapOptions(
initCenter: Position(2.3376, 48.8606),
initZoom: 16.0,
initPitch: 55.0,
initBearing: -20.0,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
),
onMapCreated: (MapController controller) {
mapController = controller;
},
onStyleLoaded: (style) {
styleController = style;
_addBuildingsLayer(themes[activeTheme]!['color']!);
},
),
),
],
),
);
}
Future<void> _addBuildingsLayer(String hexColor) async {
await styleController?.addLayer(
FillExtrusionStyleLayer(
id: '3d-buildings',
sourceId: 'composite',
minZoom: 13,
layout: const {'source-layer': 'building'},
paint: {
'fill-extrusion-color': hexColor,
'fill-extrusion-opacity': 0.7,
'fill-extrusion-height': const ['get', 'height'],
'fill-extrusion-base': const ['get', 'min_height'],
},
),
);
}
Future<void> _applyTheme(String hexColor) async {
// No setPaintProperty exists — swap the layer out and back in.
await styleController?.removeLayer('3d-buildings');
await _addBuildingsLayer(hexColor);
}
}Next Steps
- 3D Buildings — Basic 3D building setup
- Change Layer Color — Dynamic layer color changes
- Custom Map Styling — Full style customization
Tip: Prefer a style-spec interpolate/step expression over onEvent-driven imperative updates whenever the change is a pure function of zoom, pitch, or another map property — it runs natively in the renderer and needs no MapEventCameraIdle listener at all. Reserve removeLayer + addLayer for changes driven by user choice (like the theme switcher above), and prefer reacting to MapEventCameraIdle over MapEventMoveCamera for anything expensive, since the idle event only fires once the camera stops moving.