DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Compose Multiplatform Interop Layers: Bridging Native Views and Shared UI Without the Jank

---
title: "Compose Multiplatform Interop: Bridging Native Views Without the Jank"
published: true
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."
tags: kotlin, mobile, android, architecture
canonical_url: https://mvpfactory.co/blog/compose-multiplatform-interop-native-views
---

## What We Will Build

By the end of this tutorial you will understand exactly what happens at the boundary between Compose Multiplatform and native views — and you will have the specific patterns to keep your UI at 60fps when embedding maps, camera previews, or ad SDKs inside shared Compose screens.

The interop boundary is where frame budgets die. Let me show you why, and how to stop it.

## Prerequisites

- Compose Multiplatform project targeting Android and iOS
- Basic familiarity with `AndroidView` and `UIKitView`
- Understanding of Compose recomposition lifecycle

---

## Step 1: Understand Why This Is Deeper Than It Looks

Most teams treat `UIKitView` and `AndroidView` as simple wrappers. They are not. They are synchronization contracts between two fundamentally different rendering pipelines.

Compose uses a retained-mode scene graph backed by a `LayoutNode` tree. Native views on both platforms use immediate-mode layout systems — `UIView` uses Auto Layout's constraint solver, `android.view.View` uses measure/layout passes.

Here is how the layers stack:

| 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 layout within the same 16ms frame budget. If your native map SDK triggers a constraint re-solve during a Compose recomposition, you are burning two layout passes in one frame.

**The fix is easy to state and easy to skip:** give native views fixed, stable bounds. Do not wrap them in `wrapContentSize()`. Use `Modifier.size()` or `fillMaxSize()` with explicit constraints so the native layout engine never needs to negotiate dimensions dynamically.

---

## Step 2: Fix 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.

Here is the minimal setup to get this working on Android without losing scroll gestures:

Enter fullscreen mode Exit fullscreen mode


kotlin
AndroidView(
factory = { context ->
NativeMapView(context).apply {
setOnTouchListener { v, event ->
v.parent.requestDisallowInterceptTouchEvent(
event.action != MotionEvent.ACTION_UP
)
false
}
}
}
)


On iOS, you need to coordinate `UIGestureRecognizer.shouldRecognizeSimultaneouslyWith`. Skip this and scroll gestures get swallowed entirely by the native view — a silent regression that takes hours to trace back to its source.

---

## Step 3: Build a Two-Way Focus Bridge

Here is the gotcha that will save you hours: focus is the most underestimated failure mode in Compose Multiplatform interop.

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:

Enter fullscreen mode Exit fullscreen mode


kotlin
// iOS — via KMP expect/actual bridge
UIKitView(
factory = {
NativeTextField().apply {
onFocusGained = { focusRequester.requestFocus() }
onFocusLost = { focusManager.clearFocus() }
}
}
)


The docs do not mention this, but this two-way binding is mandatory if you want keyboard avoidance to work correctly on iPad and to pass App Store accessibility audits. The cost is 10 lines of expect/actual code per platform. The cost of skipping it is failed submissions.

---

## Step 4: Know Your Native View Types by Risk Level

Not all native views carry the same frame cost. Here is the pattern I use in every project — a quick reference before you reach for interop:

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

Camera previews on Android are actually a performance advantage when used correctly — `SurfaceView` bypasses the Compose rendering layer entirely and draws to a separate `Surface` in the compositor. Respect that boundary and do not fight it.

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.

---

## Gotchas

**Unstable lambdas kill native views on every recomposition.** Any lambda passed to `AndroidView` or `UIKitView` that captures an unstable reference will cause the native view to be torn down and recreated. Profile with `Recomposer.runningRecomposers` before shipping.

**`wrapContentSize()` triggers double layout passes.** The native layout engine and Compose both try to measure the view. Use explicit `Modifier.size()` constraints instead.

**Accessibility regressions are invisible in unit tests.** The focus synchronization problem only surfaces during manual testing with VoiceOver or TalkBack enabled, or during App Store review. Build the focus bridge before QA, not after.

**iOS gesture competition is silent.** If your `UIKitView` swallows scroll events, the parent scroll container simply stops scrolling. There is no error. Profile with Instruments' Core Animation profiler if you suspect it.

---

## Before You Ship

1. Fix native view bounds at the Compose layer — explicit `Modifier.size()` constraints eliminate double layout passes, which is the primary source of jank.
2. Instrument your interop boundaries — any native view holder recreation on scroll is a bug.
3. Build a two-way focus bridge for every embedded text input.

The interop boundary is unavoidable for maps, cameras, and ad SDKs. But it does not have to cost you frames. Treat `UIKitView` and `AndroidView` as synchronization contracts, not wrappers, and your shared UI will hold 60fps across both platforms.

**Further reading:**
- [Compose Multiplatform interop docs](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-interop.html)
- [AndroidView API reference](https://developer.android.com/reference/kotlin/androidx/compose/ui/viewinterop/package-summary)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)