Compose x Flutter: Why My specified Bottom Padding Failed (and How I Fixed It)
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
- 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.
- Hardcoded padding is brittle:
bottom = 90.dpworks only when the bottom UI is predictable. With Flutter overlays (and device insets), the safe space is dynamic. - 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. - Two viable strategies:
- Flutter-side reservation (preferred): Flutter wraps the Android view with
SafeArea/Paddingso 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
ComposeViewdoesn’t pre-consume insets in embedded scenarios.
- Flutter-side reservation (preferred): Flutter wraps the Android view with
- 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
WindowInsetsAPIs 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.dpbottom 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) andSafeArea(Flutter) over magic numbers. Add feature flags for edge-to-edge. - Bake in a debug inspector: Keep a
checkVisibilityToUsermodifier 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.