Skip to content

Instantly share code, notes, and snippets.

@Narayan-Dhingra
Created July 30, 2026 16:27
Show Gist options
  • Select an option

  • Save Narayan-Dhingra/3d3fc45928248dcdcad06f24ee46d77d to your computer and use it in GitHub Desktop.

Select an option

Save Narayan-Dhingra/3d3fc45928248dcdcad06f24ee46d77d to your computer and use it in GitHub Desktop.
Unified Map Choreography Engine in Jetpack Compose

Architecting a Unified Map Choreography Engine in Jetpack Compose and Mapbox

When building Snook-A-Look, we wanted the app to open with a jaw-dropping map experience on Android. We didn't want the user to just open a static map—we wanted a dynamic, narrative-driven 120fps choreography that guides the user from the UAE overview, zooms into the Dubai club cluster, and finally lands on their exact location.

Because we needed seamless transitions regardless of GPS availability, permissions, or network speed, we built a Unified Map Choreography Engine using Jetpack Compose LaunchedEffect and the Mapbox SDK.

Here’s how we architected this complex state machine without a single stutter.

The Problem: Asynchronous GPS and UX Narrative

A map intro sequence sounds simple until you account for real-world conditions:

  • Flow 1: The user granted location, and we have a GPS fix immediately.
  • Flow 2: The user hasn't granted location yet, so we must hold the camera mid-air until they do.
  • Flow 3: The user is outside the UAE (e.g., USA). The founder's mandate: “Outside-UAE users must see themselves FIRST, then fly to the UAE.”
  • Flow 4: The GPS fix arrives late, mid-animation.

If we just blindly animated the Mapbox camera, late GPS callbacks would race and stomp the running animations, causing jarring teleports.

The Solution: Compose State Machine via LaunchedEffect

We solved this by containing the entire multi-beat choreography within a single, unbreakable LaunchedEffect(Unit) in our Compose SnookMap bridge. This coroutine acts as a unified state machine.

// MapViewBridge.android.kt
val currentUserLoc by rememberUpdatedState(userLocation)
val currentIsOutsideUae by rememberUpdatedState(isOutsideUae)
val introComplete = remember { mutableStateOf(MapIntroSession.hasPlayed) }

LaunchedEffect(Unit) {
    if (MapIntroSession.hasPlayed) {
        // Snap immediately if nav-back from another screen
        snapToFinalPose()
        return@LaunchedEffect
    }
    MapIntroSession.markPlayed()

    // Brief GPS settle window (fixes race conditions on fast devices)
    var settled = 0
    while ((currentUserLoc == null || !currentIsOutsideUae) && settled < 1200) {
        delay(150L)
        settled += 150
    }

    // Outside-UAE branch: Start AT user, then fly to UAE cluster
    val startLoc = currentUserLoc
    if (startLoc != null && currentIsOutsideUae) {
        snapToUser(startLoc)
        delay(1500L)
        flyToUaeOverview(2500L)
        delay(2700L)
        flyToDubaiCluster(2200L)
        introComplete.value = true
        return@LaunchedEffect
    }

    // BEAT 1: UAE overview pause
    delay(1100L)

    // BEAT 2: Fly to Dubai clubs cluster
    flyToDubaiCluster(2200L)
    delay(2400L)

    // BEAT 3: Await user location (Holds here for permissions!)
    while (currentUserLoc == null) {
        delay(300L)
    }
    
    val loc = currentUserLoc ?: return@LaunchedEffect

    // BEAT 4: Final resolution
    if (!currentIsOutsideUae) {
        // Granted-inside, flyTo user
        flyToUser(loc, 2000L)
    } else {
        // GPS revealed late that user is outside UAE
        flyToUser(loc, 2500L)
        delay(2700L)
        flyToUaeOverview(2500L)
        delay(2700L)
        flyToDubaiCluster(2200L)
    }
    
    introComplete.value = true
}

Drawing Compose Views OVER the Native Map

Instead of using bulky Mapbox SymbolLayers for markers, we tracked the camera bounds and projected the geographical coordinates into Android pixel offsets on the fly. This allowed us to draw 100% native Jetpack Compose UI (like pulsing gold cue balls) absolutely positioned over the Mapbox AndroidView.

var userPixelPos by remember { mutableStateOf<Offset?>(null) }

DisposableEffect(userLocation) {
    val point = Point.fromLngLat(lng, lat)
    fun sync() {
        val sc = mapView.mapboxMap.pixelForCoordinate(point)
        // Filter out NaNs (far side of globe rendering)
        if (!sc.x.isNaN() && !sc.y.isNaN()) {
             userPixelPos = Offset(sc.x.toFloat(), sc.y.toFloat())
        }
    }
    
    sync()
    val cancel = mapView.mapboxMap.subscribeCameraChanged { sync() }
    onDispose { cancel.cancel() }
}

// In the UI tree:
Box(modifier = modifier) {
    AndroidView(factory = { mapView })
    
    userPixelPos?.let { pos ->
        CueBallIndicator(
            modifier = Modifier.offset { IntOffset(pos.x.roundToInt(), pos.y.roundToInt()) }
        )
    }
}

Conclusion

By isolating our animation logic inside a single LaunchedEffect and utilizing rememberUpdatedState to observe GPS data without restarting the coroutine, we guaranteed a stutter-free, cinematic map experience that flawlessly handles the chaos of async permissions and network delays.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment