diff --git a/Sources/SkipUI/SkipUI/Containers/Navigation.swift b/Sources/SkipUI/SkipUI/Containers/Navigation.swift index 9d287975..e95fc2f3 100644 --- a/Sources/SkipUI/SkipUI/Containers/Navigation.swift +++ b/Sources/SkipUI/SkipUI/Containers/Navigation.swift @@ -137,10 +137,14 @@ public struct NavigationStack : View, Renderable { #if SKIP @Composable public override func Render(context: ComposeContext) { - // Have to use rememberSaveable for e.g. a nav stack in each tab. Make the collectors non-erasable so that - // destinations defined at e.g. the root nav stack layer don't disappear when you push. - let (destinations, destinationsCollector) = rememberSaveablePreferenceCollector(key: NavigationDestinationsPreferenceKey.self, stateSaver: context.stateSaver as! Saver, Any>, isErasable: false) - let (destinationLayoutHints, destinationLayoutHintsCollector) = rememberSaveablePreferenceCollector(key: NavigationDestinationLayoutHintsPreferenceKey.self, stateSaver: context.stateSaver as! Saver, Any>, isErasable: false) + // Have to use rememberSaveable for e.g. a nav stack in each tab + let destinations = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: NavigationDestinationsPreferenceKey.self)) + } + // 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 destinationLayoutHints = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: NavigationDestinationLayoutHintsPreferenceKey.self)) + } + let destinationLayoutHintsCollector = PreferenceCollector(key: NavigationDestinationLayoutHintsPreferenceKey.self, state: destinationLayoutHints, isErasable: false) let reducedDestinations = destinations.value.reduced let reducedDestinationLayoutHints = destinationLayoutHints.value.reduced let mergedDestinations = mergeNavigationDestinationsWithLayoutHints(reducedDestinations, layoutHints: reducedDestinationLayoutHints) @@ -165,9 +169,12 @@ public struct NavigationStack : View, Renderable { } // 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, titleCollector) = rememberSaveablePreferenceCollector(key: NavigationTitlePreferenceKey.self, stateSaver: state.stateSaver as! Saver, Any>) - let (toolbarPreferences, toolbarPreferencesCollector) = rememberSaveablePreferenceCollector(key: ToolbarPreferenceKey.self, stateSaver: state.stateSaver as! Saver, Any>) - let (toolbarContentPreferences, toolbarContentPreferencesCollector) = rememberSaveablePreferenceCollector(key: ToolbarContentPreferenceKey.self, stateSaver: state.stateSaver as! Saver, Any>) + 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) let arguments = NavigationEntryArguments(isRoot: true, state: state, safeArea: safeArea, ignoresSafeAreaEdges: ignoresSafeAreaEdges, title: title.value.reduced, toolbarPreferences: toolbarPreferences.value.reduced) PreferenceValues.shared.collectPreferences([titleCollector, toolbarPreferencesCollector, toolbarContentPreferencesCollector, destinationsCollector, destinationLayoutHintsCollector]) { RenderEntry(navigator: navigator, toolbarContent: toolbarContentPreferences, arguments: arguments, context: context) { context in @@ -181,9 +188,12 @@ public struct NavigationStack : View, Renderable { } // 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, titleCollector) = rememberSaveablePreferenceCollector(key: NavigationTitlePreferenceKey.self, stateSaver: state.stateSaver as! Saver, Any>) - let (toolbarPreferences, toolbarPreferencesCollector) = rememberSaveablePreferenceCollector(key: ToolbarPreferenceKey.self, stateSaver: state.stateSaver as! Saver, Any>) - let (toolbarContentPreferences, toolbarContentPreferencesCollector) = rememberSaveablePreferenceCollector(key: ToolbarContentPreferenceKey.self, stateSaver: state.stateSaver as! Saver, Any>) + 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 @@ -267,9 +277,11 @@ public struct NavigationStack : View, Renderable { let searchFieldOffsetPx = rememberSaveable(stateSaver: context.stateSaver as! Saver) { mutableStateOf(Float(0.0)) } let searchFieldScrollConnection = remember { SearchFieldScrollConnection(heightPx: searchFieldHeightPx, offsetPx: searchFieldOffsetPx) } - let (searchableStatePreference, searchableStateCollector) = rememberSaveablePreferenceCollector(key: SearchableStatePreferenceKey.self, stateSaver: context.stateSaver as! Saver, Any>) + let searchableStatePreference = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: SearchableStatePreferenceKey.self)) } + let searchableStateCollector = PreferenceCollector(key: SearchableStatePreferenceKey.self, state: searchableStatePreference) - let (scrollToTop, scrollToTopCollector) = rememberSaveablePreferenceCollector(key: ScrollToTopPreferenceKey.self, stateSaver: context.stateSaver as! Saver, Any>) + let scrollToTop = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: ScrollToTopPreferenceKey.self)) } + let scrollToTopCollector = PreferenceCollector(key: ScrollToTopPreferenceKey.self, state: scrollToTop) let initialScrollBehavior = isInlineTitleDisplayMode ? TopAppBarDefaults.pinnedScrollBehavior() : TopAppBarDefaults.exitUntilCollapsedScrollBehavior() // Determine the final scrollBehavior early by checking if the environment value would modify it diff --git a/Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift b/Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift index 6a894353..3efa177e 100644 --- a/Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift +++ b/Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift @@ -32,7 +32,8 @@ import androidx.compose.ui.platform.LocalLayoutDirection @Composable public func PresentationRoot(defaultColorScheme: ColorScheme? = nil, absoluteSystemBarEdges systemBarEdges: Edge.Set = .all, context: ComposeContext, content: @Composable (ComposeContext) -> Void) { launchUIApplicationActivity() - let (preferredColorScheme, preferredColorSchemeCollector) = rememberSaveablePreferenceCollector(key: PreferredColorSchemePreferenceKey.self, stateSaver: context.stateSaver as! Saver, Any>) + let preferredColorScheme = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: PreferredColorSchemePreferenceKey.self)) } + let preferredColorSchemeCollector = PreferenceCollector(key: PreferredColorSchemePreferenceKey.self, state: preferredColorScheme) PreferenceValues.shared.collectPreferences([preferredColorSchemeCollector]) { let materialColorScheme = preferredColorScheme.value.reduced.colorScheme?.asMaterialTheme() ?? defaultColorScheme?.asMaterialTheme() ?? MaterialTheme.colorScheme MaterialTheme(colorScheme: materialColorScheme) { diff --git a/Sources/SkipUI/SkipUI/Containers/ScrollView.swift b/Sources/SkipUI/SkipUI/Containers/ScrollView.swift index 93004915..dcb922bb 100644 --- a/Sources/SkipUI/SkipUI/Containers/ScrollView.swift +++ b/Sources/SkipUI/SkipUI/Containers/ScrollView.swift @@ -52,7 +52,8 @@ public struct ScrollView : View, Renderable { // SKIP INSERT: @OptIn(ExperimentalMaterialApi::class) @Composable override func Render(context: ComposeContext) { // Some components in Compose have their own scrolling built in - let (builtinScrollAxisSet, builtinScrollAxisSetCollector) = rememberSaveablePreferenceCollector(key: BuiltinScrollAxisSetPreferenceKey.self, stateSaver: context.stateSaver as! Saver, Any>) + let builtinScrollAxisSet = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: BuiltinScrollAxisSetPreferenceKey.self)) } + let builtinScrollAxisSetCollector = PreferenceCollector(key: BuiltinScrollAxisSetPreferenceKey.self, state: builtinScrollAxisSet) let scrollState = rememberScrollState() let coroutineScope = rememberCoroutineScope() diff --git a/Sources/SkipUI/SkipUI/Containers/TabView.swift b/Sources/SkipUI/SkipUI/Containers/TabView.swift index ab2f7f54..a408872a 100644 --- a/Sources/SkipUI/SkipUI/Containers/TabView.swift +++ b/Sources/SkipUI/SkipUI/Containers/TabView.swift @@ -242,7 +242,8 @@ public struct TabView : View, Renderable { // Isolate access to current route within child Composable so route nav does not force us to recompose navigateToCurrentRoute(tabBackStacks: tabBackStacks, selectedTabIndex: selectedTabIndex, tabRenderables: tabRenderables) - let (tabBarPreferences, tabBarPreferencesCollector) = rememberSaveablePreferenceCollector(key: TabBarPreferenceKey.self, stateSaver: context.stateSaver as! Saver, Any>) + let tabBarPreferences = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: TabBarPreferenceKey.self)) } + let tabBarPreferencesCollector = PreferenceCollector(key: TabBarPreferenceKey.self, state: tabBarPreferences) let safeArea = EnvironmentValues.shared._safeArea /// Latest TabView-scope safe area; use inside long-lived nav entry closures so inset updates (e.g. status bar hide) propagate without relying on lexical capture of `safeArea`. diff --git a/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift b/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift index d46a0ecb..0d0911fe 100644 --- a/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift +++ b/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift @@ -166,30 +166,6 @@ struct PreferenceCollector { } } -/// Wraps `rememberSaveable` for a `Preference` with defensive null handling. -/// -/// After certain Android configuration changes (e.g. a system font scale change) the activity is recreated and the -/// in-memory map backing our `ComposeStateSaver` is lost, so saved keys no longer resolve to their values and the -/// restored `MutableState` ends up holding null. Reading `.reduced` on a null `Preference` then crashes. Reset to -/// the value produced by `initial` whenever the state value is null. See https://github.com/skiptools/skip-ui/issues/300. -@Composable func rememberSaveablePreference(stateSaver: Saver, Any>, initial: () -> Preference) -> MutableState> { - let state = rememberSaveable(stateSaver: stateSaver) { mutableStateOf(initial()) } - if (state.value as Any?) == nil { - state.value = initial() - } - return state -} - -/// Combines `rememberSaveablePreference` with the matching `PreferenceCollector` so callers don't have to repeat -/// the value type or the key. The generic `V` is supplied once on the `stateSaver` cast and inferred elsewhere. -/// Pass `collectorKey` for cases where producers contribute under a different key than the `PreferenceKey` companion -/// (e.g. when the producer keys on the value type itself rather than the `PreferenceKey` type). -@Composable func rememberSaveablePreferenceCollector(key: Any.Type, stateSaver: Saver, Any>, collectorKey: Any? = nil, isErasable: Bool = true) -> (state: MutableState>, collector: PreferenceCollector) { - let state = rememberSaveablePreference(stateSaver: stateSaver) { Preference(key: key) } - let collector = PreferenceCollector(key: collectorKey ?? key, state: state, isErasable: isErasable) - return (state: state, collector: collector) -} - struct PreferenceNode: Equatable { let id: Int let value: Value @@ -242,8 +218,8 @@ extension View { public func onPreferenceChange(key: Any, defaultValue: Any?, reducer: @escaping (Any?, Any?) -> Any?, action: @escaping (Any?) -> Void) -> any View { #if SKIP return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in - let preference = rememberSaveablePreference(stateSaver: context.stateSaver as! Saver, Any>) { - Preference(key: key, initialValue: defaultValue, reducer: reducer) + let preference = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { + mutableStateOf(Preference(key: key, initialValue: defaultValue, reducer: reducer)) } let preferenceCollector = PreferenceCollector(key: key, state: preference) let currentAction = rememberUpdatedState(action) diff --git a/Sources/SkipUI/SkipUI/Layout/Presentation.swift b/Sources/SkipUI/SkipUI/Layout/Presentation.swift index e5a7d9af..5abf117e 100644 --- a/Sources/SkipUI/SkipUI/Layout/Presentation.swift +++ b/Sources/SkipUI/SkipUI/Layout/Presentation.swift @@ -92,7 +92,8 @@ private let AlertDialogMaxWidth: Dp = 560.dp // SKIP INSERT: @OptIn(ExperimentalMaterial3Api::class) @Composable func SheetPresentation(isPresented: Binding, isFullScreen: Bool, context: ComposeContext, content: () -> any View, onDismiss: (() -> Void)?) { - let (interactiveDismissDisabledPreference, interactiveDismissDisabledCollector) = rememberSaveablePreferenceCollector(key: InteractiveDismissDisabledPreferenceKey.self, stateSaver: context.stateSaver as! Saver, Any>) + let interactiveDismissDisabledPreference = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: InteractiveDismissDisabledPreferenceKey.self)) } + let interactiveDismissDisabledCollector = PreferenceCollector(key: InteractiveDismissDisabledPreferenceKey.self, state: interactiveDismissDisabledPreference) let sheetState = rememberModalBottomSheetState(skipPartiallyExpanded: true) let isPresentedValue = isPresented.get() @@ -129,8 +130,8 @@ private let AlertDialogMaxWidth: Dp = 560.dp let sheetDepth = EnvironmentValues.shared._sheetDepth var systemBarEdges: Edge.Set = isFullScreen ? .all : [.top, .bottom] - // Producers contribute under the value type rather than the `PreferenceKey` type, so override `collectorKey`. - let (detentPreferences, detentPreferencesCollector) = rememberSaveablePreferenceCollector(key: PresentationDetentPreferenceKey.self, stateSaver: context.stateSaver as! Saver, Any>, collectorKey: PresentationDetentPreferences.self) + let detentPreferences = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: PresentationDetentPreferenceKey.self)) } + let detentPreferencesCollector = PreferenceCollector(key: PresentationDetentPreferences.self, state: detentPreferences) let reducedDetentPreferences = detentPreferences.value.reduced if !isFullScreen && verticalSizeClass != .compact {