7 min read

Compose x Flutter: Why My specified Bottom Padding Failed (and How I Fixed It)

A step-by-step learning log on debugging occlusion when hosting Jetpack Compose inside Flutter, covering Platform Views, z-order, WindowInsets, and robust fixes on both sides.

#Android#Flutter#Learning-log

Table of contents

  1. Context: What I’m Building (and the Problem I Hit)
  2. What I Learned / Architecture Decisions
  3. Key Concepts Explained
  4. 1) Platform Views & Hybrid Composition (Flutter ↔ Android)
  5. 2) WindowInsets vs Magic Numbers
  6. 3) “Who consumes the insets?”
  7. Code Snippets & Scenarios
  8. Scenario A (Preferred): Fix on Flutter Side — Reserve Space Upfront
  9. Scenario B: Fix on Android (Compose) Side — Pass an External Inset
  10. Scenario C: Runtime Guard — Detect & Pad When Overlapped
  11. What Worked, What Didn’t
  12. What I’d Do Differently
  13. References / Further Reading

Context: What I’m Building (and the Problem I Hit)

I maintain a native Android SDK built with Jetpack Compose. In a pure Android app, a simple container like:

modifier = Modifier
    .padding(start = 15.dp, end = 15.dp, bottom = 90.dp)
    .fillMaxSize()

kept my primary action button comfortably above any bottom UI. But when the SDK was launched from a Flutter app (via MethodChannel/Platform View), that same screen rendered underneath Flutter’s overlay (e.g., a bottom bar or sheet). The fixed 90.dp wasn’t enough—my content was being covered.

This post documents how I approached the problem, the mental model that finally made sense, and the fixes I adopted.


What I Learned / Architecture Decisions

  1. Different worlds, different rules: Hosting Compose inside Flutter means my entire screen lives inside a Platform View. Flutter can draw its own widgets above that view; my Compose code can’t “see” them.
  2. Hardcoded padding is brittle: bottom = 90.dp works only when the bottom UI is predictable. With Flutter overlays (and device insets), the safe space is dynamic.
  3. Insets must be coordinated across boundaries: Compose might consume insets inside a hosted ComposeView. Flutter might not reserve space for native content. I need an explicit contract between the Flutter side and the SDK.
  4. Two viable strategies:
    • Flutter-side reservation (preferred): Flutter wraps the Android view with SafeArea/Padding so the native SDK never gets overlapped.
    • Android-side adaptation: Expose an API in the SDK to accept external bottom inset (in px/dp) and apply it dynamically; ensure ComposeView doesn’t pre-consume insets in embedded scenarios.
  5. Runtime guards help: A small debug modifier can detect occlusion at runtime and inflate padding just enough to keep critical elements visible.

Key Concepts Explained

1) Platform Views & Hybrid Composition (Flutter ↔ Android)

When Flutter embeds native UI (Views/Compose), it uses Platform Views. With Hybrid Composition, Flutter and Android surfaces are composed together, but z-order is not the same as a pure Android layout. Flutter can layer its widgets above the native view. Your Compose tree won’t automatically know about those overlays.

Analogy: Imagine your Compose screen is a printed poster placed on a table. Flutter widgets are transparent sheets you can stack on top. Your poster doesn’t change size just because someone laid a sheet over it—you have to leave space for the sheet ahead of time or move the poster.

2) WindowInsets vs Magic Numbers

Android encourages edge‑to‑edge layouts that use WindowInsets to reserve space for system bars/IME/etc. Hardcoding 90.dp is a guess. On different devices (gestural nav, cutouts), and especially across app boundaries (Flutter → Android), it will fail.

3) “Who consumes the insets?”

If your Compose is hosted in a ComposeView, by default Compose may consume insets. In mixed trees, be explicit: either let the outer host (Flutter) handle safe areas, or pass the needed inset into your SDK and apply it at the root. Avoid double consumption or missing consumption.


Code Snippets & Scenarios

Scenario A (Preferred): Fix on Flutter Side — Reserve Space Upfront

If the bottom overlay is owned by Flutter, keep the native view clear using SafeArea or padding around AndroidView/your hosting widget.

// Flutter side
class NativeComposeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SafeArea(
      bottom: true, // Reserve safe space at bottom
      child: Padding(
        padding: const EdgeInsets.only(bottom: 16.0), // or dynamic based on overlay
        child: AndroidView(
          viewType: 'com.example/compose',
          layoutDirection: TextDirection.ltr,
          creationParams: {/* params */},
          creationParamsCodec: const StandardMessageCodec(),
        ),
      ),
    );
  }
}

When to choose this: Flutter controls the overlay (bottom bar/sheet). Flutter already knows its size; let it reserve space so the native view can be completely visible.

Scenario B: Fix on Android (Compose) Side — Pass an External Inset

Expose a public API to inject bottom inset from Flutter. Apply it to your root.

SDK (Android):

class MySdkUiController {
    private val _externalBottomInsetPx = mutableStateOf(0f)
    fun setExternalBottomInsetPx(px: Float) { _externalBottomInsetPx.value = px }

    @Composable
    fun Screen() {
        val density = LocalDensity.current
        val bottomInsetDp = with(density) { _externalBottomInsetPx.value.toDp() }
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(start = 15.dp, end = 15.dp, bottom = bottomInsetDp)
        ) {
            // ... content ...
        }
    }
}

Flutter → Android bridge (example):

// Flutter side: send overlay height (in pixels) via MethodChannel
final channel = const MethodChannel('com.example/bridge');
Future<void> updateInset(double bottomInsetPx) async {
  await channel.invokeMethod('setExternalBottomInset', bottomInsetPx);
}
// Android side: receive and apply
class BridgeHandler(private val controller: MySdkUiController) : MethodChannel.MethodCallHandler {
    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
        when (call.method) {
            "setExternalBottomInset" -> {
                val px = (call.arguments as? Number)?.toFloat() ?: 0f
                controller.setExternalBottomInsetPx(px)
                result.success(null)
            }
            else -> result.notImplemented()
        }
    }
}

Important: If your Compose lives in a ComposeView, make sure in mixed setups you’re not accidentally consuming insets too early. For embedded flows, consider:

// When using a ComposeView host
composeView.consumeWindowInsets = false // ensure outer host can handle insets

In full-screen Activities you might prefer using WindowInsets APIs directly instead of hardcoded dp.

Scenario C: Runtime Guard — Detect & Pad When Overlapped

When contracts fail (dev builds, 3rd-party hosts), use a debug visibility checker to detect occlusion and inflate bottom padding to keep CTAs visible.

@Composable
fun ScreenWithGuard(knownOverlayRect: Rect?) {
    var bottomInsetPx by remember { mutableStateOf(0f) }
    val density = LocalDensity.current
    val animatedInset by animateDpAsState(with(density) { bottomInsetPx.toDp() }, label = "inset")

    Box(Modifier.fillMaxSize().padding(bottom = animatedInset)) {
        // Watched CTA
        Button(
            modifier = Modifier.checkVisibilityToUser(
                tag = "PrimaryCTA",
                minVisiblePct = 80
            ) { _, selfRect, _ ->
                val overlap = knownOverlayRect?.let { r ->
                    val inter = Rect(
                        max(selfRect.left, r.left),
                        max(selfRect.top, r.top),
                        min(selfRect.right, r.right),
                        min(selfRect.bottom, r.bottom)
                    )
                    if (inter.width > 0 && inter.height > 0) inter.height else 0f
                } ?: 0f
                bottomInsetPx = overlap + with(density) { 8.dp.toPx() }
            },
            onClick = { /* ... */ }
        ) { Text("Continue") }

        // Overlay captures its rect
        BottomOverlay(
            Modifier.align(Alignment.BottomCenter)
                .onGloballyPositioned { /* update knownOverlayRect = it.boundsInWindow() */ }
        )
    }
}

This keeps the UI usable even if the surrounding host changes. I guard it behind a debug flag.


What Worked, What Didn’t

Worked

  • Moving safe-space responsibility to Flutter (SafeArea / Padding) when Flutter owns the overlay.
  • Adding an external inset API on the Android side for cases where the Flutter team can actively communicate the overlay height.
  • Using a runtime guard to auto-adjust padding during development and in unfamiliar hosts.

Didn’t

  • Relying on a fixed 90.dp bottom padding. It failed on different devices and entirely broke when Flutter stacked overlays above the Platform View.
  • Assuming Compose would automatically know about Flutter’s overlays. Different composition engines, different rules.

What I’d Do Differently

  • Define the contract early: Document which side (Flutter or Native) owns “safe space” and how values flow. Treat it like an API.
  • Adopt insets-first design: Prefer WindowInsets (Android) and SafeArea (Flutter) over magic numbers. Add feature flags for edge-to-edge.
  • Bake in a debug inspector: Keep a checkVisibilityToUser modifier in the toolbox to quickly spot occlusion regressions.
  • Automated UI tests: Compose UI tests that simulate an occluder and assert visible percentages or effective padding make this resilient.

References / Further Reading

These are the concepts and docs I used to reason about the problem and design the fix:

  • Flutter Platform Views and Hybrid Composition basics
  • Android WindowInsets guidance and edge‑to‑edge design
  • Compose insets handling and hosting in ComposeView
  • Cross-framework embedding patterns (Flutter ↔ Android) and z-order considerations

I also reviewed community threads discussing occlusion when combining Flutter overlays with native views; common themes matched my findings: don’t hardcode padding, coordinate safe areas, and avoid double-consuming insets.


If you run into the same issue, start by deciding who owns the safe space. If Flutter owns the overlay, reserve space on the Flutter side. If not, pass an explicit inset into your SDK. And for day-to-day reliability, keep a lightweight runtime guard handy.

Share

More to explore

Keep exploring

Previous

Taming 4KB vs 16KB Page Sizes on Android: A Developer’s Learning Log

Next

Journey into Kotlin Testing: Coroutines, Ktor, and Android ViewModels