Skip to content

Game-Style Map Controls

This tutorial shows how to add D-pad and button controls for navigating the map — useful for kiosk displays, accessibility, or game-like map exploration.

Prerequisites

D-Pad Navigation

Add directional buttons to pan the map:

kotlin
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.View
import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style

class GameControlsActivity : AppCompatActivity() {

    private lateinit var mapView: MapView
    private lateinit var map: MapMetricsMap

    private val handler = Handler(Looper.getMainLooper())
    private val panSpeed = 50f // pixels per tick

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_game_controls)

        mapView = findViewById(R.id.mapView)
        mapView.onCreate(savedInstanceState)
        mapView.getMapAsync { mapMetricsMap ->
            map = mapMetricsMap

            // Disable default gestures — we use buttons instead
            map.uiSettings.isScrollGesturesEnabled = false

            map.setStyle(
                Style.Builder().fromUri(
                    "https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
                )
            )

            map.cameraPosition = CameraPosition.Builder()
                .target(LatLng(48.8566, 2.3522))
                .zoom(14.0)
                .build()

            setupDPad()
            setupZoomButtons()
            setupRotateButtons()
        }
    }

    private fun setupDPad() {
        setupHoldButton(R.id.btnUp) { map.scrollBy(0f, -panSpeed) }
        setupHoldButton(R.id.btnDown) { map.scrollBy(0f, panSpeed) }
        setupHoldButton(R.id.btnLeft) { map.scrollBy(-panSpeed, 0f) }
        setupHoldButton(R.id.btnRight) { map.scrollBy(panSpeed, 0f) }
    }

    private fun setupZoomButtons() {
        findViewById<ImageButton>(R.id.btnZoomIn).setOnClickListener {
            map.animateCamera(CameraUpdateFactory.zoomIn(), 200)
        }

        findViewById<ImageButton>(R.id.btnZoomOut).setOnClickListener {
            map.animateCamera(CameraUpdateFactory.zoomOut(), 200)
        }
    }

    private fun setupRotateButtons() {
        findViewById<ImageButton>(R.id.btnRotateLeft).setOnClickListener {
            val current = map.cameraPosition.bearing
            map.easeCamera(
                CameraUpdateFactory.bearingTo(current - 15),
                300
            )
        }

        findViewById<ImageButton>(R.id.btnRotateRight).setOnClickListener {
            val current = map.cameraPosition.bearing
            map.easeCamera(
                CameraUpdateFactory.bearingTo(current + 15),
                300
            )
        }
    }

    /**
     * Repeat an action while a button is held down.
     */
    private fun setupHoldButton(viewId: Int, action: () -> Unit) {
        val button = findViewById<ImageButton>(viewId)
        var isHolding = false

        val repeatRunnable = object : Runnable {
            override fun run() {
                if (isHolding) {
                    action()
                    handler.postDelayed(this, 50) // ~20fps
                }
            }
        }

        button.setOnTouchListener { _, event ->
            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    isHolding = true
                    handler.post(repeatRunnable)
                    true
                }
                MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                    isHolding = false
                    true
                }
                else -> false
            }
        }
    }

    override fun onDestroy() {
        handler.removeCallbacksAndMessages(null)
        super.onDestroy()
        mapView.onDestroy()
    }

    override fun onStart() { super.onStart(); mapView.onStart() }
    override fun onResume() { super.onResume(); mapView.onResume() }
    override fun onPause() { super.onPause(); mapView.onPause() }
    override fun onStop() { super.onStop(); mapView.onStop() }
    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        mapView.onSaveInstanceState(outState)
    }
}

Layout (res/layout/activity_game_controls.xml):

xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <org.maplibre.android.maps.MapView
        android:id="@+id/mapView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <!-- D-Pad -->
    <RelativeLayout
        android:layout_width="160dp"
        android:layout_height="160dp"
        android:layout_gravity="start|bottom"
        android:layout_margin="16dp">

        <ImageButton
            android:id="@+id/btnUp"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:layout_centerHorizontal="true"
            android:layout_alignParentTop="true"
            android:src="@drawable/ic_arrow_up"
            android:background="@drawable/btn_dpad" />

        <ImageButton
            android:id="@+id/btnDown"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:layout_centerHorizontal="true"
            android:layout_alignParentBottom="true"
            android:src="@drawable/ic_arrow_down"
            android:background="@drawable/btn_dpad" />

        <ImageButton
            android:id="@+id/btnLeft"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:layout_centerVertical="true"
            android:layout_alignParentStart="true"
            android:src="@drawable/ic_arrow_left"
            android:background="@drawable/btn_dpad" />

        <ImageButton
            android:id="@+id/btnRight"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:layout_centerVertical="true"
            android:layout_alignParentEnd="true"
            android:src="@drawable/ic_arrow_right"
            android:background="@drawable/btn_dpad" />

    </RelativeLayout>

    <!-- Action Buttons -->
    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="end|bottom"
        android:layout_margin="16dp"
        android:orientation="vertical">

        <ImageButton
            android:id="@+id/btnZoomIn"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:src="@drawable/ic_add"
            android:background="@drawable/btn_action" />

        <ImageButton
            android:id="@+id/btnZoomOut"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:layout_marginTop="8dp"
            android:src="@drawable/ic_remove"
            android:background="@drawable/btn_action" />

        <ImageButton
            android:id="@+id/btnRotateLeft"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:layout_marginTop="16dp"
            android:src="@drawable/ic_rotate_left"
            android:background="@drawable/btn_action" />

        <ImageButton
            android:id="@+id/btnRotateRight"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:layout_marginTop="8dp"
            android:src="@drawable/ic_rotate_right"
            android:background="@drawable/btn_action" />

    </LinearLayout>

</FrameLayout>

Next Steps


Tip: The setupHoldButton pattern uses MotionEvent.ACTION_DOWN/UP to repeat the pan action while the button is held. This gives a smooth, continuous movement similar to game controllers. The 50ms interval (~20fps) is smooth without being too CPU-intensive.