Foster is a relationship check-in app, built with Kotlin Multiplatform and Compose Multiplatform for iOS and Android. When we tested it on a tablet, the phone-style bottom bar looked wrong — too stretched, not made for a big screen. Tablets usually want a side rail instead.
Adding that rail took one new file. No second codebase, no separate tablet team, no rewritten logic. Here's how it fit into the app, and how much of the code we were able to share.
1. How it fits into the app
The whole switch between phone and tablet comes down to one check, made in one place — the app shell:
BoxWithConstraints(Modifier.fillMaxSize()) {
val useRailLayout =
windowWidthSizeClass(maxWidth) == WindowWidthSizeClass.Expanded
Box(
Modifier
.fillMaxSize()
.then(if (useRailLayout) Modifier.padding(start = 88.dp) else Modifier),
) {
AppNavHost()
}
if (useRailLayout) {
SolidNavigationRail(destinations, selectedIndex, onSelect)
} else {
SolidBottomNavBar(destinations, selectedIndex, onSelect)
}
}
One boolean, checked once, decides which nav component shows up. Every screen underneath just renders normally — none of them know this check exists, and none of them needed any changes to support tablets.
2. The width check: one shared function, both platforms
To know when a window counts as "tablet-sized," we wrote one small function:
// shared/adaptive/WindowWidthSizeClass.kt
enum class WindowWidthSizeClass { Compact, Medium, Expanded }
fun windowWidthSizeClass(width: Dp): WindowWidthSizeClass = when {
width < 600.dp -> WindowWidthSizeClass.Compact
width < 840.dp -> WindowWidthSizeClass.Medium
else -> WindowWidthSizeClass.Expanded
}
This is plain Kotlin, sitting in our shared module. No Android code, no iOS code, no per-platform branch — it runs exactly the same way on both. In a normal single-platform setup, you'd write this twice: once in Kotlin for Android, once in Swift for iOS, and hope they stay in sync. Here it's one function, one source of truth, used by both apps.
It's also simple to test, since it just takes a number in and gives an answer out:
@Test
fun classifiesCompactWidths() {
assertEquals(WindowWidthSizeClass.Compact, windowWidthSizeClass(500.dp))
assertEquals(WindowWidthSizeClass.Expanded, windowWidthSizeClass(900.dp))
}
3. The rail reuses the bar — almost entirely
This is the part that shows the real value of sharing code. The rail isn't a new component built from scratch. It reuses:
- The same list of destinations
- The same tab items — same colors, same press animation, same haptic feedback
- The same small highlight detail on the edge of the surface
@Composable
fun SolidNavigationRail(...) {
Column(
modifier
.width(88.dp)
.fillMaxHeight()
.navigationBarsPadding()
.padding(horizontal = 12.dp, vertical = RailVerticalPadding.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
NavRailPill(destinations, selectedIndex, onSelect)
Spacer(Modifier.weight(1f))
SolidIconButton(icon = Res.drawable.ic_contact, ...)
}
}
The pill just turns from a row into a column: width and height swap, the shape changes from a circle to a rounded rectangle, and the selection indicator moves on the y axis instead of x. Same spring animation, same math — just turned sideways.
We didn't write new selection logic, new animation logic, or new haptic logic for the rail. All of that already existed and just got reused. The only genuinely new code was the shape of the container itself.
4. What made this easy
If you're weighing KMP or CMP and wondering how much a second layout really costs once phone support already exists, here's what kept ours cheap:
-
Everything lives in one shared module. The bar, the rail, and the width check are all in
commonMain. There was no separate Android version and iOS version to keep in sync — one set of files serves both platforms. - The tab items never depended on their container. Colors, animations, and haptics were written once, without any assumption about where they'd be placed. That's what let the rail reuse them directly.
- No new dependency needed. The width check is ten lines we own and understand, not a library we had to learn or wait to stabilize.
5. What this means for going cross-platform
Going from phone-only to phone-and-tablet cost us one new container file and one small shared function — not a second codebase, and not a separate implementation per platform. That's the part of Kotlin Multiplatform and Compose Multiplatform that's easy to undersell until you actually need it: once your logic and your UI both live in one shared codebase, supporting a new device size isn't a new project. It's mostly just reusing decisions you already made.
Foster is built with Kotlin Multiplatform and Compose Multiplatform for iOS and Android — one shared codebase for UI, data, reminders, theming, and now adaptive navigation.











