Skip to content

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:

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

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.