Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

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:

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:

dart
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:

dart
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


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.