From 9519a1bd169e45d04561e37f342301abf7bda166 Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Mon, 30 Mar 2026 21:54:04 -0700 Subject: [PATCH 1/2] Upgrade to Navigation 3 This removes `BackHandler`, fixing predictive back. Fixes #292 and #370 --- Sources/SkipUI/Skip/skip.yml | 13 +- .../SkipUI/SkipUI/Containers/Navigation.swift | 244 ++++++++++-------- .../SkipUI/SkipUI/Containers/TabView.swift | 145 ++++++----- 3 files changed, 230 insertions(+), 172 deletions(-) diff --git a/Sources/SkipUI/Skip/skip.yml b/Sources/SkipUI/Skip/skip.yml index b47c1a60..edef481d 100644 --- a/Sources/SkipUI/Skip/skip.yml +++ b/Sources/SkipUI/Skip/skip.yml @@ -13,7 +13,8 @@ settings: - block: 'create("libs")' contents: - 'version("coil", "3.4.0")' - - 'version("androidx-navigation", "2.9.7")' + - 'version("androidx-nav3", "1.0.1")' + - 'version("kotlinx-serialization-json", "1.8.1")' - 'version("androidx-appcompat", "1.7.1")' - 'version("androidx-activity", "1.13.0")' - 'version("androidx-lifecycle-process", "2.10.0")' @@ -32,7 +33,10 @@ settings: - 'library("androidx-appcompat", "androidx.appcompat", "appcompat").versionRef("androidx-appcompat")' - 'library("androidx-appcompat-resources", "androidx.appcompat", "appcompat-resources").versionRef("androidx-appcompat")' - - 'library("androidx-navigation-compose", "androidx.navigation", "navigation-compose").versionRef("androidx-navigation")' + - 'library("androidx-navigation3-runtime", "androidx.navigation3", "navigation3-runtime").versionRef("androidx-nav3")' + - 'library("androidx-navigation3-ui", "androidx.navigation3", "navigation3-ui").versionRef("androidx-nav3")' + - 'library("kotlinx-serialization-json", "org.jetbrains.kotlinx", "kotlinx-serialization-json").versionRef("kotlinx-serialization-json")' + - 'plugin("kotlin-serialization", "org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin")' - 'library("androidx-activity-compose", "androidx.activity", "activity-compose").versionRef("androidx-activity")' - 'library("androidx-lifecycle-process", "androidx.lifecycle", "lifecycle-process").versionRef("androidx-lifecycle-process")' - 'library("androidx-compose-material3-adaptive", "androidx.compose.material3.adaptive", "adaptive").versionRef("androidx-material3-adaptive")' @@ -54,6 +58,7 @@ build: - block: 'plugins' contents: - 'alias(libs.plugins.kotlin.compose)' + - 'alias(libs.plugins.kotlin.serialization)' - block: 'android' contents: @@ -73,7 +78,9 @@ build: - 'api(libs.androidx.compose.material3)' - 'api(libs.androidx.compose.material3.adaptive)' - 'api(libs.androidx.compose.foundation)' - - 'api(libs.androidx.navigation.compose)' + - 'api(libs.androidx.navigation3.runtime)' + - 'api(libs.androidx.navigation3.ui)' + - 'api(libs.kotlinx.serialization.json)' - 'api(libs.androidx.appcompat)' - 'api(libs.androidx.appcompat.resources)' - 'api(libs.androidx.activity.compose)' diff --git a/Sources/SkipUI/SkipUI/Containers/Navigation.swift b/Sources/SkipUI/SkipUI/Containers/Navigation.swift index 7078a1aa..ae93e260 100644 --- a/Sources/SkipUI/SkipUI/Containers/Navigation.swift +++ b/Sources/SkipUI/SkipUI/Containers/Navigation.swift @@ -54,7 +54,6 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -77,14 +76,15 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavHostController -import androidx.navigation.NavType -import androidx.navigation.navArgument -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay import kotlin.reflect.full.superclasses +import kotlinx.serialization.Serializable +import androidx.compose.runtime.key import kotlinx.coroutines.delay #endif @@ -136,25 +136,23 @@ public struct NavigationStack : View, Renderable { // Make this collector non-erasable so that destinations defined at e.g. the root nav stack layer don't disappear when you push let destinationsCollector = PreferenceCollector(key: NavigationDestinationsPreferenceKey.self, state: destinations, isErasable: false) let reducedDestinations = destinations.value.reduced - let navController = rememberNavController() - let navigator = rememberSaveable(stateSaver: context.stateSaver as! Saver) { mutableStateOf(Navigator(navController: navController, destinations: reducedDestinations, destinationKeyTransformer: destinationKeyTransformer)) } - navigator.value.didCompose(navController: navController, destinations: reducedDestinations, path: path, navigationPath: navigationPath, keyboardController: LocalSoftwareKeyboardController.current) + let navBackStack = rememberNavBackStack(SkipNavigationStackRootKey.root) + let navigator = rememberSaveable(stateSaver: context.stateSaver as! Saver) { mutableStateOf(Navigator(navBackStack: navBackStack, destinations: reducedDestinations, destinationKeyTransformer: destinationKeyTransformer)) } + navigator.value.didCompose(navBackStack: navBackStack, destinations: reducedDestinations, path: path, navigationPath: navigationPath, keyboardController: LocalSoftwareKeyboardController.current) // SKIP INSERT: val providedNavigator = LocalNavigator provides navigator.value CompositionLocalProvider(providedNavigator) { let safeArea = EnvironmentValues.shared._safeArea - // We have to ignore the safe area around the entire NavHost to prevent push/pop animation issues with the system bars. + // We have to ignore the safe area around the entire NavDisplay to prevent push/pop animation issues with the system bars. // When we layout, only extend into safe areas that are due to system bars, not into any app chrome var ignoresSafeAreaEdges: Edge.Set = [.top, .bottom] ignoresSafeAreaEdges.formIntersection(safeArea?.absoluteSystemBarEdges ?? []) IgnoresSafeAreaLayout(expandInto: ignoresSafeAreaEdges) { _, _ in ComposeContainer(modifier: context.modifier, fillWidth: true, fillHeight: true) { modifier in - let isRTL = EnvironmentValues.shared.layoutDirection == LayoutDirection.rightToLeft - NavHost(navController: navController, startDestination: Navigator.rootRoute, modifier: modifier) { - composable(route: Navigator.rootRoute, - exitTransition: { fadeOut(animationSpec: tween(durationMillis: 200)) + slideOutHorizontally(targetOffsetX: { $0 * (isRTL ? 1 : -1) / 3 }) }, - popEnterTransition: { fadeIn() + slideInHorizontally(initialOffsetX: { $0 * (isRTL ? 1 : -1) / 3 }) }) { entry in - guard let state = navigator.value.state(for: entry) else { + let decoratorList = listOf(rememberSaveableStateHolderNavEntryDecorator()) + let entryProvider = entryProvider { + entry { _ in + guard let state = navigator.value.stateForRoot() else { return } // These preferences are per-entry, but if we put them in RenderEntry then their initial values don't show @@ -172,39 +170,39 @@ public struct NavigationStack : View, Renderable { } } } - for destinationIndex in 0.., Any>) { mutableStateOf(Preference(key: NavigationTitlePreferenceKey.self)) } - let titleCollector = PreferenceCollector(key: NavigationTitlePreferenceKey.self, state: title) - let toolbarPreferences = rememberSaveable(stateSaver: state.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: ToolbarPreferenceKey.self)) } - let toolbarPreferencesCollector = PreferenceCollector(key: ToolbarPreferenceKey.self, state: toolbarPreferences) - let toolbarContentPreferences = rememberSaveable(stateSaver: state.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: ToolbarContentPreferenceKey.self)) } - let toolbarContentPreferencesCollector = PreferenceCollector(key: ToolbarContentPreferenceKey.self, state: toolbarContentPreferences) - EnvironmentValues.shared.setValues { - $0.setdismiss(DismissAction(action: { navigator.value.navigateBack() })) - return ComposeResult.ok - } in: { - let arguments = NavigationEntryArguments(isRoot: false, state: state, safeArea: safeArea, ignoresSafeAreaEdges: ignoresSafeAreaEdges, title: title.value.reduced, toolbarPreferences: toolbarPreferences.value.reduced) - PreferenceValues.shared.collectPreferences([titleCollector, toolbarPreferencesCollector, toolbarContentPreferencesCollector, destinationsCollector]) { - RenderEntry(navigator: navigator, toolbarContent: toolbarContentPreferences, arguments: arguments, context: context) { context in - let destinationArguments = NavigationDestinationArguments(targetValue: targetValue) - RenderDestination(state.destination, arguments: destinationArguments, context: context) - } + entry { navKey in + guard let state = navigator.value.state(forPushKey: navKey), let targetValue = state.targetValue else { + return + } + // These preferences are per-entry, but if we put them in RenderEntry then their initial values don't show + // during the navigation animation. We have to collect them here + let title = rememberSaveable(stateSaver: state.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: NavigationTitlePreferenceKey.self)) } + let titleCollector = PreferenceCollector(key: NavigationTitlePreferenceKey.self, state: title) + let toolbarPreferences = rememberSaveable(stateSaver: state.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: ToolbarPreferenceKey.self)) } + let toolbarPreferencesCollector = PreferenceCollector(key: ToolbarPreferenceKey.self, state: toolbarPreferences) + let toolbarContentPreferences = rememberSaveable(stateSaver: state.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: ToolbarContentPreferenceKey.self)) } + let toolbarContentPreferencesCollector = PreferenceCollector(key: ToolbarContentPreferenceKey.self, state: toolbarContentPreferences) + EnvironmentValues.shared.setValues { + $0.setdismiss(DismissAction(action: { navigator.value.navigateBack() })) + return ComposeResult.ok + } in: { + let arguments = NavigationEntryArguments(isRoot: false, state: state, safeArea: safeArea, ignoresSafeAreaEdges: ignoresSafeAreaEdges, title: title.value.reduced, toolbarPreferences: toolbarPreferences.value.reduced) + PreferenceValues.shared.collectPreferences([titleCollector, toolbarPreferencesCollector, toolbarContentPreferencesCollector, destinationsCollector]) { + RenderEntry(navigator: navigator, toolbarContent: toolbarContentPreferences, arguments: arguments, context: context) { context in + let destinationArguments = NavigationDestinationArguments(targetValue: targetValue) + RenderDestination(state.destination, arguments: destinationArguments, context: context) } } } } } + NavDisplay( + backStack: navBackStack, + modifier: modifier, + onBack: { navigator.value.navigateBack() }, + entryDecorators: decoratorList, + entryProvider: entryProvider + ) } } } @@ -258,13 +256,6 @@ public struct NavigationStack : View, Renderable { } modifier = modifier.then(context.modifier) - // Intercept system back button to keep our state in sync - BackHandler(enabled: !arguments.isRoot) { - if arguments.toolbarPreferences.backButtonHidden != true { - navigator.value.navigateBack() - } - } - let defaultTopBarHeight = 112.dp let topBarBottomPx = remember { // Default our initial value to the expected value, which helps avoid visual artifacts as we measure actual values and @@ -670,6 +661,18 @@ public struct NavigationStack : View, Renderable { } #if SKIP + +// SKIP INSERT: @Serializable +public enum SkipNavigationStackRootKey : NavKey { + case root +} + +// SKIP INSERT: @Serializable +public struct SkipNavigationStackPushKey : NavKey { + public let destinationIndex: Int + public let identifier: String +} + @Stable struct NavigationEntryArguments: Equatable { let isRoot: Bool let state: Navigator.BackStackState @@ -708,7 +711,7 @@ struct NavigationDestination { return String(describing: destinationIndex) + "/" + valueString } - private var navController: NavHostController + private var navBackStack: NavBackStack private var keyboardController: SoftwareKeyboardController? private var destinations: NavigationDestinations private var destinationIndexes: [AnyHashable: Int] = [:] @@ -741,16 +744,16 @@ struct NavigationDestination { } } - init(navController: NavHostController, destinations: NavigationDestinations, destinationKeyTransformer: ((Any) -> String)?) { - self.navController = navController + init(navBackStack: NavBackStack, destinations: NavigationDestinations, destinationKeyTransformer: ((Any) -> String)?) { + self.navBackStack = navBackStack self.destinations = destinations self.destinationKeyTransformer = destinationKeyTransformer updateDestinationIndexes() } /// Call with updated state on recompose. - @Composable func didCompose(navController: NavHostController, destinations: NavigationDestinations, path: Binding<[Any]>?, navigationPath: Binding?, keyboardController: SoftwareKeyboardController?) { - self.navController = navController + @Composable func didCompose(navBackStack: NavBackStack, destinations: NavigationDestinations, path: Binding<[Any]>?, navigationPath: Binding?, keyboardController: SoftwareKeyboardController?) { + self.navBackStack = navBackStack self.destinations = destinations self.path = path self.navigationPath = navigationPath @@ -762,7 +765,7 @@ struct NavigationDestination { /// Whether we're at the root of the navigation stack. var isRoot: Bool { - return navController.currentBackStack.value.size <= 2 // graph entry, root entry + return navBackStack.size <= 1 } /// Navigate to a target value specified in a `NavigationLink`. @@ -802,44 +805,53 @@ struct NavigationDestination { func navigateBack() { // Check for a view destination before we pop our path bindings, because the user could push arbitrary views // that are not represented in the bound path - let viewDestinationPrefix = Self.route(for: viewDestinationIndex, valueString: "") - if navController.currentBackStackEntry?.destination.route?.hasPrefix(viewDestinationPrefix) == true { - navController.popBackStack() + if let lastKey = navBackStack.lastOrNull() as? SkipNavigationStackPushKey, lastKey.destinationIndex == viewDestinationIndex { + navBackStack.removeLastOrNull() } else if let path { path.wrappedValue.popLast() } else if let navigationPath { navigationPath.wrappedValue.removeLast() } else if !isRoot { - navController.popBackStack() + navBackStack.removeLastOrNull() } } /// Whether the given view entry ID is presented. func isViewPresented(id: String, asTop: Bool = false) -> Bool { - let stack = navController.currentBackStack.value - guard !stack.isEmpty() else { + guard !navBackStack.isEmpty() else { return false } guard !asTop else { - return stack.last().id == id + return stableEntryId(forKey: navBackStack[navBackStack.size - 1]) == id + } + for key in navBackStack { + if stableEntryId(forKey: key) == id { + return true + } } - return stack.any { $0.id == id } + return false } - /// The entry being navigated to. - func state(for entry: NavBackStackEntry) -> BackStackState? { - if let state = backStackState[entry.id] { + func stateForRoot() -> BackStackState? { + let rootId = stableEntryId(forKey: SkipNavigationStackRootKey.root) + if let state = backStackState[rootId] { return state } // Need to establish the root state? - guard navController.currentBackStack.value.count() > 1 && entry.id == navController.currentBackStack.value[1].id else { + guard navBackStack.size >= 1, navBackStack.firstOrNull() is SkipNavigationStackRootKey else { return nil } - let rootState = BackStackState(id: entry.id, route: Self.rootRoute) - backStackState[entry.id] = rootState + let rootState = BackStackState(id: rootId, route: Self.rootRoute) + backStackState[rootId] = rootState return rootState } + /// The entry being navigated to. + func state(forPushKey pushKey: SkipNavigationStackPushKey) -> BackStackState? { + let id = stableEntryId(forKey: pushKey) + return backStackState[id] + } + /// The effective title display mode for the given preference value. func titleDisplayMode(for state: BackStackState, hasTitle: Bool, preference: ToolbarTitleDisplayMode?) -> ToolbarTitleDisplayMode { if let preference { @@ -854,24 +866,34 @@ struct NavigationDestination { // Base the display mode on the back stack var titleDisplayMode: ToolbarTitleDisplayMode? = nil - for entry in navController.currentBackStack.value { - if entry.id == state.id { + for key in navBackStack { + let entryId = stableEntryId(forKey: key) + if entryId == state.id { break - } else if let entryTitleDisplayMode = backStackState[entry.id]?.titleDisplayMode { + } else if let entryTitleDisplayMode = backStackState[entryId]?.titleDisplayMode { titleDisplayMode = entryTitleDisplayMode } } return titleDisplayMode ?? ToolbarTitleDisplayMode.automatic } - /// Sync our back stack state with the nav controller. - @Composable private func syncState() { - // Collect as state to ensure we get re-called on change - let entryList = navController.currentBackStack.collectAsState() + private func stableEntryId(forKey key: NavKey) -> String { + if key is SkipNavigationStackRootKey { + return Self.rootRoute + } else if let pushKey = key as? SkipNavigationStackPushKey { + return Self.route(for: pushKey.destinationIndex, valueString: pushKey.identifier) + } else { + return String(describing: key) + } + } - // Toggle any presented bindings for popped states back to false. Do this immediately so that we don't + /// Sync our back stack state with the navigation back stack. + @Composable private func syncState() { + let stackKeys = navBackStack.toList() // re-present views that were removed from the stack - let entryIDs = Set(entryList.value.map { $0.id }) + let entryIDs = Set(stackKeys.map { stableEntryId(forKey: $0) }) + // Toggle any presented bindings for popped states back to false. Do this immediately so that we don't + // continue to present popped values while waiting for our delayed state sync below. for (id, state) in backStackState { if !entryIDs.contains(id) { state.binding?.wrappedValue = false @@ -880,12 +902,14 @@ struct NavigationDestination { // Sync the back stack with remaining states. We delay this to allow views that receive compose calls while // animating away to find their state - LaunchedEffect(entryList.value) { + let stackEffectKey = stackKeys.map { stableEntryId(forKey: $0) }.joinToString(separator: "|") + LaunchedEffect(stackEffectKey) { delay(1000) // 1 second var syncedBackStackState: [String: BackStackState] = [:] - for entry in entryList.value { - if let state = backStackState[entry.id] { - syncedBackStackState[entry.id] = state + for key in stackKeys { + let entryId = stableEntryId(forKey: key) + if let state = backStackState[entryId] { + syncedBackStackState[entryId] = state } } backStackState = syncedBackStackState @@ -896,19 +920,20 @@ struct NavigationDestination { guard let path = (self.path?.wrappedValue ?? navigationPath?.wrappedValue.path) else { return } - let backStack = navController.currentBackStack.value - guard !backStack.isEmpty() else { + let keys = navBackStack.toList() + guard !keys.isEmpty() else { return } // Figure out where the path and back stack first differ var pathIndex = 0 - var backStackIndex = 2 // graph, root + var backStackIndex = 1 // root key at 0 while pathIndex < path.count { - if backStackIndex >= backStack.count() { + if backStackIndex >= keys.size { break } - let state = backStackState[backStack[backStackIndex].id] + let kid = stableEntryId(forKey: keys[backStackIndex]) + let state = backStackState[kid] if state?.targetValue != path[pathIndex] { break } @@ -922,8 +947,9 @@ struct NavigationDestination { if pathIndex == path.count { hasOnlyTrailingViews = true let viewDestinationPrefix = Self.route(for: viewDestinationIndex, valueString: "") - for i in 0..<(backStack.count() - backStackIndex) { - if backStack[backStackIndex + i].destination.route?.hasPrefix(viewDestinationPrefix) != true { + for i in 0..<(keys.size - backStackIndex) { + let kid = stableEntryId(forKey: keys[backStackIndex + i]) + if !kid.hasPrefix(viewDestinationPrefix) { hasOnlyTrailingViews = false break } @@ -934,8 +960,8 @@ struct NavigationDestination { } // Pop back to last common value - for _ in 0..<(backStack.count() - backStackIndex) { - navController.popBackStack() + for _ in 0..<(keys.size - backStackIndex) { + navBackStack.removeLastOrNull() } // Navigate to any new path values for i in pathIndex.. any View)?, targetValue: Any, binding: Binding? = nil) -> String? { - // We see a top app bar glitch when the keyboard animates away after push, so manually dismiss it first - keyboardController?.hide() - navController.navigate(route) - guard let entry = navController.currentBackStackEntry else { + let slash = route.indexOf("/") + guard slash >= 0 else { + return nil + } + let indexStr = route.substring(0, slash) + let identifier = route.substring(slash + 1) + guard let destIndex = Int(string: indexStr) else { return nil } - var state = backStackState[entry.id] + let pushKey = SkipNavigationStackPushKey(destinationIndex: destIndex, identifier: identifier) + return navigate(pushKey: pushKey, route: route, destination: destination, targetValue: targetValue, binding: binding) + } + + private func navigate(pushKey: SkipNavigationStackPushKey, route: String, destination: ((Any) -> any View)?, targetValue: Any, binding: Binding? = nil) -> String? { + // We see a top app bar glitch when the keyboard animates away after push, so manually dismiss it first + keyboardController?.hide() + navBackStack.add(pushKey) + let entryId = stableEntryId(forKey: pushKey) + var state = backStackState[entryId] if state == nil { - state = BackStackState(id: entry.id, route: route, destination: destination, targetValue: targetValue) - backStackState[entry.id] = state + state = BackStackState(id: entryId, route: route, destination: destination, targetValue: targetValue) + backStackState[entryId] = state } if let binding { state?.binding = binding } - return entry.id + return entryId } private func route(for key: AnyHashable, value: Any) -> String { diff --git a/Sources/SkipUI/SkipUI/Containers/TabView.swift b/Sources/SkipUI/SkipUI/Containers/TabView.swift index 52b3d6c0..cfa57911 100644 --- a/Sources/SkipUI/SkipUI/Containers/TabView.swift +++ b/Sources/SkipUI/SkipUI/Containers/TabView.swift @@ -41,6 +41,7 @@ import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -57,13 +58,15 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable #endif // SKIP @bridge @@ -220,9 +223,10 @@ public struct TabView : View, Renderable { } } - let navController = rememberNavController() + let tabBackStacks = rememberSkipTabViewBackStacks() + let selectedTabIndex = rememberSaveable(stateSaver: context.stateSaver as! Saver) { mutableStateOf(0) } // Isolate access to current route within child Composable so route nav does not force us to recompose - navigateToCurrentRoute(controller: navController, tabRenderables: tabRenderables) + navigateToCurrentRoute(tabBackStacks: tabBackStacks, selectedTabIndex: selectedTabIndex, tabRenderables: tabRenderables) let tabBarPreferences = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: TabBarPreferenceKey.self)) } let tabBarPreferencesCollector = PreferenceCollector(key: TabBarPreferenceKey.self, state: tabBarPreferences) @@ -310,7 +314,7 @@ public struct TabView : View, Renderable { } } - let currentRoute = currentRoute(for: navController) // Note: forces recompose of this context on tab navigation + let currentRoute = String(describing: selectedTabIndex.value) // Note: forces recompose of this context on tab navigation // Pull the tab bar below the keyboard let bottomPadding = with(density) { min(bottomBarHeightPx.value, Float(WindowInsets.ime.getBottom(density))).toDp() } PaddingLayout(padding: EdgeInsets(top: 0.0, leading: 0.0, bottom: Double(-bottomPadding.value), trailing: 0.0), context: context.content()) { context in @@ -321,7 +325,7 @@ public struct TabView : View, Renderable { if let selection, let tagValue = tagValue(route: route, in: tabRenderables) { selection.wrappedValue = tagValue } else { - navigate(controller: navController, route: route) + selectedTabIndex.value = tabIndex } } let itemIcon: @Composable (Int) -> Void = { tabIndex in @@ -373,41 +377,49 @@ public struct TabView : View, Renderable { ComposeContainer(modifier: context.modifier, fillWidth: true, fillHeight: true) { modifier in // Don't use a Scaffold: it clips content beyond its bounds and prevents .ignoresSafeArea modifiers from working Column(modifier: modifier.background(Color.background.colorImpl())) { - NavHost(navController, - modifier: Modifier.fillMaxWidth().weight(Float(1.0)), - startDestination: "0", - enterTransition: { fadeIn() }, - exitTransition: { fadeOut() }) { - // Use a constant number of routes. Changing routes causes a NavHost to reset its state - let entryContext = context.content() - for tabIndex in 0..<100 { - composable(String(describing: tabIndex)) { _ in - // Inset manually where our container ignored the safe area, but we aren't showing a bar - let topPadding = ignoresSafeAreaEdges.contains(.top) ? WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() : 0.dp - var bottomPadding = 0.dp - if bottomBarTopPx.value <= Float(0.0) && ignoresSafeAreaEdges.contains(.bottom) { - bottomPadding = max(0.dp, WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() - WindowInsets.ime.asPaddingValues().calculateBottomPadding()) - } - let contentModifier = Modifier.fillMaxSize().padding(top: topPadding, bottom: bottomPadding) - let contentSafeArea = safeArea?.insetting(.bottom, to: bottomBarTopPx.value) - - // Special-case the first composition to avoid seeing the layout adjust. This is a common - // issue with nav stacks in particular, and they're common enough that we need to cater to them. - // Use an extra container to avoid causing the content itself to recompose - let hasComposed = remember { mutableStateOf(false) } - SideEffect { hasComposed.value = true } - let alpha = hasComposed.value ? Float(1.0) : Float(0.0) - Box(modifier: Modifier.alpha(alpha), contentAlignment: androidx.compose.ui.Alignment.Center) { - // This block is called multiple times on tab switch. Use stable arguments that will prevent our entry from - // recomposing when called with the same values - let arguments = TabEntryArguments(tabIndex: tabIndex, modifier: contentModifier, safeArea: contentSafeArea) - PreferenceValues.shared.collectPreferences([tabBarPreferencesCollector]) { - RenderEntry(with: arguments, context: entryContext) - } + let entryContext = context.content() + let activeStack = tabBackStacks[selectedTabIndex.value] + let tabDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator()) + let tabEntryProvider: (NavKey) -> NavEntry = { key in + let tabKey = key as! SkipTabViewRouteKey + return NavEntry(tabKey, content: { key in + let tabIndex = (key as! SkipTabViewRouteKey).index + // Inset manually where our container ignored the safe area, but we aren't showing a bar + let topPadding = ignoresSafeAreaEdges.contains(.top) ? WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() : 0.dp + var bottomPadding = 0.dp + if bottomBarTopPx.value <= Float(0.0) && ignoresSafeAreaEdges.contains(.bottom) { + bottomPadding = max(0.dp, WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() - WindowInsets.ime.asPaddingValues().calculateBottomPadding()) + } + let contentModifier = Modifier.fillMaxSize().padding(top: topPadding, bottom: bottomPadding) + let contentSafeArea = safeArea?.insetting(.bottom, to: bottomBarTopPx.value) + + // Special-case the first composition to avoid seeing the layout adjust. This is a common + // issue with nav stacks in particular, and they're common enough that we need to cater to them. + // Use an extra container to avoid causing the content itself to recompose + let hasComposed = remember { mutableStateOf(false) } + SideEffect { hasComposed.value = true } + let alpha = hasComposed.value ? Float(1.0) : Float(0.0) + Box(modifier: Modifier.alpha(alpha), contentAlignment: androidx.compose.ui.Alignment.Center) { + // This block is called multiple times on tab switch. Use stable arguments that will prevent our entry from + // recomposing when called with the same values + let arguments = TabEntryArguments(tabIndex: tabIndex, modifier: contentModifier, safeArea: contentSafeArea) + PreferenceValues.shared.collectPreferences([tabBarPreferencesCollector]) { + RenderEntry(with: arguments, context: entryContext) } } - } + }) } + NavDisplay( + backStack: activeStack, + modifier: Modifier.fillMaxWidth().weight(Float(1.0)), + onBack: { + if activeStack.size > 1 { + activeStack.removeLastOrNull() + } + }, + entryDecorators: tabDecorators, + entryProvider: tabEntryProvider + ) bottomBar() } } @@ -474,34 +486,14 @@ public struct TabView : View, Renderable { } } - private func navigate(controller navController: NavHostController, route: String) { - navController.navigate(route) { - // Clear back stack so that tabs don't participate in Android system back button - let destinationID = navController.currentBackStackEntry?.destination?.id ?? navController.graph.startDestinationId - popUpTo(destinationID) { - inclusive = true - saveState = true - } - // Avoid multiple copies of the same destination when reselecting the same item - launchSingleTop = true - // Restore state when reselecting a previously selected item - restoreState = true - } - } - - @Composable private func navigateToCurrentRoute(controller navController: NavHostController, tabRenderables: kotlin.collections.List) { - let currentRoute = currentRoute(for: navController) - if let selection, let currentRoute, selection.wrappedValue != tagValue(route: currentRoute, in: tabRenderables) { - if let route = route(tagValue: selection.wrappedValue, in: tabRenderables) { - navigate(controller: navController, route: route) + @Composable private func navigateToCurrentRoute(tabBackStacks: kotlin.collections.List>, selectedTabIndex: MutableState, tabRenderables: kotlin.collections.List) { + let currentRoute = String(describing: selectedTabIndex.value) + if let selection, selection.wrappedValue != tagValue(route: currentRoute, in: tabRenderables) { + if let route = route(tagValue: selection.wrappedValue, in: tabRenderables), let idx = Int(string: route) { + selectedTabIndex.value = idx } } } - - @Composable private func currentRoute(for navController: NavHostController) -> String? { - // In your BottomNavigation composable, get the current NavBackStackEntry using the currentBackStackEntryAsState() function. This entry gives you access to the current NavDestination. The selected state of each BottomNavigationItem can then be determined by comparing the item's route with the route of the current destination and its parent destinations (to handle cases when you are using nested navigation) via the NavDestination hierarchy. - navController.currentBackStackEntryAsState().value?.destination?.route - } #else public var body: some View { stubView() @@ -510,6 +502,27 @@ public struct TabView : View, Renderable { } #if SKIP +// SKIP INSERT: @Serializable +public struct SkipTabViewRouteKey : NavKey { + public let index: Int +} + +/// Fixed 100 persistent back stacks for tab bar content (matches legacy `0..<100` routes). +@Composable public func rememberSkipTabViewBackStacks() -> kotlin.collections.List> { + // Use a constant number of routes. Changing routes causes a NavHost to reset its state + // TODO is this necessary with NavDisplay? + let slots = remember { arrayOfNulls?>(100) } + var tabIndex = 0 + while tabIndex < 100 { + let ti = tabIndex + key(ti) { + slots[ti] = rememberNavBackStack(SkipTabViewRouteKey(index: ti)) + } + tabIndex += 1 + } + return slots.filterNotNull() +} + @Stable struct TabEntryArguments: Equatable { let tabIndex: Int let modifier: Modifier From 9c09c637ba4f653e6a77eef18d2dc0e560f46354 Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Wed, 1 Apr 2026 08:46:24 -0700 Subject: [PATCH 2/2] sidebar adaptable --- Sources/SkipUI/Skip/skip.yml | 2 + .../SkipUI/SkipUI/Containers/TabView.swift | 418 +++++++++++------- 2 files changed, 261 insertions(+), 159 deletions(-) diff --git a/Sources/SkipUI/Skip/skip.yml b/Sources/SkipUI/Skip/skip.yml index edef481d..291bd5b2 100644 --- a/Sources/SkipUI/Skip/skip.yml +++ b/Sources/SkipUI/Skip/skip.yml @@ -40,6 +40,7 @@ settings: - 'library("androidx-activity-compose", "androidx.activity", "activity-compose").versionRef("androidx-activity")' - 'library("androidx-lifecycle-process", "androidx.lifecycle", "lifecycle-process").versionRef("androidx-lifecycle-process")' - 'library("androidx-compose-material3-adaptive", "androidx.compose.material3.adaptive", "adaptive").versionRef("androidx-material3-adaptive")' + - 'library("androidx-compose-material3-adaptive-navigation-suite", "androidx.compose.material3", "material3-adaptive-navigation-suite").versionRef("androidx-material3-adaptive")' - 'library("androidx-work-runtime", "androidx.work", "work-runtime-ktx").versionRef("androidx-work")' @@ -77,6 +78,7 @@ build: - 'api(libs.androidx.compose.material.icons.extended)' - 'api(libs.androidx.compose.material3)' - 'api(libs.androidx.compose.material3.adaptive)' + - 'api(libs.androidx.compose.material3.adaptive.navigation.suite)' - 'api(libs.androidx.compose.foundation)' - 'api(libs.androidx.navigation3.runtime)' - 'api(libs.androidx.navigation3.ui)' diff --git a/Sources/SkipUI/SkipUI/Containers/TabView.swift b/Sources/SkipUI/SkipUI/Containers/TabView.swift index cfa57911..5c0d3f9a 100644 --- a/Sources/SkipUI/SkipUI/Containers/TabView.swift +++ b/Sources/SkipUI/SkipUI/Containers/TabView.swift @@ -11,15 +11,17 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight @@ -34,7 +36,15 @@ import androidx.compose.material3.NavigationBarDefaults import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemColors import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.NavigationRailItemDefaults import androidx.compose.material3.contentColorFor +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldLayout +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType +import androidx.compose.material3.adaptive.navigationsuite.rememberNavigationSuiteScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.SideEffect @@ -244,129 +254,11 @@ public struct TabView : View, Renderable { } } let bottomBarHeightPx = remember { mutableStateOf(with(density) { defaultBottomBarHeight.toPx() }) } + let tabNavLeadingEndPx = remember { mutableStateOf(Float(0.0)) } // Reduce the tab bar preferences outside the bar composable. Otherwise the reduced value may change // when the bottom bar recomposes let reducedTabBarPreferences = tabBarPreferences.value.reduced - let bottomBar: @Composable () -> Void = { - guard tabs.any({ $0 != nil }) && reducedTabBarPreferences.visibility != Visibility.hidden else { - SideEffect { - bottomBarTopPx.value = Float(0.0) - bottomBarHeightPx.value = Float(0.0) - } - return - } - var tabBarModifier = Modifier.fillMaxWidth() - .onGloballyPositionedInWindow { bounds in - bottomBarTopPx.value = bounds.top - bottomBarHeightPx.value = bounds.bottom - bounds.top - } - let tint = EnvironmentValues.shared._tint - let hasColorScheme = reducedTabBarPreferences.colorScheme != nil - let isSystemBackground = reducedTabBarPreferences.isSystemBackground == true - let showScrolledBackground = reducedTabBarPreferences.backgroundVisibility == Visibility.visible || reducedTabBarPreferences.scrollableState?.canScrollForward == true - let materialColorScheme: androidx.compose.material3.ColorScheme - if showScrolledBackground, let customColorScheme = reducedTabBarPreferences.colorScheme?.asMaterialTheme() { - materialColorScheme = customColorScheme - } else { - materialColorScheme = MaterialTheme.colorScheme - } - MaterialTheme(colorScheme: materialColorScheme) { - let indicatorColor: androidx.compose.ui.graphics.Color - if let tint { - indicatorColor = tint.asComposeColor().copy(alpha: Float(0.35)) - } else { - indicatorColor = ColorScheme.fromMaterialTheme(colorScheme: materialColorScheme) == ColorScheme.dark ? androidx.compose.ui.graphics.Color.White.copy(alpha: Float(0.1)) : androidx.compose.ui.graphics.Color.Black.copy(alpha: Float(0.1)) - } - let tabBarBackgroundColor: androidx.compose.ui.graphics.Color - let unscrolledTabBarBackgroundColor: androidx.compose.ui.graphics.Color - let tabBarBackgroundForBrush: ShapeStyle? - let tabBarItemColors: NavigationBarItemColors - if reducedTabBarPreferences.backgroundVisibility == Visibility.hidden { - tabBarBackgroundColor = androidx.compose.ui.graphics.Color.Transparent - unscrolledTabBarBackgroundColor = androidx.compose.ui.graphics.Color.Transparent - tabBarBackgroundForBrush = nil - tabBarItemColors = NavigationBarItemDefaults.colors(indicatorColor: indicatorColor) - } else if let background = reducedTabBarPreferences.background { - if let color = background.asColor(opacity: 1.0, animationContext: nil) { - tabBarBackgroundColor = color - unscrolledTabBarBackgroundColor = isSystemBackground ? Color.systemBarBackground.colorImpl() : color.copy(alpha: Float(0.0)) - tabBarBackgroundForBrush = nil - } else { - unscrolledTabBarBackgroundColor = isSystemBackground ? Color.systemBarBackground.colorImpl() : androidx.compose.ui.graphics.Color.Transparent - tabBarBackgroundColor = unscrolledTabBarBackgroundColor.copy(alpha: Float(0.0)) - tabBarBackgroundForBrush = background - } - tabBarItemColors = NavigationBarItemDefaults.colors(indicatorColor: indicatorColor) - } else { - tabBarBackgroundColor = Color.systemBarBackground.colorImpl() - unscrolledTabBarBackgroundColor = isSystemBackground ? tabBarBackgroundColor : tabBarBackgroundColor.copy(alpha: Float(0.0)) - tabBarBackgroundForBrush = nil - if tint == nil { - tabBarItemColors = NavigationBarItemDefaults.colors() - } else { - tabBarItemColors = NavigationBarItemDefaults.colors(indicatorColor: indicatorColor) - } - } - if showScrolledBackground, let tabBarBackgroundForBrush { - if let tabBarBackgroundBrush = tabBarBackgroundForBrush.asBrush(opacity: 1.0, animationContext: nil) { - tabBarModifier = tabBarModifier.background(tabBarBackgroundBrush) - } - } - - let currentRoute = String(describing: selectedTabIndex.value) // Note: forces recompose of this context on tab navigation - // Pull the tab bar below the keyboard - let bottomPadding = with(density) { min(bottomBarHeightPx.value, Float(WindowInsets.ime.getBottom(density))).toDp() } - PaddingLayout(padding: EdgeInsets(top: 0.0, leading: 0.0, bottom: Double(-bottomPadding.value), trailing: 0.0), context: context.content()) { context in - let tabsState = rememberUpdatedState(tabs) - let containerColor = showScrolledBackground ? tabBarBackgroundColor : unscrolledTabBarBackgroundColor - let onItemClick: (Int) -> Void = { tabIndex in - let route = String(describing: tabIndex) - if let selection, let tagValue = tagValue(route: route, in: tabRenderables) { - selection.wrappedValue = tagValue - } else { - selectedTabIndex.value = tabIndex - } - } - let itemIcon: @Composable (Int) -> Void = { tabIndex in - let tab = tabsState.value[tabIndex] - tab?.RenderImage(context: tabContext) - } - let itemLabel: @Composable (Int) -> Void = { tabIndex in - let tab = tabsState.value[tabIndex] - tab?.RenderTitle(context: tabContext) - } - var options = Material3NavigationBarOptions(modifier: context.modifier.then(tabBarModifier), containerColor: containerColor, contentColor: MaterialTheme.colorScheme.contentColorFor(containerColor), onItemClick: onItemClick, itemIcon: itemIcon, itemLabel: itemLabel, itemColors: tabBarItemColors) - if let updateOptions = EnvironmentValues.shared._material3NavigationBar { - options = updateOptions(options) - } - NavigationBar(modifier: options.modifier, containerColor: options.containerColor, contentColor: options.contentColor, tonalElevation: options.tonalElevation) { - for tabIndex in 0.. Void)? - if let itemLabel = options.itemLabel { - label = { itemLabel(tabIndex) } - } else { - label = nil - } - NavigationBarItem(selected: route == currentRoute, - onClick: { options.onItemClick(tabIndex) }, - icon: { options.itemIcon(tabIndex) }, - modifier: options.itemModifier(tabIndex), - enabled: options.itemEnabled(tabIndex) && tabs[tabIndex]?.isDisabled != true, - label: label, - alwaysShowLabel: options.alwaysShowItemLabels, - colors: options.itemColors, - interactionSource: options.itemInteractionSource - ) - } - } - } - } - } // When we layout, extend into the safe area if it is due to system bars, not into any app chrome. We extend // into the top bar too so that tab content can also extend into the top area without getting cut off during @@ -376,51 +268,247 @@ public struct TabView : View, Renderable { IgnoresSafeAreaLayout(expandInto: ignoresSafeAreaEdges) { _, _ in ComposeContainer(modifier: context.modifier, fillWidth: true, fillHeight: true) { modifier in // Don't use a Scaffold: it clips content beyond its bounds and prevents .ignoresSafeArea modifiers from working - Column(modifier: modifier.background(Color.background.colorImpl())) { - let entryContext = context.content() - let activeStack = tabBackStacks[selectedTabIndex.value] - let tabDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator()) - let tabEntryProvider: (NavKey) -> NavEntry = { key in - let tabKey = key as! SkipTabViewRouteKey - return NavEntry(tabKey, content: { key in - let tabIndex = (key as! SkipTabViewRouteKey).index - // Inset manually where our container ignored the safe area, but we aren't showing a bar - let topPadding = ignoresSafeAreaEdges.contains(.top) ? WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() : 0.dp - var bottomPadding = 0.dp - if bottomBarTopPx.value <= Float(0.0) && ignoresSafeAreaEdges.contains(.bottom) { - bottomPadding = max(0.dp, WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() - WindowInsets.ime.asPaddingValues().calculateBottomPadding()) + Box(modifier: modifier.background(Color.background.colorImpl()).fillMaxSize()) { + let tabViewStyle = EnvironmentValues.shared._tabViewStyle + let layoutType: NavigationSuiteType + if tabViewStyle is TabBarOnlyTabViewStyle { + layoutType = NavigationSuiteType.NavigationBar + } else { + // .automatic and .sidebarAdaptable both use adaptive behavior. + layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(currentWindowAdaptiveInfo()) + } + let layoutTypeState = rememberUpdatedState(layoutType) + let navigationSuiteScaffoldState = rememberNavigationSuiteScaffoldState() + NavigationSuiteScaffoldLayout( + navigationSuite: { + guard tabs.any({ $0 != nil }) && reducedTabBarPreferences.visibility != Visibility.hidden else { + SideEffect { + bottomBarTopPx.value = Float(0.0) + bottomBarHeightPx.value = Float(0.0) + tabNavLeadingEndPx.value = Float(0.0) + } + return } - let contentModifier = Modifier.fillMaxSize().padding(top: topPadding, bottom: bottomPadding) - let contentSafeArea = safeArea?.insetting(.bottom, to: bottomBarTopPx.value) - - // Special-case the first composition to avoid seeing the layout adjust. This is a common - // issue with nav stacks in particular, and they're common enough that we need to cater to them. - // Use an extra container to avoid causing the content itself to recompose - let hasComposed = remember { mutableStateOf(false) } - SideEffect { hasComposed.value = true } - let alpha = hasComposed.value ? Float(1.0) : Float(0.0) - Box(modifier: Modifier.alpha(alpha), contentAlignment: androidx.compose.ui.Alignment.Center) { - // This block is called multiple times on tab switch. Use stable arguments that will prevent our entry from - // recomposing when called with the same values - let arguments = TabEntryArguments(tabIndex: tabIndex, modifier: contentModifier, safeArea: contentSafeArea) - PreferenceValues.shared.collectPreferences([tabBarPreferencesCollector]) { - RenderEntry(with: arguments, context: entryContext) + var tabBarModifier = (layoutType == NavigationSuiteType.NavigationBar ? Modifier.fillMaxWidth() : Modifier.fillMaxHeight().wrapContentWidth()) + .onGloballyPositionedInWindow { bounds in + let lt = layoutTypeState.value + if lt == NavigationSuiteType.NavigationBar { + bottomBarTopPx.value = bounds.top + bottomBarHeightPx.value = bounds.bottom - bounds.top + tabNavLeadingEndPx.value = Float(0.0) + } else if lt == NavigationSuiteType.NavigationRail { + bottomBarTopPx.value = Float(0.0) + bottomBarHeightPx.value = Float(0.0) + tabNavLeadingEndPx.value = bounds.right + } else { + bottomBarTopPx.value = Float(0.0) + bottomBarHeightPx.value = Float(0.0) + tabNavLeadingEndPx.value = Float(0.0) + } } + let tint = EnvironmentValues.shared._tint + let isSystemBackground = reducedTabBarPreferences.isSystemBackground == true + let showScrolledBackground = reducedTabBarPreferences.backgroundVisibility == Visibility.visible || reducedTabBarPreferences.scrollableState?.canScrollForward == true + let materialColorScheme: androidx.compose.material3.ColorScheme + if showScrolledBackground, let customColorScheme = reducedTabBarPreferences.colorScheme?.asMaterialTheme() { + materialColorScheme = customColorScheme + } else { + materialColorScheme = MaterialTheme.colorScheme } - }) - } - NavDisplay( - backStack: activeStack, - modifier: Modifier.fillMaxWidth().weight(Float(1.0)), - onBack: { - if activeStack.size > 1 { - activeStack.removeLastOrNull() + MaterialTheme(colorScheme: materialColorScheme) { + let indicatorColor: androidx.compose.ui.graphics.Color + if let tint { + indicatorColor = tint.asComposeColor().copy(alpha: Float(0.35)) + } else { + indicatorColor = ColorScheme.fromMaterialTheme(colorScheme: materialColorScheme) == ColorScheme.dark ? androidx.compose.ui.graphics.Color.White.copy(alpha: Float(0.1)) : androidx.compose.ui.graphics.Color.Black.copy(alpha: Float(0.1)) + } + let tabBarBackgroundColor: androidx.compose.ui.graphics.Color + let unscrolledTabBarBackgroundColor: androidx.compose.ui.graphics.Color + let tabBarBackgroundForBrush: ShapeStyle? + let tabBarItemColors: NavigationBarItemColors + if reducedTabBarPreferences.backgroundVisibility == Visibility.hidden { + tabBarBackgroundColor = androidx.compose.ui.graphics.Color.Transparent + unscrolledTabBarBackgroundColor = androidx.compose.ui.graphics.Color.Transparent + tabBarBackgroundForBrush = nil + tabBarItemColors = NavigationBarItemDefaults.colors(indicatorColor: indicatorColor) + } else if let background = reducedTabBarPreferences.background { + if let color = background.asColor(opacity: 1.0, animationContext: nil) { + tabBarBackgroundColor = color + unscrolledTabBarBackgroundColor = isSystemBackground ? Color.systemBarBackground.colorImpl() : color.copy(alpha: Float(0.0)) + tabBarBackgroundForBrush = nil + } else { + unscrolledTabBarBackgroundColor = isSystemBackground ? Color.systemBarBackground.colorImpl() : androidx.compose.ui.graphics.Color.Transparent + tabBarBackgroundColor = unscrolledTabBarBackgroundColor.copy(alpha: Float(0.0)) + tabBarBackgroundForBrush = background + } + tabBarItemColors = NavigationBarItemDefaults.colors(indicatorColor: indicatorColor) + } else { + tabBarBackgroundColor = Color.systemBarBackground.colorImpl() + unscrolledTabBarBackgroundColor = isSystemBackground ? tabBarBackgroundColor : tabBarBackgroundColor.copy(alpha: Float(0.0)) + tabBarBackgroundForBrush = nil + if tint == nil { + tabBarItemColors = NavigationBarItemDefaults.colors() + } else { + tabBarItemColors = NavigationBarItemDefaults.colors(indicatorColor: indicatorColor) + } + } + if showScrolledBackground, let tabBarBackgroundForBrush { + if let tabBarBackgroundBrush = tabBarBackgroundForBrush.asBrush(opacity: 1.0, animationContext: nil) { + tabBarModifier = tabBarModifier.background(tabBarBackgroundBrush) + } + } + + let currentRoute = String(describing: selectedTabIndex.value) // Note: forces recompose of this context on tab navigation + let bottomPadding: Dp + if layoutType == NavigationSuiteType.NavigationBar { + bottomPadding = with(density) { min(bottomBarHeightPx.value, Float(WindowInsets.ime.getBottom(density))).toDp() } + } else { + bottomPadding = 0.dp + } + PaddingLayout(padding: EdgeInsets(top: 0.0, leading: 0.0, bottom: Double(-bottomPadding.value), trailing: 0.0), context: context.content()) { context in + let tabsState = rememberUpdatedState(tabs) + let containerColor = showScrolledBackground ? tabBarBackgroundColor : unscrolledTabBarBackgroundColor + let onItemClick: (Int) -> Void = { tabIndex in + let route = String(describing: tabIndex) + if let selection, let tagValue = tagValue(route: route, in: tabRenderables) { + selection.wrappedValue = tagValue + } else { + selectedTabIndex.value = tabIndex + } + } + let itemIcon: @Composable (Int) -> Void = { tabIndex in + let tab = tabsState.value[tabIndex] + tab?.RenderImage(context: tabContext) + } + let itemLabel: @Composable (Int) -> Void = { tabIndex in + let tab = tabsState.value[tabIndex] + tab?.RenderTitle(context: tabContext) + } + var options = Material3NavigationBarOptions(modifier: context.modifier.then(tabBarModifier), containerColor: containerColor, contentColor: MaterialTheme.colorScheme.contentColorFor(containerColor), onItemClick: onItemClick, itemIcon: itemIcon, itemLabel: itemLabel, itemColors: tabBarItemColors) + if let updateOptions = EnvironmentValues.shared._material3NavigationBar { + options = updateOptions(options) + } + let railItemColors = NavigationRailItemDefaults.colors( + selectedIconColor: options.itemColors.selectedIconColor, + selectedTextColor: options.itemColors.selectedTextColor, + indicatorColor: options.itemColors.selectedIndicatorColor, + unselectedIconColor: options.itemColors.unselectedIconColor, + unselectedTextColor: options.itemColors.unselectedTextColor, + disabledIconColor: options.itemColors.disabledIconColor, + disabledTextColor: options.itemColors.disabledTextColor + ) + if layoutType == NavigationSuiteType.NavigationBar { + NavigationBar(modifier: options.modifier, containerColor: options.containerColor, contentColor: options.contentColor, tonalElevation: options.tonalElevation) { + for tabIndex in 0.. Void)? + if let itemLabel = options.itemLabel { + label = { itemLabel(tabIndex) } + } else { + label = nil + } + NavigationBarItem(selected: route == currentRoute, + onClick: { options.onItemClick(tabIndex) }, + icon: { options.itemIcon(tabIndex) }, + modifier: options.itemModifier(tabIndex), + enabled: options.itemEnabled(tabIndex) && tabs[tabIndex]?.isDisabled != true, + label: label, + alwaysShowLabel: options.alwaysShowItemLabels, + colors: options.itemColors, + interactionSource: options.itemInteractionSource + ) + } + } + } else { + NavigationRail(modifier: options.modifier, containerColor: options.containerColor, contentColor: options.contentColor) { + // Center the item group vertically (Material navigation rail guidance for tablets). + Spacer(modifier: Modifier.weight(Float(1.0))) + for tabIndex in 0.. Void)? + if let itemLabel = options.itemLabel { + label = { itemLabel(tabIndex) } + } else { + label = nil + } + NavigationRailItem(selected: route == currentRoute, + onClick: { options.onItemClick(tabIndex) }, + icon: { options.itemIcon(tabIndex) }, + modifier: options.itemModifier(tabIndex), + enabled: options.itemEnabled(tabIndex) && tabs[tabIndex]?.isDisabled != true, + label: label, + alwaysShowLabel: options.alwaysShowItemLabels, + colors: railItemColors, + interactionSource: options.itemInteractionSource + ) + } + Spacer(modifier: Modifier.weight(Float(1.0))) + } + } + } } }, - entryDecorators: tabDecorators, - entryProvider: tabEntryProvider + navigationSuiteType: layoutType, + state: navigationSuiteScaffoldState, + primaryActionContent: {}, + content: { + let entryContext = context.content() + let activeStack = tabBackStacks[selectedTabIndex.value] + let tabDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator()) + let tabEntryProvider: (NavKey) -> NavEntry = { key in + let tabKey = key as! SkipTabViewRouteKey + return NavEntry(tabKey, content: { key in + let tabIndex = (key as! SkipTabViewRouteKey).index + // Inset manually where our container ignored the safe area, but we aren't showing a bar + let topPadding = ignoresSafeAreaEdges.contains(.top) ? WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() : 0.dp + var bottomPadding = 0.dp + if bottomBarTopPx.value <= Float(0.0) && ignoresSafeAreaEdges.contains(.bottom) { + bottomPadding = max(0.dp, WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() - WindowInsets.ime.asPaddingValues().calculateBottomPadding()) + } + let contentModifier = Modifier.fillMaxSize().padding(top: topPadding, bottom: bottomPadding) + var contentSafeArea = safeArea + if bottomBarTopPx.value > Float(0.0) { + contentSafeArea = contentSafeArea?.insetting(.bottom, to: bottomBarTopPx.value) + } + if tabNavLeadingEndPx.value > Float(0.0) { + contentSafeArea = contentSafeArea?.insetting(.leading, to: tabNavLeadingEndPx.value) + } + + // Special-case the first composition to avoid seeing the layout adjust. This is a common + // issue with nav stacks in particular, and they're common enough that we need to cater to them. + // Use an extra container to avoid causing the content itself to recompose + let hasComposed = remember { mutableStateOf(false) } + SideEffect { hasComposed.value = true } + let alpha = hasComposed.value ? Float(1.0) : Float(0.0) + Box(modifier: Modifier.alpha(alpha), contentAlignment: androidx.compose.ui.Alignment.Center) { + // This block is called multiple times on tab switch. Use stable arguments that will prevent our entry from + // recomposing when called with the same values + let arguments = TabEntryArguments(tabIndex: tabIndex, modifier: contentModifier, safeArea: contentSafeArea) + PreferenceValues.shared.collectPreferences([tabBarPreferencesCollector]) { + RenderEntry(with: arguments, context: entryContext) + } + } + }) + } + NavDisplay( + backStack: activeStack, + modifier: Modifier.fillMaxSize(), + onBack: { + if activeStack.size > 1 { + activeStack.removeLastOrNull() + } + }, + entryDecorators: tabDecorators, + entryProvider: tabEntryProvider + ) + } ) - bottomBar() } } } @@ -571,6 +659,16 @@ extension TabViewStyle where Self == TabBarOnlyTabViewStyle { public static var tabBarOnly: TabBarOnlyTabViewStyle { TabBarOnlyTabViewStyle() } } +public struct SidebarAdaptableTabViewStyle: TabViewStyle { + static let identifier = 3 // For bridging + + public init() {} +} + +extension TabViewStyle where Self == SidebarAdaptableTabViewStyle { + public static var sidebarAdaptable: SidebarAdaptableTabViewStyle { SidebarAdaptableTabViewStyle() } +} + public struct PageTabViewStyle: TabViewStyle { static let identifier = 2 // For bridging @@ -1076,6 +1174,8 @@ extension View { switch bridgedStyle { case TabBarOnlyTabViewStyle.identifier: style = TabBarOnlyTabViewStyle() + case SidebarAdaptableTabViewStyle.identifier: + style = SidebarAdaptableTabViewStyle() case PageTabViewStyle.identifier: let indexDisplayMode: PageTabViewStyle.IndexDisplayMode = (bridgedDisplayMode == nil ? nil : PageTabViewStyle.IndexDisplayMode(rawValue: bridgedDisplayMode!)) ?? .automatic style = PageTabViewStyle(indexDisplayMode: indexDisplayMode)