Offset the Vanishing Point Using Padding in Flutter
This tutorial shows how to offset the map's center point so the map's focal point shifts to accommodate UI overlays like side panels, bottom sheets, or info cards without covering the area of interest.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
How padding works in this SDK
There is no contentPadding property on MapMetricsView/MapOptions in this SDK. The real way to shift the visual center is to convert your target coordinate to a screen Offset with MapController.toScreenLocation, nudge that Offset by the pixel amount you want to compensate for, convert it back to a Position with MapController.toLngLat, and re-center the camera on that adjusted position with animateCamera. Both conversions are real MapController methods.
/// Re-center [target] so it appears offset by [dx]/[dy] screen pixels
/// from the map's true center — useful for keeping a point of interest
/// visible above a bottom sheet or beside a side panel.
Future<void> recenterWithOffset(
MapController controller,
Position target, {
double dx = 0,
double dy = 0,
}) async {
final targetScreen = await controller.toScreenLocation(target);
final shifted = Offset(targetScreen.dx + dx, targetScreen.dy + dy);
final newCenter = await controller.toLngLat(shifted);
await controller.animateCamera(center: newCenter);
}Bottom Sheet with Map Padding
Shift the map center up when a bottom panel is shown, by moving the target's screen position down before converting back to a geographic coordinate:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class MapPaddingScreen extends StatefulWidget {
@override
_MapPaddingScreenState createState() => _MapPaddingScreenState();
}
class _MapPaddingScreenState extends State<MapPaddingScreen> {
MapController? mapController;
bool showPanel = false;
final double panelHeight = 200.0;
final Position target = Position(2.2945, 48.8584); // Eiffel Tower (lng, lat)
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Map Padding')),
body: Stack(
children: [
MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: target,
initZoom: 15.0,
),
onMapCreated: (MapController controller) {
mapController = controller;
},
mapChildren: [
WidgetLayer(
markers: [
Marker(
point: target,
size: const Size(32, 32),
alignment: Alignment.bottomCenter,
child: const Icon(Icons.location_on, color: Colors.red, size: 32),
),
],
),
],
),
// Bottom panel
if (showPanel)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
height: panelHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 8)],
),
child: Padding(
padding: EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text('Eiffel Tower',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold)),
),
IconButton(
icon: Icon(Icons.close),
onPressed: () => setState(() => showPanel = false),
),
],
),
Text('Champ de Mars, 5 Av. Anatole France',
style: TextStyle(color: Colors.grey[600])),
SizedBox(height: 12),
Row(
children: [
Icon(Icons.star, color: Colors.amber, size: 18),
Text(' 4.7 (200K reviews)'),
],
),
Spacer(),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {},
child: Text('Get Directions'),
),
),
],
),
),
),
),
],
),
floatingActionButton: showPanel
? null
: FloatingActionButton(
onPressed: () async {
setState(() => showPanel = true);
// Shift the target up by half the panel height so it
// stays visible in the space above the sheet.
final controller = mapController;
if (controller != null) {
final targetScreen = await controller.toScreenLocation(target);
final shifted = Offset(
targetScreen.dx,
targetScreen.dy + panelHeight / 2,
);
final newCenter = await controller.toLngLat(shifted);
await controller.animateCamera(center: newCenter);
}
},
child: Icon(Icons.info),
),
);
}
}Side Panel Padding
Shift the map center to the right when a left panel is open, by moving the target's screen position left before converting back:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class SidePanelPaddingScreen extends StatefulWidget {
@override
_SidePanelPaddingScreenState createState() =>
_SidePanelPaddingScreenState();
}
class _SidePanelPaddingScreenState extends State<SidePanelPaddingScreen> {
MapController? mapController;
bool showSidePanel = false;
final double panelWidth = 250.0;
final List<Map<String, dynamic>> places = [
{'name': 'Eiffel Tower', 'position': Position(2.2945, 48.8584)},
{'name': 'Louvre Museum', 'position': Position(2.3376, 48.8606)},
{'name': 'Notre-Dame', 'position': Position(2.3499, 48.8530)},
{'name': 'Sacre-Coeur', 'position': Position(2.3431, 48.8867)},
{'name': 'Arc de Triomphe', 'position': Position(2.2950, 48.8738)},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Side Panel Padding'),
leading: IconButton(
icon: Icon(showSidePanel ? Icons.menu_open : Icons.menu),
onPressed: () => setState(() => showSidePanel = !showSidePanel),
),
),
body: Row(
children: [
// Side panel
if (showSidePanel)
Container(
width: panelWidth,
color: Colors.white,
child: ListView.builder(
itemCount: places.length,
itemBuilder: (context, i) {
final place = places[i];
return ListTile(
leading: Icon(Icons.location_on, color: Colors.blue),
title: Text(place['name']),
onTap: () async {
final controller = mapController;
if (controller == null) return;
final target = place['position'] as Position;
await controller.animateCamera(center: target, zoom: 15.0);
// Compensate for the panel covering the left side.
final targetScreen = await controller.toScreenLocation(target);
final shifted = Offset(
targetScreen.dx - panelWidth / 2,
targetScreen.dy,
);
final newCenter = await controller.toLngLat(shifted);
await controller.animateCamera(center: newCenter);
},
);
},
),
),
// Map
Expanded(
child: MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: Position(2.320, 48.860), // lng, lat
initZoom: 13.0,
),
onMapCreated: (MapController controller) {
mapController = controller;
},
mapChildren: [
WidgetLayer(
markers: places.map((p) {
return Marker(
point: p['position'] as Position,
size: const Size(28, 28),
alignment: Alignment.bottomCenter,
child: const Icon(Icons.location_on, color: Colors.blue, size: 28),
);
}).toList(),
),
],
),
),
],
),
);
}
}Padding Options
| Direction | Screen offset applied before toLngLat | Use Case |
|---|---|---|
| Bottom sheet | dy: +panelHeight / 2 | Bottom sheets, info panels |
| Top bar | dy: -barHeight / 2 | Top search bars |
| Left drawer | dx: -panelWidth / 2 | Left sidebars, drawers |
| Right panel | dx: +panelWidth / 2 | Right panels |
Next Steps
- Popup on Click — Popup patterns
- Navigation Controls — Full screen map controls
- Restrict Map Panning — Constrain the visible area
Tip: MapController.fitBounds also accepts an offset: Offset and padding: EdgeInsets parameter, which is the more direct real-API tool when you're fitting the camera to a bounding box (rather than re-centering on a single point) and want the fitted content to avoid a UI overlay.