Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

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:

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:

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> {
  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

ButtonAction
Arrow UpPan north
Arrow DownPan south
Arrow LeftPan west
Arrow RightPan east
+Zoom in
-Zoom out
Rotate LeftRotate counter-clockwise
Rotate RightRotate clockwise
HomeReset to starting view

Next Steps


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.