Compose Multiplatform interop: bridging native views without the jank
SEO Meta Description: Deep dive into UIKitView and AndroidView interop in Compose Multiplatform — render tree reconciliation, input forwarding, focus management, and patterns to prevent frame drops in production.
TL;DR
Embedding native views inside Compose Multiplatform screens is unavoidable for maps, cameras, and ad SDKs — but the interop boundary is where frame budgets die. The render tree operates on two separate threading models, input events traverse two dispatch chains, and focus state lives in two worlds simultaneously. This post walks through the architectural patterns that keep your UI at 60fps when crossing that boundary.
The interop problem is deeper than most teams think
Most teams treat UIKitView and AndroidView as simple wrappers. They’re not. They’re synchronization contracts between two fundamentally different rendering pipelines, and violating those contracts silently costs frames.
In my experience building production systems with shared Compose UI across platforms, the first sign of trouble is always a native map or camera preview causing the rest of the screen to stutter — not the native view itself, but the Compose layout pass around it.
Render tree reconciliation
Compose uses a retained-mode scene graph backed by a LayoutNode tree. Native views on both platforms operate on immediate-mode layout systems — UIView on iOS uses Auto Layout’s constraint solver, android.view.View uses measure/layout passes.
When you embed a native view, the runtimes must negotiate bounds on every frame where layout changes.
| Layer | Android | iOS |
|---|---|---|
| Compose tree | LayoutNode → AndroidView holder | LayoutNode → UIKitView holder |
| Native layout | ViewGroup.onLayout() | UIView.layoutSubviews() |
| Sync point | AndroidView.update lambda | UIKitView.update closure |
| Threading | Main thread only | Main thread only |
Both sides must complete their layout within the same 16ms frame budget. If your native map SDK triggers a constraint re-solve during a Compose recomposition, you’re burning two layout passes in one frame.
The fix is easy to state and easy to skip: give native views fixed, stable bounds. Don’t wrap them in wrapContentSize(). Use Modifier.size() or fillMaxSize() with explicit constraints so the native layout engine never needs to negotiate dimensions dynamically.
Input event forwarding
On Android, AndroidView intercepts touch events before they reach the Compose gesture detector — ViewGroup hit-testing runs first. On iOS, UIKitView uses a UIGestureRecognizer bridge that competes with Compose’s pointer input system.
// Android: prevent touch theft from parent Compose scroll
AndroidView(
factory = { context ->
NativeMapView(context).apply {
setOnTouchListener { v, event ->
// Request parent to not intercept
v.parent.requestDisallowInterceptTouchEvent(
event.action != MotionEvent.ACTION_UP
)
false
}
}
}
)
On iOS, the equivalent requires coordinating UIGestureRecognizer.shouldRecognizeSimultaneouslyWith — skip this and scroll gestures get swallowed entirely by the native view.
Focus management across the boundary
Focus is the most underestimated failure mode. Compose’s FocusManager and the platform’s native focus system are independent state machines. When a user tabs into a UIKitView-embedded text field on iOS, Compose has no idea the focus moved — onFocusChanged callbacks never fire, keyboard avoidance logic breaks, and accessibility announcements go silent.
You have to explicitly synchronize focus state using platform callbacks back into Compose:
// iOS — via KMP expect/actual bridge
UIKitView(
factory = {
NativeTextField().apply {
onFocusGained = { focusRequester.requestFocus() }
onFocusLost = { focusManager.clearFocus() }
}
}
)
This two-way binding keeps both focus state machines in sync and prevents the silent accessibility regressions that reviewers catch during App Store submission.
Camera and ad SDK patterns
Camera previews are the highest-stakes interop case. SurfaceView on Android bypasses the Compose rendering layer entirely — it draws to a separate Surface in the compositor. That’s actually a performance advantage if you respect it.
| Native view type | Render strategy | Frame impact |
|---|---|---|
| Google Maps / MapKit | Interop + GPU texture | Medium — layout sync cost |
| Camera (SurfaceView) | Compositor overlay | Low — bypasses Compose |
| Ad SDKs (WebView-backed) | Interop + JS thread | High — avoid recomposition near it |
| AR / Metal views | Platform compositor | Low with fixed bounds |
For ad SDKs specifically: isolate them in a remember-stable holder and ensure zero recomposition triggers near the AndroidView/UIKitView call site. A single unstable lambda reference causes the entire native view to tear down and recreate on recomposition.
Before you ship
-
Fix your native view bounds at the Compose layer. Pass explicit
Modifier.size()constraints instead of letting the native layout engine negotiate dimensions — this eliminates double layout passes, which is the primary source of jank. -
Instrument your interop boundaries. Use
Recomposer.runningRecomposerson Android and Instruments’ Core Animation profiler on iOS to measure how often native view holders are recreated. Any recreation on scroll is a bug. -
Build a two-way focus bridge for every embedded text input. The cost is 10 lines of expect/actual code per platform. The cost of skipping it is failed accessibility audits and broken keyboard avoidance on iPads.
Tags: kotlin kmp multiplatform jetpackcompose mobile