Game-Style Map Controls in Flutter
This tutorial shows how to create game-like controls for navigating the map — using on-screen buttons to pan, zoom, and rotate like a virtual joystick.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
D-Pad Controls
Arrow buttons to pan the map in all directions plus zoom and rotate. There's no onCameraMove callback in the real SDK, so instead of tracking camera state through a stream, each button reads the current camera synchronously with MapController.getCamera() and calls moveCamera with the updated values:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
import 'dart:async';
class GameControlsScreen extends StatefulWidget {
@override
_GameControlsScreenState createState() => _GameControlsScreenState();
}
class _GameControlsScreenState extends State<GameControlsScreen> {
MapController? mapController;
Timer? moveTimer;
final double panStep = 0.002; // How far to move per step
final double rotateStep = 5.0; // Degrees per step
final double zoomStep = 0.5;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// Map (gestures disabled for a pure game-control feel)
MapMetricsView(
options: MapOptions(
initCenter: Position(2.3522, 48.8566), // lng, lat — Paris
initZoom: 15,
initPitch: 45,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
gestures: const MapGestures(
rotate: false,
pan: false,
zoom: false,
pitch: false,
),
),
onMapCreated: (controller) => mapController = controller,
),
// D-Pad (bottom-left)
Positioned(
bottom: 40,
left: 20,
child: _buildDPad(),
),
// Zoom & Rotate (bottom-right)
Positioned(
bottom: 40,
right: 20,
child: _buildActionButtons(),
),
// Info overlay (top)
Positioned(
top: 50,
left: 16,
right: 16,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Use the controls to navigate the map',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white, fontSize: 13),
),
),
),
],
),
);
}
Widget _buildDPad() {
return Container(
width: 150,
height: 150,
child: Stack(
children: [
// Up
Positioned(
top: 0,
left: 50,
child: _dPadButton(Icons.arrow_drop_up, () => _pan(0, panStep)),
),
// Down
Positioned(
bottom: 0,
left: 50,
child: _dPadButton(Icons.arrow_drop_down, () => _pan(0, -panStep)),
),
// Left
Positioned(
top: 50,
left: 0,
child: _dPadButton(Icons.arrow_left, () => _pan(-panStep, 0)),
),
// Right
Positioned(
top: 50,
right: 0,
child: _dPadButton(Icons.arrow_right, () => _pan(panStep, 0)),
),
// Center dot
Positioned(
top: 55,
left: 55,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.grey[700],
shape: BoxShape.circle,
),
),
),
],
),
);
}
Widget _dPadButton(IconData icon, VoidCallback action) {
return GestureDetector(
onTapDown: (_) {
action();
// Continuous movement while held
moveTimer = Timer.periodic(Duration(milliseconds: 100), (_) => action());
},
onTapUp: (_) => moveTimer?.cancel(),
onTapCancel: () => moveTimer?.cancel(),
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: Colors.white, size: 30),
),
);
}
Widget _buildActionButtons() {
return Column(
children: [
// Zoom in
_actionButton(Icons.add, Colors.blue, () => _zoom(zoomStep)),
SizedBox(height: 8),
// Zoom out
_actionButton(Icons.remove, Colors.blue, () => _zoom(-zoomStep)),
SizedBox(height: 16),
// Rotate left
_actionButton(Icons.rotate_left, Colors.orange, () => _rotate(-rotateStep)),
SizedBox(height: 8),
// Rotate right
_actionButton(Icons.rotate_right, Colors.orange, () => _rotate(rotateStep)),
SizedBox(height: 16),
// Reset
_actionButton(Icons.home, Colors.green, _resetView),
],
);
}
Widget _actionButton(IconData icon, Color color, VoidCallback onPressed) {
return GestureDetector(
onTapDown: (_) {
onPressed();
moveTimer = Timer.periodic(Duration(milliseconds: 150), (_) => onPressed());
},
onTapUp: (_) => moveTimer?.cancel(),
onTapCancel: () => moveTimer?.cancel(),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color.withOpacity(0.8),
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 4)],
),
child: Icon(icon, color: Colors.white),
),
);
}
void _pan(double dLng, double dLat) {
final controller = mapController;
if (controller == null) return;
final camera = controller.getCamera();
controller.moveCameraSync(
center: Position(camera.center.lng + dLng, camera.center.lat + dLat),
);
}
void _zoom(double delta) {
final controller = mapController;
if (controller == null) return;
final camera = controller.getCamera();
final newZoom = (camera.zoom + delta).clamp(1.0, 20.0);
controller.moveCameraSync(zoom: newZoom);
}
void _rotate(double deltaBearing) {
final controller = mapController;
if (controller == null) return;
final camera = controller.getCamera();
controller.moveCameraSync(bearing: camera.bearing + deltaBearing);
}
void _resetView() {
mapController?.animateCamera(
center: Position(2.3522, 48.8566),
zoom: 15,
bearing: 0,
pitch: 45,
);
}
@override
void dispose() {
moveTimer?.cancel();
super.dispose();
}
}Controls Reference
| Button | Action |
|---|---|
| Arrow Up | Pan north |
| Arrow Down | Pan south |
| Arrow Left | Pan west |
| Arrow Right | Pan east |
| + | Zoom in |
| - | Zoom out |
| Rotate Left | Rotate counter-clockwise |
| Rotate Right | Rotate clockwise |
| Home | Reset to starting view |
Next Steps
- Set Pitch and Bearing — Manual camera perspective
- Navigation Controls — Standard map controls
- Animate Camera Around Point — Automatic orbiting
Tip: Use GestureDetector.onTapDown with a repeating Timer for continuous movement while the button is held down, giving a smooth game-like feel. moveCameraSync (Android JNI only, falls back to async elsewhere) avoids microtask scheduling gaps in a tight repeat loop like this one.