
Embed Compose screens inside native UIKit navigation and tab controllers, using UINavigationController/UITabBarController, native back/swipe, sheets, titles, back-stack-driven state, drop-in NavDisplay replacement.
Native UIKit navigation for Compose Multiplatform apps built on Navigation 3.
Rename NavDisplay to NativeNavDisplay and your iOS screens run in a real UINavigationController, with system swipe-back, large titles, and sheets. NativeTabDisplay adds a native UITabBarController. On Android and desktop, both fall back to NavDisplay.
- NavDisplay(
+ NativeNavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider { /* unchanged */ },
)Install · Quick start · Tabs · Compatibility · Limitations · How it works
NavDisplay. NativeNavDisplay has both overloads (backStack and entries) and every parameter of NavDisplay. Decorators, sceneStrategies, DialogSceneStrategy, and back handlers keep working.ComposeUIViewController in a UINavigationController, so the back button, edge swipe-back, and the back button's history menu are UIKit's own.NativeTabDisplay shows a UITabBarController with a navigation stack per tab. It accepts either the single back stack of Navigation 3's commonui recipe or one back stack per tab.onBack. If the app refuses a pop, the screen is put back.The library is on Maven Central. Depend on it from commonMain:
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.seungmin.nav3:nav3-uikit:0.1.0-dev01")
}
}
}One coordinate serves Android, JVM, iosArm64, and iosSimulatorArm64. The library exposes Navigation 3 (org.jetbrains.androidx.navigation3:navigation3-ui) as an api dependency, so the app does not have to declare it. The tab display uses UITab and requires iOS 18 or later.
NativeNavigationViewController. Apps that use NativeTabDisplay need it for the tab bar to appear on an open iPhone Duo.// iosMain
fun MainViewController(): UIViewController = NativeNavigationViewController { App() }NavDisplay with NativeNavDisplay, and add NativeNavigation metadata to the entries that should use the native navigation bar or a sheet.NativeNavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider {
entry<Home>(metadata = NativeNavigation.title("Home", large = true)) { HomeScreen() }
entry<Detail>(metadata = { NativeNavigation.title("Detail ${it.id}") }) { DetailScreen(it.id) }
entry<Settings>(metadata = NativeNavigation.sheet(listOf(SheetDetent.Medium, SheetDetent.Large))) { SettingsSheet() }
entry<Immersive>(metadata = NativeNavigation.navigationBarHidden()) { ImmersiveScreen() }
entry<ConfirmDelete>(metadata = DialogSceneStrategy.dialog()) { ConfirmDeleteDialog() }
},
sceneStrategies = listOf(DialogSceneStrategy()),
)Entries without NativeNavigation metadata draw their own top bar, exactly as under NavDisplay.
| Metadata | Effect on iOS |
|---|---|
NativeNavigation.title(text, large) |
Shows the native navigation bar with this title, optionally as a large title |
NativeNavigation.sheet(detents) |
Presents the entry as a sheet with SheetDetent.Medium and/or SheetDetent.Large
|
NativeNavigation.navigationBarHidden() |
Hides the navigation bar for this entry |
NativeNavigation.tabBarHidden() |
Hides the tab bar while this entry is pushed above its tab's root |
NativeNavigationBarItems sets the native bar's title and trailing buttons from inside the screen. LocalNativeNavigationBar is true where the native bar is available, so one screen can serve every platform:
if (LocalNativeNavigationBar.current) {
NativeNavigationBarItems(
title = note.title,
trailing = listOf(NativeBarButton("Edit", systemImage = "pencil") { onEdit() }),
)
} else {
TopAppBar(title = { Text(note.title) }, actions = { /* ... */ })
}NavDisplay has no tabs; an app's tab bar is its own UI next to it. To get a native tab bar, replace the app's tab bar and NavDisplay with one NativeTabDisplay call. Choose the overload that matches how the app already stores its navigation state.
One back stack holding every tab, as the TopLevelBackStack of Navigation 3's commonui recipe does. The selected tab is read from the back stack.
// e.g. [Home, Detail(1), Search, Detail(2)]
val topLevelBackStack = remember { TopLevelBackStack<NavKey>(Home) }
NativeTabDisplay(
backStack = topLevelBackStack.backStack,
tabs = listOf(
NativeTabRoot(Home, "Home", icon = "house"), // SF Symbol name
NativeTabRoot(Search, "Search", icon = painterResource(Res.drawable.search)), // tinted as a template image
),
onTabSelected = { topLevelBackStack.addTopLevel(it) },
onBack = { topLevelBackStack.removeLast() },
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = entryProvider { /* entries for every tab */ },
)One back stack per tab, as Navigation 3's multiplestacks recipe does. The app owns the selection.
val home = rememberNavBackStack(config, Home)
val search = rememberNavBackStack(config, SearchHome)
var selected by rememberSaveable { mutableIntStateOf(0) }
NativeTabDisplay(
tabs = listOf(
NativeTab(home, "Home", icon = "house"),
NativeTab(search, "Search", icon = painterResource(Res.drawable.search)),
),
selectedTab = selected,
onTabSelected = { selected = it },
onBack = { tab -> listOf(home, search)[tab].removeLastOrNull() },
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = entryProvider { /* entries for every tab */ },
)With one back stack per tab, every tab keeps its rememberSaveable state and ViewModels while it is hidden. An app that wants to keep its Compose tab bar can use NativeNavDisplay alone; the tab bar then stays a Compose view and does not move into the iPhone Duo vertical bar.
NativeNavDisplay passes every parameter to NavDisplay. NativeTabDisplay shows a NavDisplay above its tabBar slot, which defaults to a plain row of tab icons and titles; pass the app's own tab bar there. Back behaves as before: the single back stack overload follows the back stack exactly, and the per-tab overload returns to the first tab from the root of any other tab.
| Dependency | Tested with |
|---|---|
Navigation 3 (org.jetbrains.androidx.navigation3:navigation3-ui) |
1.1.2 |
| Compose Multiplatform | 1.12.1 |
| Kotlin | 2.4.20 |
The tested versions are the ones in gradle/libs.versions.toml (last checked 2026-10-16); update this table when that file changes. If the app declares Navigation 3 itself, Gradle uses the higher of the two versions.
NavMetadataKey, the metadata { } builder, and SceneDecoratorStrategy, which the public API uses. Navigation 3 1.2.0 and 1.3.0-alpha02 keep the signatures of every function the library calls.rememberSceneState itself instead of calling NavDisplay. A Navigation 3 release that changes how scenes or overlays are computed can therefore change iOS behavior without a compile error, and the iOS code also relies on Compose Multiplatform's partly experimental UIKit interop. After moving to a version outside the table, run the UI tests described in CONTRIBUTING.md.entries form, because NavEntry does not expose its key. Apps that build entries from several back stacks use the NativeTab overload.if (backStack.first() in tabKeys) NativeTabDisplay(...) else NativeNavDisplay(...).NavDisplay behavior.sample/: runnable single-stack and tab samples for iOS and desktop.Copyright 2026 Seungmin Yang
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Native UIKit navigation for Compose Multiplatform apps built on Navigation 3.
Rename NavDisplay to NativeNavDisplay and your iOS screens run in a real UINavigationController, with system swipe-back, large titles, and sheets. NativeTabDisplay adds a native UITabBarController. On Android and desktop, both fall back to NavDisplay.
- NavDisplay(
+ NativeNavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider { /* unchanged */ },
)Install · Quick start · Tabs · Compatibility · Limitations · How it works
NavDisplay. NativeNavDisplay has both overloads (backStack and entries) and every parameter of NavDisplay. Decorators, sceneStrategies, DialogSceneStrategy, and back handlers keep working.ComposeUIViewController in a UINavigationController, so the back button, edge swipe-back, and the back button's history menu are UIKit's own.NativeTabDisplay shows a UITabBarController with a navigation stack per tab. It accepts either the single back stack of Navigation 3's commonui recipe or one back stack per tab.onBack. If the app refuses a pop, the screen is put back.The library is on Maven Central. Depend on it from commonMain:
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.seungmin.nav3:nav3-uikit:0.1.0-dev01")
}
}
}One coordinate serves Android, JVM, iosArm64, and iosSimulatorArm64. The library exposes Navigation 3 (org.jetbrains.androidx.navigation3:navigation3-ui) as an api dependency, so the app does not have to declare it. The tab display uses UITab and requires iOS 18 or later.
NativeNavigationViewController. Apps that use NativeTabDisplay need it for the tab bar to appear on an open iPhone Duo.// iosMain
fun MainViewController(): UIViewController = NativeNavigationViewController { App() }NavDisplay with NativeNavDisplay, and add NativeNavigation metadata to the entries that should use the native navigation bar or a sheet.NativeNavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider {
entry<Home>(metadata = NativeNavigation.title("Home", large = true)) { HomeScreen() }
entry<Detail>(metadata = { NativeNavigation.title("Detail ${it.id}") }) { DetailScreen(it.id) }
entry<Settings>(metadata = NativeNavigation.sheet(listOf(SheetDetent.Medium, SheetDetent.Large))) { SettingsSheet() }
entry<Immersive>(metadata = NativeNavigation.navigationBarHidden()) { ImmersiveScreen() }
entry<ConfirmDelete>(metadata = DialogSceneStrategy.dialog()) { ConfirmDeleteDialog() }
},
sceneStrategies = listOf(DialogSceneStrategy()),
)Entries without NativeNavigation metadata draw their own top bar, exactly as under NavDisplay.
| Metadata | Effect on iOS |
|---|---|
NativeNavigation.title(text, large) |
Shows the native navigation bar with this title, optionally as a large title |
NativeNavigation.sheet(detents) |
Presents the entry as a sheet with SheetDetent.Medium and/or SheetDetent.Large
|
NativeNavigation.navigationBarHidden() |
Hides the navigation bar for this entry |
NativeNavigation.tabBarHidden() |
Hides the tab bar while this entry is pushed above its tab's root |
NativeNavigationBarItems sets the native bar's title and trailing buttons from inside the screen. LocalNativeNavigationBar is true where the native bar is available, so one screen can serve every platform:
if (LocalNativeNavigationBar.current) {
NativeNavigationBarItems(
title = note.title,
trailing = listOf(NativeBarButton("Edit", systemImage = "pencil") { onEdit() }),
)
} else {
TopAppBar(title = { Text(note.title) }, actions = { /* ... */ })
}NavDisplay has no tabs; an app's tab bar is its own UI next to it. To get a native tab bar, replace the app's tab bar and NavDisplay with one NativeTabDisplay call. Choose the overload that matches how the app already stores its navigation state.
One back stack holding every tab, as the TopLevelBackStack of Navigation 3's commonui recipe does. The selected tab is read from the back stack.
// e.g. [Home, Detail(1), Search, Detail(2)]
val topLevelBackStack = remember { TopLevelBackStack<NavKey>(Home) }
NativeTabDisplay(
backStack = topLevelBackStack.backStack,
tabs = listOf(
NativeTabRoot(Home, "Home", icon = "house"), // SF Symbol name
NativeTabRoot(Search, "Search", icon = painterResource(Res.drawable.search)), // tinted as a template image
),
onTabSelected = { topLevelBackStack.addTopLevel(it) },
onBack = { topLevelBackStack.removeLast() },
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = entryProvider { /* entries for every tab */ },
)One back stack per tab, as Navigation 3's multiplestacks recipe does. The app owns the selection.
val home = rememberNavBackStack(config, Home)
val search = rememberNavBackStack(config, SearchHome)
var selected by rememberSaveable { mutableIntStateOf(0) }
NativeTabDisplay(
tabs = listOf(
NativeTab(home, "Home", icon = "house"),
NativeTab(search, "Search", icon = painterResource(Res.drawable.search)),
),
selectedTab = selected,
onTabSelected = { selected = it },
onBack = { tab -> listOf(home, search)[tab].removeLastOrNull() },
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = entryProvider { /* entries for every tab */ },
)With one back stack per tab, every tab keeps its rememberSaveable state and ViewModels while it is hidden. An app that wants to keep its Compose tab bar can use NativeNavDisplay alone; the tab bar then stays a Compose view and does not move into the iPhone Duo vertical bar.
NativeNavDisplay passes every parameter to NavDisplay. NativeTabDisplay shows a NavDisplay above its tabBar slot, which defaults to a plain row of tab icons and titles; pass the app's own tab bar there. Back behaves as before: the single back stack overload follows the back stack exactly, and the per-tab overload returns to the first tab from the root of any other tab.
| Dependency | Tested with |
|---|---|
Navigation 3 (org.jetbrains.androidx.navigation3:navigation3-ui) |
1.1.2 |
| Compose Multiplatform | 1.12.1 |
| Kotlin | 2.4.20 |
The tested versions are the ones in gradle/libs.versions.toml (last checked 2026-10-16); update this table when that file changes. If the app declares Navigation 3 itself, Gradle uses the higher of the two versions.
NavMetadataKey, the metadata { } builder, and SceneDecoratorStrategy, which the public API uses. Navigation 3 1.2.0 and 1.3.0-alpha02 keep the signatures of every function the library calls.rememberSceneState itself instead of calling NavDisplay. A Navigation 3 release that changes how scenes or overlays are computed can therefore change iOS behavior without a compile error, and the iOS code also relies on Compose Multiplatform's partly experimental UIKit interop. After moving to a version outside the table, run the UI tests described in CONTRIBUTING.md.entries form, because NavEntry does not expose its key. Apps that build entries from several back stacks use the NativeTab overload.if (backStack.first() in tabKeys) NativeTabDisplay(...) else NativeNavDisplay(...).NavDisplay behavior.sample/: runnable single-stack and tab samples for iOS and desktop.Copyright 2026 Seungmin Yang
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.