Skip to content

Animate a Marker Along a Route

This tutorial shows how to smoothly animate a marker moving along a path — useful for showing vehicle tracking, delivery routes, or GPS playback.

Prerequisites

Animate Marker with ValueAnimator

Move a symbol layer marker along a route using Android's animation framework:

kotlin
import android.animation.TypeEvaluator
import android.animation.ValueAnimator
import android.graphics.BitmapFactory
import android.os.Bundle
import android.view.animation.LinearInterpolator
import androidx.appcompat.app.AppCompatActivity
import com.google.gson.JsonObject
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
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.layers.SymbolLayer
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.Point

class AnimateMarkerActivity : AppCompatActivity() {

    private lateinit var mapView: MapView
    private lateinit var map: MapMetricsMap
    private var markerAnimator: ValueAnimator? = null

    // Route coordinates
    private val routePoints = listOf(
        LatLng(48.8566, 2.3522),   // Paris
        LatLng(48.8620, 2.3400),
        LatLng(48.8680, 2.3310),
        LatLng(48.8738, 2.2950),   // Arc de Triomphe
        LatLng(48.8750, 2.2870),
        LatLng(48.8800, 2.2780),
        LatLng(48.8867, 2.3431),   // Sacré-Cœur
    )

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

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

            map.setStyle(
                Style.Builder().fromUri(
                    "https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY"
                )
            ) { style ->
                setupMarker(style)
                animateMarkerAlongRoute()
            }
        }
    }

    private fun setupMarker(style: Style) {
        // Add marker icon
        val bitmap = BitmapFactory.decodeResource(resources, R.drawable.ic_marker)
        style.addImage("moving-marker", bitmap)

        // Create GeoJSON source with initial position
        val initialPoint = Point.fromLngLat(
            routePoints[0].longitude,
            routePoints[0].latitude
        )
        style.addSource(
            GeoJsonSource("marker-source", Feature.fromGeometry(initialPoint))
        )

        // Add symbol layer
        style.addLayer(
            SymbolLayer("marker-layer", "marker-source")
                .withProperties(
                    iconImage("moving-marker"),
                    iconSize(1.0f),
                    iconAllowOverlap(true),
                    iconIgnorePlacement(true)
                )
        )

        // Set camera
        map.cameraPosition = CameraPosition.Builder()
            .target(routePoints[0])
            .zoom(13.0)
            .build()
    }

    private fun animateMarkerAlongRoute() {
        // Animate through each segment
        animateSegment(0)
    }

    private fun animateSegment(index: Int) {
        if (index >= routePoints.size - 1) return

        val start = routePoints[index]
        val end = routePoints[index + 1]

        markerAnimator = ValueAnimator.ofObject(
            LatLngEvaluator(), start, end
        ).apply {
            duration = 2000 // 2 seconds per segment
            interpolator = LinearInterpolator()

            addUpdateListener { animation ->
                val position = animation.animatedValue as LatLng
                val point = Point.fromLngLat(position.longitude, position.latitude)

                // Update marker position
                val source = map.style?.getSource("marker-source") as? GeoJsonSource
                source?.setGeoJson(Feature.fromGeometry(point))

                // Follow marker with camera
                map.easeCamera(
                    CameraUpdateFactory.newLatLng(position),
                    100
                )
            }

            addListener(object : android.animation.AnimatorListenerAdapter() {
                override fun onAnimationEnd(animation: android.animation.Animator) {
                    // Move to next segment
                    animateSegment(index + 1)
                }
            })

            start()
        }
    }

    // Custom evaluator for LatLng interpolation
    private class LatLngEvaluator : TypeEvaluator<LatLng> {
        override fun evaluate(fraction: Float, startValue: LatLng, endValue: LatLng): LatLng {
            val lat = startValue.latitude + (endValue.latitude - startValue.latitude) * fraction
            val lng = startValue.longitude + (endValue.longitude - startValue.longitude) * fraction
            return LatLng(lat, lng)
        }
    }

    override fun onDestroy() {
        markerAnimator?.cancel()
        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)
    }
}

With Route Trail Line

Show the path behind the marker as it moves:

kotlin
import org.maplibre.android.style.layers.LineLayer
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.LineString

private val visitedPoints = mutableListOf<Point>()

private fun setupRouteTrail(style: Style) {
    // Trail source — starts empty
    style.addSource(GeoJsonSource("trail-source"))

    // Trail line layer
    style.addLayer(
        LineLayer("trail-layer", "trail-source")
            .withProperties(
                lineColor("#4285F4"),
                lineWidth(3f),
                lineOpacity(0.7f)
            )
    )
}

private fun updateTrail(position: LatLng) {
    visitedPoints.add(
        Point.fromLngLat(position.longitude, position.latitude)
    )

    if (visitedPoints.size >= 2) {
        val lineString = LineString.fromLngLats(visitedPoints)
        val source = map.style?.getSource("trail-source") as? GeoJsonSource
        source?.setGeoJson(Feature.fromGeometry(lineString))
    }
}

Then call updateTrail(position) inside the addUpdateListener callback.

With Play/Pause Controls

kotlin
import android.widget.Button

private var isPlaying = true

private fun setupControls() {
    val playPauseBtn = findViewById<Button>(R.id.btnPlayPause)

    playPauseBtn.setOnClickListener {
        if (isPlaying) {
            markerAnimator?.pause()
            playPauseBtn.text = "Play"
        } else {
            markerAnimator?.resume()
            playPauseBtn.text = "Pause"
        }
        isPlaying = !isPlaying
    }

    findViewById<Button>(R.id.btnRestart).setOnClickListener {
        markerAnimator?.cancel()
        visitedPoints.clear()
        animateSegment(0)
        isPlaying = true
        playPauseBtn.text = "Pause"
    }
}

Next Steps


Tip: For smoother animation at high zoom levels, interpolate every 10-20ms. For performance with many animated markers, use a single GeoJSON source with multiple features instead of separate sources per marker.