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

7 min read
#Jetpack Compose #Flutter #Android

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

Didn’t


What I’d Do Differently


References / Further Reading

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

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.

About the Author

Aniket Indulkar is an Android Engineer based in London with a Master's in Artificial Intelligence. He writes about AI, ML, Android development, and his continuous learning journey.

Connect on LinkedIn →