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:
dart
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> {
MapMetricsController? mapController;
CameraPosition? currentCamera;
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 (disable touch gestures for pure game-control feel)
MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (controller) => mapController = controller,
onCameraMove: (position) {
currentCamera = position;
},
initialCameraPosition: CameraPosition(
target: LatLng(48.8566, 2.3522),
zoom: 15.0,
tilt: 45.0,
),
),
// 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) {
if (currentCamera == null) return;
final target = currentCamera!.target;
mapController?.moveCamera(
CameraUpdate.newLatLng(
LatLng(target.latitude + dLat, target.longitude + dLng),
),
);
}
void _zoom(double delta) {
if (currentCamera == null) return;
final newZoom = (currentCamera!.zoom + delta).clamp(1.0, 20.0);
mapController?.moveCamera(CameraUpdate.zoomTo(newZoom));
}
void _rotate(double deltaBearing) {
if (currentCamera == null) return;
final newBearing = currentCamera!.bearing + deltaBearing;
mapController?.moveCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: currentCamera!.target,
zoom: currentCamera!.zoom,
bearing: newBearing,
tilt: currentCamera!.tilt,
),
),
);
}
void _resetView() {
mapController?.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: LatLng(48.8566, 2.3522),
zoom: 15.0,
bearing: 0,
tilt: 45.0,
),
),
);
}
@override
void dispose() {
moveTimer?.cancel();
mapController?.dispose();
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.