MVP Factory
ai startup development

Compose Multiplatform interop: bridging native views without the jank

KW
Krystian Wiewiór · · 5 min read

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.

LayerAndroidiOS
Compose treeLayoutNodeAndroidView holderLayoutNodeUIKitView holder
Native layoutViewGroup.onLayout()UIView.layoutSubviews()
Sync pointAndroidView.update lambdaUIKitView.update closure
ThreadingMain thread onlyMain 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 typeRender strategyFrame impact
Google Maps / MapKitInterop + GPU textureMedium — layout sync cost
Camera (SurfaceView)Compositor overlayLow — bypasses Compose
Ad SDKs (WebView-backed)Interop + JS threadHigh — avoid recomposition near it
AR / Metal viewsPlatform compositorLow 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

  1. 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.

  2. Instrument your interop boundaries. Use Recomposer.runningRecomposers on Android and Instruments’ Core Animation profiler on iOS to measure how often native view holders are recreated. Any recreation on scroll is a bug.

  3. 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


Share: Twitter LinkedIn