Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save Narayan-Dhingra/9cb8f2b3c50e0ff52512e96c4e773851 to your computer and use it in GitHub Desktop.
iOS Mapbox WKWebView Bridge in Compose Multiplatform
package app.snookalook.mobile.bridges
import app.snookalook.mobile.util.ErrorReporter
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.UIKitView
import app.snookalook.mobile.di.MapboxConfig
import app.snookalook.mobile.model.Center
import app.snookalook.mobile.model.GeoPoint
import app.snookalook.mobile.model.lat
import app.snookalook.mobile.model.lng
import app.snookalook.mobile.state.appearance.AppearanceState
import app.snookalook.mobile.state.appearance.AppearanceStore
import app.snookalook.mobile.state.home.MapCameraRequestChannel
import app.snookalook.mobile.state.home.MapIntroSession
import app.snookalook.mobile.state.home.MapPoseSession
import app.snookalook.mobile.ui.theme.isEffectivelyLight
import app.snookalook.mobile.util.UaeGeo
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import org.koin.mp.KoinPlatform
import platform.CoreGraphics.CGRectMake
import platform.Foundation.NSURL
import platform.WebKit.WKScriptMessage
import platform.WebKit.WKScriptMessageHandlerProtocol
import platform.WebKit.WKUserContentController
import platform.WebKit.WKWebView
import platform.WebKit.WKWebViewConfiguration
import platform.UIKit.UIScrollViewContentInsetAdjustmentBehavior
import platform.darwin.NSObject
@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun SnookMap(
modifier: Modifier,
centers: List<Center>,
userLocation: Pair<Double, Double>?,
isOutsideUae: Boolean,
onCenterSelected: (Center) -> Unit,
) {
val centersById = remember(centers) { centers.associateBy { it.id } }
val appearanceStore: AppearanceStore? = remember {
runCatching { KoinPlatform.getKoin().get<AppearanceStore>() }.getOrNull()
}
val appearanceState = appearanceStore?.state?.collectAsState()?.value ?: AppearanceState()
val isLight = appearanceState.selected.isEffectivelyLight()
// Stable HTML, keyed only on isLight (initial theme). Centers injected
// via JS post-load so async-arriving centers do NOT trigger HTML reload
// mid-arc. Theme flips routed through snookSetStyle JS.
val html = remember(isLight) { buildMapHtml(isLight, MapPoseSession.last) }
val readyFlow = remember { MutableStateFlow(false) }
var webViewRef by remember { mutableStateOf<WKWebView?>(null) }
val currentUserLoc by rememberUpdatedState(userLocation)
val currentIsOutsideUae by rememberUpdatedState(isOutsideUae)
// Inject / refresh markers when centers change. Map state preserved.
LaunchedEffect(centers) {
readyFlow.first { it }
val webView = webViewRef ?: return@LaunchedEffect
val markersJson = buildMarkersJson(centers)
webView.evaluateJavaScript("snookSetMarkers($markersJson);", null)
}
// 3-flow camera choreography, port of Android MapViewBridge.android.kt
// LaunchedEffect(Unit) state machine (PR #40 815e971). Beats + timings
// match exactly so iOS feels identical to Android.
//
// Flow 3 outside-early (GPS settled outside): snap user@z6.5 →
// UAE overview → Dubai cluster
// Flow 1 inside-granted: UAE → cluster → flyTo user@z14
// Flow 2 no-permission: UAE → cluster → await grant → branch
// Flow 3 outside-late: UAE → cluster → user@z6.5 → UAE → cluster
//
// Codex P2 (PR #130, Android parity): hasPlayed flips at intro START
// (re-entry snap semantics), so it cannot gate user camera requests —
// mid-arc picks would fly then get stomped by the still-running intro
// coroutine. introComplete flips only at each choreography terminal.
val introComplete = remember { mutableStateOf(MapIntroSession.hasPlayed) }
LaunchedEffect(Unit) {
readyFlow.first { it }
val webView = webViewRef ?: return@LaunchedEffect
// Session-singleton gate, intro plays once per launch. Re-entry
// snaps to final pose. Founder pen 2026-06-06.
if (MapIntroSession.hasPlayed) {
val loc = currentUserLoc
if (loc != null) {
val (lat, lng) = loc
val zoom = if (currentIsOutsideUae) 6.5 else 14.0
webView.evaluateJavaScript("snookSetUserDot($lat, $lng);", null)
webView.evaluateJavaScript("snookSnapUser($lat, $lng, $zoom);", null)
MapPoseSession.set(lat, lng, zoom)
} else {
webView.evaluateJavaScript("snookDubaiCluster(0);", null)
MapPoseSession.set(55.30, 25.20, 10.5)
}
introComplete.value = true
return@LaunchedEffect
}
MapIntroSession.markPlayed()
// GPS settle window, granted permission usually fixes <500ms on
// real device. Outside-UAE detect at compose time skips late arc.
var settled = 0
while ((currentUserLoc == null || !currentIsOutsideUae) && settled < 1200) {
delay(150L)
settled += 150
}
// Outside-UAE branch with GPS settled: user FIRST (founder spec
// 2026-05-29, outside-UAE user must see themselves first).
val startLoc = currentUserLoc
if (startLoc != null && currentIsOutsideUae) {
val (lat, lng) = startLoc
webView.evaluateJavaScript("snookSetUserDot($lat, $lng);", null)
webView.evaluateJavaScript("snookSnapUser($lat, $lng, 6.5);", null)
MapPoseSession.set(lat, lng, 6.5)
delay(1500L)
webView.evaluateJavaScript("snookUaeOverview(2500);", null)
MapPoseSession.set(54.0, 24.5, 6.0)
delay(2700L)
webView.evaluateJavaScript("snookDubaiCluster(2200);", null)
MapPoseSession.set(55.30, 25.20, 10.5)
introComplete.value = true
return@LaunchedEffect
}
// Beat 1: UAE overview pause, see all 7 emirates.
delay(1100L)
// Beat 2: flyTo Dubai cluster, frames all 5 green balls.
webView.evaluateJavaScript("snookDubaiCluster(2200);", null)
MapPoseSession.set(55.30, 25.20, 10.5)
delay(2400L)
// Beat 3: await GPS fix.
while (currentUserLoc == null) {
delay(300L)
}
val loc = currentUserLoc ?: return@LaunchedEffect
val (lat, lng) = loc
val outside = currentIsOutsideUae
if (!outside) {
// Flow 1 + Flow 2 granted-inside, flyTo user@z14, final pose.
val insideUae = UaeGeo.boundsContain(GeoPoint(lat, lng))
if (insideUae) {
webView.evaluateJavaScript("snookSetUserDot($lat, $lng);", null)
webView.evaluateJavaScript("snookFlyUser($lat, $lng, 14, 2000);", null)
MapPoseSession.set(lat, lng, 14.0)
}
} else {
// GPS revealed outside-UAE late, user → UAE → cluster arc.
webView.evaluateJavaScript("snookSetUserDot($lat, $lng);", null)
webView.evaluateJavaScript("snookFlyUser($lat, $lng, 6.5, 2500);", null)
MapPoseSession.set(lat, lng, 6.5)
delay(2700L)
webView.evaluateJavaScript("snookUaeOverview(2500);", null)
MapPoseSession.set(54.0, 24.5, 6.0)
delay(2700L)
webView.evaluateJavaScript("snookDubaiCluster(2200);", null)
MapPoseSession.set(55.30, 25.20, 10.5)
}
introComplete.value = true
}
// User-driven camera animations (PickLocation pin, RefreshLocation
// fresh GPS). Side-channel from HomeStore.Label.AnimateMapCameraTo →
// HomeScreen forwards to MapCameraRequestChannel. Gated on intro
// completion so the choreography is not raced; pre-intro picks are
// dropped (rare, acceptable, founder pen "fly after intro").
LaunchedEffect(Unit) {
readyFlow.first { it }
MapCameraRequestChannel.requests.collect { target ->
if (!introComplete.value) return@collect
val webView = webViewRef ?: return@collect
// Move the gold user-dot to the target FIRST so it slides in with
// the camera rather than appearing only after flyTo lands. Mapbox
// GL keeps the marker pinned to (lng, lat) during flyTo, so the
// pulsing ring is on-screen throughout the animation.
webView.evaluateJavaScript(
"snookSetUserDot(${target.lat}, ${target.lng});",
null,
)
webView.evaluateJavaScript(
"snookFlyUser(${target.lat}, ${target.lng}, ${target.zoom}, ${target.durationMs});",
null,
)
MapPoseSession.set(target.lat, target.lng, target.zoom)
}
}
// Theme flip, re-style without rebuilding WebView.
DisposableEffect(isLight) {
val styleUrl = if (isLight) "mapbox://styles/mapbox/light-v11" else "mapbox://styles/mapbox/dark-v11"
webViewRef?.evaluateJavaScript("snookSetStyle('$styleUrl');", null)
onDispose {}
}
UIKitView(
modifier = modifier,
factory = {
val messageHandler = object : NSObject(), WKScriptMessageHandlerProtocol {
override fun userContentController(
userContentController: WKUserContentController,
didReceiveScriptMessage: WKScriptMessage,
) {
val body = didReceiveScriptMessage.body as? String ?: return
if (body == "__ready__") {
readyFlow.value = true
return
}
if (body.startsWith("__diag__:")) {
ErrorReporter.breadcrumb("map", "SnookMap[diag] ${body.removePrefix("__diag__:")}")
return
}
val center = centersById[body]
if (center != null) {
onCenterSelected(center)
} else {
ErrorReporter.breadcrumb("map", "SnookMap: tap on unknown centerId=$body")
}
}
}
val config = WKWebViewConfiguration().apply {
userContentController.addScriptMessageHandler(
messageHandler,
name = "snookBridge",
)
}
WKWebView(frame = CGRectMake(0.0, 0.0, 0.0, 0.0), configuration = config).apply {
setOpaque(false)
// Match dark map bg so first 1-2s tile-load gap shows brand-dark
// rather than black.
scrollView.setScrollEnabled(false)
scrollView.setBounces(false)
scrollView.contentInsetAdjustmentBehavior =
UIScrollViewContentInsetAdjustmentBehavior.UIScrollViewContentInsetAdjustmentNever
// baseURL fixed to mapbox host so document.origin is well-defined.
// null baseURL produced "null" origin → mapbox-gl-js Marker.addTo
// appended elements but did NOT apply position:absolute /
// transform, markers block-flow stacked vertically regardless
// of lng/lat. Founder visual 2026-06-22 ~17:31Z confirmed bug
// persisted after CSS-inline fix `34f2591`.
loadHTMLString(html, baseURL = NSURL.URLWithString("https://api.mapbox.com/"))
webViewRef = this
}
},
update = { webView ->
// No reload, HTML stable. Centers / theme / user dot all updated
// via JS calls in side effects above.
webViewRef = webView
},
)
}
private fun buildMarkersJson(centers: List<Center>): String {
val sb = StringBuilder("[")
centers.forEachIndexed { i, c ->
if (i > 0) sb.append(",")
val safeName = c.name.replace("\\", "\\\\").replace("\"", "\\\"")
val safeId = c.id.replace("\\", "\\\\").replace("\"", "\\\"")
sb.append("{\"id\":\"").append(safeId)
.append("\",\"name\":\"").append(safeName)
.append("\",\"lat\":").append(c.lat)
.append(",\"lng\":").append(c.lng)
.append("}")
}
sb.append("]")
return sb.toString()
}
private fun buildMapHtml(isLight: Boolean, initialPose: MapPoseSession.Pose? = null): String {
val token = MapboxConfig.PUBLIC_TOKEN
val styleUrl = if (isLight) "mapbox://styles/mapbox/light-v11" else "mapbox://styles/mapbox/dark-v11"
val bodyBg = if (isLight) "#F4F1EA" else "#0E1530"
val initLng = initialPose?.lng ?: 54.0
val initLat = initialPose?.lat ?: 24.5
val initZoom = initialPose?.zoom ?: 6.0
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.4.0/mapbox-gl.js"></script>
<link href="https://api.mapbox.com/mapbox-gl-js/v3.4.0/mapbox-gl.css" rel="stylesheet">
<style>
html, body { margin: 0; padding: 0; background: $bodyBg; }
#map { position: absolute; top: 0; bottom: 0; width: 100%; background: $bodyBg; }
/* Critical mapbox-gl marker/canvas/popup rules inlined, fallback
* when remote mapbox-gl.css fails to apply in WKWebView (null
* baseURL + 0x0 init race observed on iOS). Without these the
* .mapboxgl-marker wrapper has no position:absolute and the
* transform3d() translates mapbox-gl applies on pan/zoom have
* no positioning anchor, markers block-flow stack at top of
* #map regardless of camera. Mirrors Android's projected-overlay
* fidelity by ensuring DOM markers actually pin to lng/lat. */
.mapboxgl-map { overflow: hidden; position: relative; -webkit-tap-highlight-color: rgba(0,0,0,0); }
.mapboxgl-canvas-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.mapboxgl-canvas { position: absolute; left: 0; top: 0; }
.mapboxgl-marker {
position: absolute;
top: 0;
left: 0;
will-change: transform;
opacity: 1;
pointer-events: auto;
}
.mapboxgl-popup { position: absolute; top: 0; left: 0; display: flex; will-change: transform; pointer-events: none; }
.mapboxgl-popup-content { position: relative; background: #1a1a1a; border-radius: 8px; padding: 10px 14px; pointer-events: auto; }
.mapboxgl-popup-anchor-bottom { flex-direction: column; align-items: center; }
.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip { align-self: center; border-top-color: #1a1a1a; }
.mapboxgl-popup-tip { width: 0; height: 0; border: 6px solid transparent; }
/* Center marker, green snooker ball, radial gradient + specular */
.snook-center {
width: 22px;
height: 22px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #3DAA6E 0%, #0B3D2E 100%);
box-shadow: 0 2px 6px rgba(0,0,0,0.45);
cursor: pointer;
position: relative;
}
.snook-center::after {
content: '';
position: absolute;
top: 3px; left: 4px;
width: 6px; height: 6px;
border-radius: 50%;
background: rgba(255,255,255,0.55);
filter: blur(1px);
}
/* User marker, gold ball + pulsing ring */
.snook-user {
width: 18px;
height: 18px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #F2D060 0%, #B8922A 100%);
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
position: relative;
}
.snook-user::before {
content: '';
position: absolute;
top: -9px; left: -9px;
width: 36px; height: 36px;
border-radius: 50%;
background: rgba(212,175,55,0.55);
animation: snookPulse 1.4s linear infinite;
pointer-events: none;
}
.snook-user::after {
content: '';
position: absolute;
top: 3px; left: 4px;
width: 5px; height: 5px;
border-radius: 50%;
background: rgba(255,255,255,0.7);
filter: blur(0.6px);
}
@keyframes snookPulse {
0% { transform: scale(0.6); opacity: 0.65; }
100% { transform: scale(1.8); opacity: 0; }
}
</style>
</head>
<body>
<div id="map"></div>
<script>
mapboxgl.accessToken = '$token';
// Initial pose = UAE overview, matching Android setCamera(uaeOverviewCam)
// at MapView construct so Beat 1 has somewhere to come FROM.
var map = new mapboxgl.Map({
container: 'map',
style: '$styleUrl',
center: [$initLng, $initLat],
zoom: $initZoom,
attributionControl: false
});
// Choreography JS helpers, driven from Kotlin LaunchedEffect via
// WKWebView.evaluateJavaScript. All durations in ms.
window.snookUaeOverview = function(duration) {
map.flyTo({
center: [54.0, 24.5],
zoom: 6.0,
duration: duration || 2500,
essential: true
});
};
window.snookDubaiCluster = function(duration) {
map.flyTo({
center: [55.30, 25.20],
zoom: 10.5,
duration: duration || 2200,
essential: true
});
};
window.snookFlyUser = function(lat, lng, zoom, duration) {
map.flyTo({
center: [lng, lat],
zoom: zoom,
duration: duration || 2000,
essential: true
});
};
window.snookSnapUser = function(lat, lng, zoom) {
map.jumpTo({
center: [lng, lat],
zoom: zoom
});
};
// Center markers, Kotlin drives via snookSetMarkers([{id,name,lat,lng}]).
// Replaces all existing center markers (idempotent).
window.__snookCenterMarkers = [];
window.snookSetMarkers = function(centers) {
// Force canvas re-size in case WKWebView frame was 0x0 at
// mapbox-gl construct (UIKitView CGRectMake(0,0,0,0) init →
// Compose layout sets real frame after, mapbox-gl needs to be
// told). Without this, map.project([lng,lat]) returns garbage.
try { map.resize(); } catch (e) {}
for (var i = 0; i < window.__snookCenterMarkers.length; i++) {
window.__snookCenterMarkers[i].remove();
}
window.__snookCenterMarkers = [];
for (var j = 0; j < centers.length; j++) {
(function(c) {
var el = document.createElement('div');
el.className = 'snook-center';
el.addEventListener('click', function() {
window.webkit.messageHandlers.snookBridge.postMessage(c.id);
});
var popup = new mapboxgl.Popup({ offset: 18, closeButton: false })
.setHTML('<div style="color:#C8A96E;font-family:sans-serif;font-size:13px;font-weight:600;">' + c.name + '</div>');
var m = new mapboxgl.Marker(el)
.setLngLat([c.lng, c.lat])
.setPopup(popup)
.addTo(map);
// Belt-and-suspenders: explicit inline-style positioning
// in case mapbox-gl Marker.addTo did not apply
// position:absolute (observed iOS WKWebView regression
//, block-flow stacking, founder visual 2026-06-22).
// Inline style has higher specificity than CSS rules.
el.style.position = 'absolute';
el.style.top = '0';
el.style.left = '0';
el.style.willChange = 'transform';
window.__snookCenterMarkers.push(m);
})(centers[j]);
}
// Force one update tick, mapbox-gl re-projects markers on
// any camera event. Trigger a no-op jumpTo to current center
// to re-compute transforms after potential resize change.
try {
var c = map.getCenter();
map.jumpTo({ center: [c.lng, c.lat], zoom: map.getZoom() });
} catch (e) {}
// Diagnostic, report computed style of first marker so we
// can verify position:absolute + non-zero transform in device
// logs. Strip after fix confirmed.
setTimeout(function() {
try {
var first = document.querySelector('.snook-center');
if (!first) {
window.webkit.messageHandlers.snookBridge.postMessage('__diag__:no-snook-center-in-DOM');
return;
}
var cs = window.getComputedStyle(first);
var parent = first.parentElement;
var canvasSize = '';
try {
var cv = map.getCanvas();
canvasSize = cv.width + 'x' + cv.height + ' client=' + cv.clientWidth + 'x' + cv.clientHeight;
} catch (e) { canvasSize = 'err:' + e.message; }
var diag = {
n: document.querySelectorAll('.snook-center').length,
pos: cs.position,
tform: cs.transform,
parent: parent ? parent.className : 'null',
inlinePos: first.style.position,
canvas: canvasSize
};
window.webkit.messageHandlers.snookBridge.postMessage('__diag__:' + JSON.stringify(diag));
} catch (e) {
window.webkit.messageHandlers.snookBridge.postMessage('__diag__:err:' + e.message);
}
}, 300);
};
// User-dot marker, Kotlin drives create/update via snookSetUserDot.
// Mirrors Android's animated gold "3" ball (pulsing ring CSS keyframe).
window.__snookUserMarker = null;
window.snookSetUserDot = function(lat, lng) {
if (window.__snookUserMarker) {
window.__snookUserMarker.setLngLat([lng, lat]);
return;
}
var userEl = document.createElement('div');
userEl.className = 'snook-user';
window.__snookUserMarker = new mapboxgl.Marker(userEl)
.setLngLat([lng, lat])
.addTo(map);
};
// Theme flip, Kotlin DisposableEffect(isLight) calls this; map
// state preserved (markers, camera).
window.snookSetStyle = function(styleUrl) {
map.setStyle(styleUrl);
};
// Legacy snookSetUser, kept for back-compat callers; now routes
// through snookSetUserDot + snookFlyUser.
window.snookSetUser = function(lat, lng) {
window.snookSetUserDot(lat, lng);
window.snookFlyUser(lat, lng, 14, 1000);
};
map.on('load', function() {
// Signal Kotlin LaunchedEffect that choreography + setMarkers can fire.
window.webkit.messageHandlers.snookBridge.postMessage('__ready__');
});
</script>
</body>
</html>
""".trimIndent()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment