Satellite Map with Terrain Relief in Flutter
This tutorial shows how to display satellite imagery combined with a tilted 3D-style camera and hillshade relief — ideal for outdoor, hiking, and geographic exploration apps.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
What this SDK actually supports for terrain
There is no mapController.setTerrain(...) method and no "exaggeration" parameter in this SDK — StyleController's real methods are addSource, addLayer, updateGeoJsonSource, removeLayer, removeSource, getAttributions, addImage/addImages/addSprite, removeImage, and setProjection. There's no call that displaces the map surface into 3D elevation.
What is real and gives you a comparable outdoor-map effect:
- Tilted camera perspective —
MapOptions.initPitch/initBearing, andMapController.animateCamera(pitch: ..., bearing: ...), genuinely tilt and rotate the camera. This is what makes mountains and terrain "look 3D" even without true elevation displacement. - Hillshade relief shading — add a
RasterDemSource(elevation tiles) and aHillshadeStyleLayerviaStyleController.addSource/addLayerinonStyleLoaded. This draws shaded relief directly into the map's 2D surface — a real, supported way to convey terrain shape. - Satellite imagery — just point
initStyle(orsetStyleUri) at a style whose raster/vector layers include satellite tiles. There's no separate "satellite mode" flag; it's simply a different style document.
Basic Satellite View
Switch to a satellite style URL and use a tilted, rotated camera for a 3D-feeling perspective:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class SatelliteMapScreen extends StatefulWidget {
@override
_SatelliteMapScreenState createState() => _SatelliteMapScreenState();
}
class _SatelliteMapScreenState extends State<SatelliteMapScreen> {
MapController? mapController;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Satellite View')),
body: MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_SATELLITE_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: Position(8.2275, 46.8182), // Swiss Alps (lng, lat)
initZoom: 10.0,
initPitch: 60.0,
initBearing: 30.0,
),
onMapCreated: (MapController controller) {
mapController = controller;
},
),
);
}
}Toggle Between Map and Satellite
Let users switch styles at runtime with MapController.setStyleUri — switching in-place, without destroying and recreating the native map:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class MapToggleScreen extends StatefulWidget {
@override
_MapToggleScreenState createState() => _MapToggleScreenState();
}
class _MapToggleScreenState extends State<MapToggleScreen> {
MapController? mapController;
bool isSatellite = false;
final String mapStyleUrl =
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY';
final String satelliteStyleUrl =
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_SATELLITE_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Map / Satellite Toggle')),
body: Stack(
children: [
MapMetricsView(
options: MapOptions(
initStyle: mapStyleUrl,
initCenter: Position(2.2945, 48.8584), // lng, lat
initZoom: 15.0,
),
onMapCreated: (MapController controller) {
mapController = controller;
},
),
// Toggle button
Positioned(
top: 16,
right: 16,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 4)],
),
child: ToggleButtons(
isSelected: [!isSatellite, isSatellite],
onPressed: (index) async {
final satellite = index == 1;
setState(() => isSatellite = satellite);
await mapController?.setStyleUri(
satellite ? satelliteStyleUrl : mapStyleUrl,
);
await mapController?.animateCamera(
pitch: satellite ? 45.0 : 0.0,
);
},
borderRadius: BorderRadius.circular(8),
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(Icons.map, size: 18),
SizedBox(width: 4),
Text('Map'),
],
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(Icons.satellite, size: 18),
SizedBox(width: 4),
Text('Satellite'),
],
),
),
],
),
),
),
],
),
);
}
}Satellite with Hillshade Relief
Combine satellite imagery with a RasterDemSource + HillshadeStyleLayer for shaded terrain relief, plus a tilted camera:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class SatelliteTerrainScreen extends StatefulWidget {
@override
_SatelliteTerrainScreenState createState() =>
_SatelliteTerrainScreenState();
}
const _terrainSourceId = 'terrain-source';
const _hillshadeLayerId = 'hillshade-layer';
class _SatelliteTerrainScreenState extends State<SatelliteTerrainScreen> {
MapController? mapController;
bool hillshadeEnabled = true;
final List<Map<String, dynamic>> locations = [
{'name': 'Swiss Alps', 'position': Position(8.228, 46.818), 'zoom': 10.0, 'bearing': 30.0},
{'name': 'Grand Canyon', 'position': Position(-112.113, 36.107), 'zoom': 11.0, 'bearing': 90.0},
{'name': 'Mount Fuji', 'position': Position(138.727, 35.361), 'zoom': 11.0, 'bearing': 200.0},
{'name': 'Himalayas', 'position': Position(86.925, 27.988), 'zoom': 10.0, 'bearing': 45.0},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Satellite + Hillshade')),
body: Stack(
children: [
MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_SATELLITE_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: Position(8.228, 46.818), // lng, lat
initZoom: 10.0,
initPitch: 60.0,
initBearing: 30.0,
),
onMapCreated: (MapController controller) {
mapController = controller;
},
onStyleLoaded: (style) async {
if (hillshadeEnabled) await _addHillshade(style);
},
),
// Location buttons
Positioned(
bottom: 80,
left: 8,
right: 8,
child: SizedBox(
height: 40,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: locations.length,
separatorBuilder: (_, __) => SizedBox(width: 6),
itemBuilder: (context, i) {
final loc = locations[i];
return ElevatedButton(
onPressed: () {
mapController?.animateCamera(
center: loc['position'] as Position,
zoom: loc['zoom'] as double,
pitch: 60.0,
bearing: loc['bearing'] as double,
);
},
child: Text(loc['name'], style: TextStyle(fontSize: 11)),
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: 12),
),
);
},
),
),
),
// Hillshade toggle
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
children: [
Text('Hillshade relief'),
Switch(
value: hillshadeEnabled,
onChanged: (val) async {
setState(() => hillshadeEnabled = val);
final style = mapController?.style;
if (style == null) return;
if (val) {
await _addHillshade(style);
} else {
await style.removeLayer(_hillshadeLayerId);
await style.removeSource(_terrainSourceId);
}
},
),
],
),
),
),
),
],
),
);
}
Future<void> _addHillshade(StyleController style) async {
await style.addSource(
const RasterDemSource(
id: _terrainSourceId,
url: 'https://gateway.mapmetrics-atlas.net/terrain/tiles.json',
tileSize: 256,
),
);
await style.addLayer(
const HillshadeStyleLayer(
id: _hillshadeLayerId,
sourceId: _terrainSourceId,
paint: {'hillshade-shadow-color': '#473B24'},
),
);
}
}Next Steps
- Navigation Controls — Camera and gesture handling
- Multiple Geometries — Markers, lines, polygons, circles together
- Custom Map Styling — Style your map for any region
Tip: Satellite imagery + hillshade relief is data-heavy. For production apps, enable the hillshade layer only past a reasonable zoom level (e.g. 8+) to save bandwidth and improve load times at global zoom levels.