Skip to main content

Resolving Scroll Conflicts in KMP Compose Multiplatform on iOS

 Embedding heavy native iOS components—like MKMapView, WKWebView, or a complex UITableView—inside a Compose Multiplatform (CMP) layout is a common architectural requirement. However, it introduces a notorious class of bugs involving gesture contention and performance degradation.

The specific symptoms are painful:

  1. The "Slippery" Map: You try to pan a map embedded in a scrollable screen, but the entire screen scrolls instead.
  2. The Recomposition Stutter: As the parent container scrolls, the embedded native view flickers, lags, or resets its state because the UIKitView bridge is aggressively invoking the update block.

Here is the root cause analysis and the production-grade fix for managing UIKitView scroll conflicts and lifecycle events on iOS.

The Why: Gesture Arenas and Render Loops

To understand why this breaks, you have to understand how Compose Multiplatform renders on iOS.

1. The Gesture Arena

Compose UI implements its own gesture detection system. When you place a UIKitView inside a Compose Column(Modifier.verticalScroll()), you essentially have two gesture recognition systems competing:

  • The Compose Touch Hierarchy: Which wants to consume vertical drags for the scroll container.
  • The UIKit Responder Chain: Which the MKMapView uses to handle internal pans and zooms.

By default, the Compose CMPViewController (which hosts your Compose content) acts as the primary gesture receiver. If Compose determines a touch is a "scroll," it often claims the token before the underlying UIKitView has a chance to react.

2. The update Loop

The UIKitView composable takes a factory block (run once) and an update block (run on recomposition).

In a scrolling scenario, the position of the UIKitView relative to the window changes every frame. Depending on how your state is structured, this can trigger the update lambda repeatedly. If you perform heavy logic in update (like setting map annotations or reloading web content) without checking for equality, you will cripple the scroll performance (60fps -> 15fps) and potentially cause visual glitches as the native layer struggles to keep up with property updates.

The Fix

We will implement a NativeMapComponent that solves both issues. We will use the UIKitInteropProperties to fix the touch handling and a memoized view state pattern to fix the recomposition performance.

Prerequisites

  • Kotlin: 1.9.20+ (ideally 2.0+)
  • Compose Multiplatform: 1.6.0+

Implementation

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.interop.UIKitInteropInteractionMode
import androidx.compose.ui.interop.UIKitInteropProperties
import androidx.compose.ui.interop.UIKitView
import androidx.compose.ui.unit.dp
import kotlinx.cinterop.ExperimentalForeignApi
import platform.CoreLocation.CLLocationCoordinate2DMake
import platform.MapKit.MKCoordinateRegionMakeWithDistance
import platform.MapKit.MKMapView

@OptIn(ExperimentalForeignApi::class)
@Composable
fun EmbeddedIOSMap(
    latitude: Double,
    longitude: Double,
    modifier: Modifier = Modifier
) {
    // 1. MEMOIZE THE NATIVE VIEW
    // We instantiate the MKMapView once using `remember`. 
    // This ensures that recompositions do not trigger the factory block again,
    // and we maintain a stable reference to the exact same UIView instance.
    val mkMapView = remember { MKMapView() }

    UIKitView(
        factory = {
            mkMapView.apply {
                showsUserLocation = true
                // Additional one-time configuration
            }
        },
        modifier = modifier
            .fillMaxWidth()
            .height(300.dp),
        update = { view ->
            // 2. IDEMPOTENT UPDATES
            // The `update` block runs frequently (potentially on every scroll frame).
            // We must GUARD against redundant native calls.
            // Only update the region if the data actually changed significantly.
            
            val currentCenter = view.region.center
            val isLocationChanged = 
                currentCenter.useContents { latitude } != latitude || 
                currentCenter.useContents { longitude } != longitude

            if (isLocationChanged) {
                val coordinate = CLLocationCoordinate2DMake(latitude, longitude)
                val region = MKCoordinateRegionMakeWithDistance(
                    centerCoordinate = coordinate,
                    latitudinalMeters = 1000.0,
                    longitudinalMeters = 1000.0
                )
                view.setRegion(region, animated = false)
            }
        },
        properties = UIKitInteropProperties(
            // 3. FIX GESTURE CONFLICTS
            // 'NonCooperative' tells Compose: "Do not participate in gestures 
            // specifically over this view area. Let the native view handle it."
            // This prevents the parent ScrollView from stealing the pan/zoom touches.
            interactionMode = UIKitInteropInteractionMode.NonCooperative,
            isNativeAccessibilityEnabled = true
        )
    )
}

The Explanation

1. UIKitInteropInteractionMode.NonCooperative

This is the critical line for the scroll conflict.

In Cooperative mode (the default in some versions), Compose attempts to multiplex the touch events, often resulting in the "delayed" feel or the parent scroll taking priority. By setting it to NonCooperative, we define a "hole" in the Compose gesture overlay. Touches landing within the bounds of this UIKitView are passed directly to the underlying UIView (the MKMapView).

This restores the native feel of the map (inertia, smooth panning) because the CMPViewController's pan gesture recognizer is effectively bypassed for this region.

2. Idempotent update Block

A common mistake in KMP is treating the update block like an imperative command.

// BAD
update = { view ->
   view.setRegion(newRegion) // Runs on every scroll frame!
}

If the parent scrolls, Compose might recompose the layout. Even if latitude hasn't changed, the function body runs. Calling setRegion or loadRequest (for WebView) repeatedly stops the native view's internal momentum and consumes massive CPU resources.

By comparing the incoming Compose state (latitude) against the current Native state (view.region.center), we ensure we only touch the native bridge when absolutely necessary.

3. remember { MKMapView() } vs Factory

Why did we instantiate the map inside a remember block before passing it to the factory?

While UIKitView guarantees the factory is only called once per lifecycle, holding the reference in a remember variable allows us to perform advanced lifecycle management manually if needed (e.g., calling mapView.delegate = null in a DisposableEffect). It also makes the update block cleaner as we aren't relying implicitly on the it argument alone, though using it is also valid. The key is that the instance remains stable across scroll recompositions.

Conclusion

When embedding native views in Kotlin Multiplatform on iOS, the bridge is thin but strict. You cannot rely on Compose's smart recomposition skipping alone because layout bounds changes during scrolling will trigger updates.

  1. Use UIKitInteropInteractionMode.NonCooperative to permit the native view to handle its own complex gestures (Maps, WebViews).
  2. Write defensive, idempotent code in your update block to prevent frame drops during parent scrolling.

This approach delivers 60fps scrolling performance while maintaining fully interactive, native-feeling embedded components.