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.
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.
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
}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()) }
)
}
}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.