Add a Hillshade Layer in Flutter
This tutorial shows how to add a hillshade layer to your MapMetrics Flutter map — creating a shaded relief effect that makes terrain features like mountains, valleys, and ridges visible on a 2D map.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
Basic Hillshade
Add a hillshade layer using a raster DEM (Digital Elevation Model) source:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class HillshadeScreen extends StatefulWidget {
@override
_HillshadeScreenState createState() => _HillshadeScreenState();
}
class _HillshadeScreenState extends State<HillshadeScreen> {
MapController? mapController;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Hillshade Layer')),
body: MapMetricsView(
options: MapOptions(
initCenter: Position(8.2275, 46.8182), // Swiss Alps (lng, lat)
initZoom: 9.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: (StyleController style) {
_addHillshade(style);
},
),
);
}
Future<void> _addHillshade(StyleController style) async {
// Add terrain DEM source
await style.addSource(
const RasterDemSource(
id: 'hillshade-source',
tiles: ['https://gateway.mapmetrics-atlas.net/terrain/{z}/{x}/{y}.png'],
tileSize: 256,
),
);
// Add hillshade layer
await style.addLayer(
const HillshadeStyleLayer(
id: 'hillshade-layer',
sourceId: 'hillshade-source',
paint: {
'hillshade-exaggeration': 0.5,
'hillshade-shadow-color': '#000000',
'hillshade-highlight-color': '#ffffff',
'hillshade-accent-color': '#000000',
'hillshade-illumination-direction': 315.0,
},
),
);
}
}Hillshade with Adjustable Light Direction
Let users control the sun direction to see terrain from different angles. HillshadeStyleLayer is an immutable value — there is no setPaintProperty call — so an update means removing the layer and adding it back with new paint values:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class AdjustableHillshadeScreen extends StatefulWidget {
@override
_AdjustableHillshadeScreenState createState() =>
_AdjustableHillshadeScreenState();
}
class _AdjustableHillshadeScreenState
extends State<AdjustableHillshadeScreen> {
MapController? mapController;
StyleController? styleController;
double lightDirection = 315.0;
double exaggeration = 0.5;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Adjustable Hillshade')),
body: Stack(
children: [
MapMetricsView(
options: MapOptions(
initCenter: Position(8.2275, 46.8182), // lng, lat
initZoom: 9.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: (StyleController style) {
styleController = style;
_addHillshade(style);
},
),
// Controls
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(Icons.wb_sunny, size: 18),
SizedBox(width: 8),
Text('Light: ${lightDirection.toInt()}°'),
Expanded(
child: Slider(
value: lightDirection,
min: 0,
max: 360,
onChanged: (val) {
setState(() => lightDirection = val);
_updateHillshade();
},
),
),
],
),
Row(
children: [
Icon(Icons.terrain, size: 18),
SizedBox(width: 8),
Text('Relief: ${exaggeration.toStringAsFixed(1)}'),
Expanded(
child: Slider(
value: exaggeration,
min: 0.0,
max: 1.0,
onChanged: (val) {
setState(() => exaggeration = val);
_updateHillshade();
},
),
),
],
),
],
),
),
),
),
],
),
);
}
Future<void> _addHillshade(StyleController style) async {
await style.addSource(
const RasterDemSource(
id: 'hillshade-source',
tiles: ['https://gateway.mapmetrics-atlas.net/terrain/{z}/{x}/{y}.png'],
tileSize: 256,
),
);
await style.addLayer(
HillshadeStyleLayer(
id: 'hillshade-layer',
sourceId: 'hillshade-source',
paint: {
'hillshade-exaggeration': exaggeration,
'hillshade-illumination-direction': lightDirection,
},
),
);
}
Future<void> _updateHillshade() async {
final style = styleController;
if (style == null) return;
// Re-add the layer with the current slider values in place of the old one.
await style.removeLayer('hillshade-layer');
await style.addLayer(
HillshadeStyleLayer(
id: 'hillshade-layer',
sourceId: 'hillshade-source',
paint: {
'hillshade-exaggeration': exaggeration,
'hillshade-illumination-direction': lightDirection,
},
),
);
}
}Hillshade with Location Presets
Jump to famous mountain ranges to see the hillshade effect. Camera moves use MapController.animateCamera(center: Position(lng, lat), zoom: ...), not a CameraUpdate helper:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class HillshadePresetsScreen extends StatefulWidget {
@override
_HillshadePresetsScreenState createState() =>
_HillshadePresetsScreenState();
}
class _HillshadePresetsScreenState extends State<HillshadePresetsScreen> {
MapController? mapController;
final List<Map<String, dynamic>> presets = [
{'name': 'Swiss Alps', 'lat': 46.818, 'lng': 8.228, 'zoom': 9.0},
{'name': 'Norwegian Fjords', 'lat': 61.0, 'lng': 6.5, 'zoom': 8.0},
{'name': 'Pyrenees', 'lat': 42.695, 'lng': 0.041, 'zoom': 8.0},
{'name': 'Scottish Highlands', 'lat': 57.0, 'lng': -5.0, 'zoom': 8.0},
{'name': 'Dolomites', 'lat': 46.410, 'lng': 11.844, 'zoom': 10.0},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Hillshade Presets')),
body: Column(
children: [
Container(
padding: EdgeInsets.all(8),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: presets.map((p) {
return ActionChip(
avatar: Icon(Icons.terrain, size: 16),
label: Text(p['name'], style: TextStyle(fontSize: 12)),
onPressed: () {
mapController?.animateCamera(
center: Position(p['lng'] as double, p['lat'] as double),
zoom: p['zoom'] as double,
);
},
);
}).toList(),
),
),
Expanded(
child: MapMetricsView(
options: MapOptions(
initCenter: Position(8.228, 46.818), // Swiss Alps (lng, lat)
initZoom: 9.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: (StyleController style) async {
await style.addSource(
const RasterDemSource(
id: 'hillshade-source',
tiles: [
'https://gateway.mapmetrics-atlas.net/terrain/{z}/{x}/{y}.png',
],
tileSize: 256,
),
);
await style.addLayer(
const HillshadeStyleLayer(
id: 'hillshade-layer',
sourceId: 'hillshade-source',
paint: {
'hillshade-exaggeration': 0.5,
'hillshade-illumination-direction': 315.0,
},
),
);
},
),
),
],
),
);
}
}Hillshade Properties
HillshadeStyleLayer takes plain MapLibre style spec keys in its paint map:
| Property | Type | Default | Description |
|---|---|---|---|
hillshade-exaggeration | double | 0.5 | Intensity of the shading (0.0 - 1.0) |
hillshade-illumination-direction | double | 335.0 | Sun angle in degrees (0-360) |
hillshade-shadow-color | String | #000000 | Color of shaded areas |
hillshade-highlight-color | String | #ffffff | Color of illuminated areas |
hillshade-accent-color | String | #000000 | Color for emphasizing terrain |
Next Steps
- 3D Terrain — Full 3D terrain elevation
- Add Contour Lines — Elevation contour lines
- Satellite Terrain — Satellite with elevation
Tip: Hillshade works on flat 2D maps (no tilt needed) and is lighter on performance than full 3D terrain. It's ideal for hiking apps where you want terrain visibility without the rendering overhead of 3D.