Skip to content

Commit 23cf094

Browse files
committed
Fix crash on state reset when preferences are queried
1 parent d6c2dad commit 23cf094

6 files changed

Lines changed: 44 additions & 36 deletions

File tree

Sources/SkipUI/SkipUI/Containers/Navigation.swift

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,10 @@ public struct NavigationStack : View, Renderable {
134134

135135
#if SKIP
136136
@Composable public override func Render(context: ComposeContext) {
137-
// Have to use rememberSaveable for e.g. a nav stack in each tab
138-
let destinations = rememberSaveable(stateSaver: context.stateSaver as! Saver<Preference<NavigationDestinations>, Any>) { mutableStateOf(Preference<NavigationDestinations>(key: NavigationDestinationsPreferenceKey.self))
139-
}
140-
// Make this collector non-erasable so that destinations defined at e.g. the root nav stack layer don't disappear when you push
141-
let destinationsCollector = PreferenceCollector<NavigationDestinations>(key: NavigationDestinationsPreferenceKey.self, state: destinations, isErasable: false)
142-
let destinationLayoutHints = rememberSaveable(stateSaver: context.stateSaver as! Saver<Preference<NavigationDestinationLayoutHintsMap>, Any>) { mutableStateOf(Preference<NavigationDestinationLayoutHintsMap>(key: NavigationDestinationLayoutHintsPreferenceKey.self))
143-
}
144-
let destinationLayoutHintsCollector = PreferenceCollector<NavigationDestinationLayoutHintsMap>(key: NavigationDestinationLayoutHintsPreferenceKey.self, state: destinationLayoutHints, isErasable: false)
137+
// Have to use rememberSaveable for e.g. a nav stack in each tab. Make the collectors non-erasable so that
138+
// destinations defined at e.g. the root nav stack layer don't disappear when you push.
139+
let (destinations, destinationsCollector) = rememberSaveablePreferenceCollector(key: NavigationDestinationsPreferenceKey.self, stateSaver: context.stateSaver as! Saver<Preference<NavigationDestinations>, Any>, isErasable: false)
140+
let (destinationLayoutHints, destinationLayoutHintsCollector) = rememberSaveablePreferenceCollector(key: NavigationDestinationLayoutHintsPreferenceKey.self, stateSaver: context.stateSaver as! Saver<Preference<NavigationDestinationLayoutHintsMap>, Any>, isErasable: false)
145141
let reducedDestinations = destinations.value.reduced
146142
let reducedDestinationLayoutHints = destinationLayoutHints.value.reduced
147143
let mergedDestinations = mergeNavigationDestinationsWithLayoutHints(reducedDestinations, layoutHints: reducedDestinationLayoutHints)
@@ -166,12 +162,9 @@ public struct NavigationStack : View, Renderable {
166162
}
167163
// These preferences are per-entry, but if we put them in RenderEntry then their initial values don't show
168164
// during the navigation animation. We have to collect them here
169-
let title = rememberSaveable(stateSaver: state.stateSaver as! Saver<Preference<Text>, Any>) { mutableStateOf(Preference<Text>(key: NavigationTitlePreferenceKey.self)) }
170-
let titleCollector = PreferenceCollector<Text>(key: NavigationTitlePreferenceKey.self, state: title)
171-
let toolbarPreferences = rememberSaveable(stateSaver: state.stateSaver as! Saver<Preference<ToolbarPreferences>, Any>) { mutableStateOf(Preference<ToolbarPreferences>(key: ToolbarPreferenceKey.self)) }
172-
let toolbarPreferencesCollector = PreferenceCollector<ToolbarPreferences>(key: ToolbarPreferenceKey.self, state: toolbarPreferences)
173-
let toolbarContentPreferences = rememberSaveable(stateSaver: state.stateSaver as! Saver<Preference<ToolbarContentPreferences>, Any>) { mutableStateOf(Preference<ToolbarContentPreferences>(key: ToolbarContentPreferenceKey.self)) }
174-
let toolbarContentPreferencesCollector = PreferenceCollector<ToolbarContentPreferences>(key: ToolbarContentPreferenceKey.self, state: toolbarContentPreferences)
165+
let (title, titleCollector) = rememberSaveablePreferenceCollector(key: NavigationTitlePreferenceKey.self, stateSaver: state.stateSaver as! Saver<Preference<Text>, Any>)
166+
let (toolbarPreferences, toolbarPreferencesCollector) = rememberSaveablePreferenceCollector(key: ToolbarPreferenceKey.self, stateSaver: state.stateSaver as! Saver<Preference<ToolbarPreferences>, Any>)
167+
let (toolbarContentPreferences, toolbarContentPreferencesCollector) = rememberSaveablePreferenceCollector(key: ToolbarContentPreferenceKey.self, stateSaver: state.stateSaver as! Saver<Preference<ToolbarContentPreferences>, Any>)
175168
let arguments = NavigationEntryArguments(isRoot: true, state: state, safeArea: safeArea, ignoresSafeAreaEdges: ignoresSafeAreaEdges, title: title.value.reduced, toolbarPreferences: toolbarPreferences.value.reduced)
176169
PreferenceValues.shared.collectPreferences([titleCollector, toolbarPreferencesCollector, toolbarContentPreferencesCollector, destinationsCollector, destinationLayoutHintsCollector]) {
177170
RenderEntry(navigator: navigator, toolbarContent: toolbarContentPreferences, arguments: arguments, context: context) { context in
@@ -185,12 +178,9 @@ public struct NavigationStack : View, Renderable {
185178
}
186179
// These preferences are per-entry, but if we put them in RenderEntry then their initial values don't show
187180
// during the navigation animation. We have to collect them here
188-
let title = rememberSaveable(stateSaver: state.stateSaver as! Saver<Preference<Text>, Any>) { mutableStateOf(Preference<Text>(key: NavigationTitlePreferenceKey.self)) }
189-
let titleCollector = PreferenceCollector<Text>(key: NavigationTitlePreferenceKey.self, state: title)
190-
let toolbarPreferences = rememberSaveable(stateSaver: state.stateSaver as! Saver<Preference<ToolbarPreferences>, Any>) { mutableStateOf(Preference<ToolbarPreferences>(key: ToolbarPreferenceKey.self)) }
191-
let toolbarPreferencesCollector = PreferenceCollector<ToolbarPreferences>(key: ToolbarPreferenceKey.self, state: toolbarPreferences)
192-
let toolbarContentPreferences = rememberSaveable(stateSaver: state.stateSaver as! Saver<Preference<ToolbarContentPreferences>, Any>) { mutableStateOf(Preference<ToolbarContentPreferences>(key: ToolbarContentPreferenceKey.self)) }
193-
let toolbarContentPreferencesCollector = PreferenceCollector<ToolbarContentPreferences>(key: ToolbarContentPreferenceKey.self, state: toolbarContentPreferences)
181+
let (title, titleCollector) = rememberSaveablePreferenceCollector(key: NavigationTitlePreferenceKey.self, stateSaver: state.stateSaver as! Saver<Preference<Text>, Any>)
182+
let (toolbarPreferences, toolbarPreferencesCollector) = rememberSaveablePreferenceCollector(key: ToolbarPreferenceKey.self, stateSaver: state.stateSaver as! Saver<Preference<ToolbarPreferences>, Any>)
183+
let (toolbarContentPreferences, toolbarContentPreferencesCollector) = rememberSaveablePreferenceCollector(key: ToolbarContentPreferenceKey.self, stateSaver: state.stateSaver as! Saver<Preference<ToolbarContentPreferences>, Any>)
194184
EnvironmentValues.shared.setValues {
195185
$0.setdismiss(DismissAction(action: { navigator.value.navigateBack() }))
196186
return ComposeResult.ok
@@ -266,11 +256,9 @@ public struct NavigationStack : View, Renderable {
266256
let searchFieldOffsetPx = rememberSaveable(stateSaver: context.stateSaver as! Saver<Float, Any>) { mutableStateOf(Float(0.0)) }
267257
let searchFieldScrollConnection = remember { SearchFieldScrollConnection(heightPx: searchFieldHeightPx, offsetPx: searchFieldOffsetPx) }
268258

269-
let searchableStatePreference = rememberSaveable(stateSaver: context.stateSaver as! Saver<Preference<SearchableState?>, Any>) { mutableStateOf(Preference<SearchableState?>(key: SearchableStatePreferenceKey.self)) }
270-
let searchableStateCollector = PreferenceCollector<SearchableState?>(key: SearchableStatePreferenceKey.self, state: searchableStatePreference)
259+
let (searchableStatePreference, searchableStateCollector) = rememberSaveablePreferenceCollector(key: SearchableStatePreferenceKey.self, stateSaver: context.stateSaver as! Saver<Preference<SearchableState?>, Any>)
271260

272-
let scrollToTop = rememberSaveable(stateSaver: context.stateSaver as! Saver<Preference<ScrollToTopAction>, Any>) { mutableStateOf(Preference<ScrollToTopAction>(key: ScrollToTopPreferenceKey.self)) }
273-
let scrollToTopCollector = PreferenceCollector<ScrollToTopAction>(key: ScrollToTopPreferenceKey.self, state: scrollToTop)
261+
let (scrollToTop, scrollToTopCollector) = rememberSaveablePreferenceCollector(key: ScrollToTopPreferenceKey.self, stateSaver: context.stateSaver as! Saver<Preference<ScrollToTopAction>, Any>)
274262

275263
let initialScrollBehavior = isInlineTitleDisplayMode ? TopAppBarDefaults.pinnedScrollBehavior() : TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
276264
// Determine the final scrollBehavior early by checking if the environment value would modify it

Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection
3232
@Composable public func PresentationRoot(defaultColorScheme: ColorScheme? = nil, absoluteSystemBarEdges systemBarEdges: Edge.Set = .all, context: ComposeContext, content: @Composable (ComposeContext) -> Void) {
3333
launchUIApplicationActivity()
3434

35-
let preferredColorScheme = rememberSaveable(stateSaver: context.stateSaver as! Saver<Preference<PreferredColorScheme>, Any>) { mutableStateOf(Preference<PreferredColorScheme>(key: PreferredColorSchemePreferenceKey.self)) }
36-
let preferredColorSchemeCollector = PreferenceCollector<PreferredColorScheme>(key: PreferredColorSchemePreferenceKey.self, state: preferredColorScheme)
35+
let (preferredColorScheme, preferredColorSchemeCollector) = rememberSaveablePreferenceCollector(key: PreferredColorSchemePreferenceKey.self, stateSaver: context.stateSaver as! Saver<Preference<PreferredColorScheme>, Any>)
3736
PreferenceValues.shared.collectPreferences([preferredColorSchemeCollector]) {
3837
let materialColorScheme = preferredColorScheme.value.reduced.colorScheme?.asMaterialTheme() ?? defaultColorScheme?.asMaterialTheme() ?? MaterialTheme.colorScheme
3938
MaterialTheme(colorScheme: materialColorScheme) {

Sources/SkipUI/SkipUI/Containers/ScrollView.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,7 @@ public struct ScrollView : View, Renderable {
5252
// SKIP INSERT: @OptIn(ExperimentalMaterialApi::class)
5353
@Composable override func Render(context: ComposeContext) {
5454
// Some components in Compose have their own scrolling built in
55-
let builtinScrollAxisSet = rememberSaveable(stateSaver: context.stateSaver as! Saver<Preference<Axis.Set>, Any>) { mutableStateOf(Preference<Axis.Set>(key: BuiltinScrollAxisSetPreferenceKey.self)) }
56-
let builtinScrollAxisSetCollector = PreferenceCollector<Axis.Set>(key: BuiltinScrollAxisSetPreferenceKey.self, state: builtinScrollAxisSet)
55+
let (builtinScrollAxisSet, builtinScrollAxisSetCollector) = rememberSaveablePreferenceCollector(key: BuiltinScrollAxisSetPreferenceKey.self, stateSaver: context.stateSaver as! Saver<Preference<Axis.Set>, Any>)
5756

5857
let scrollState = rememberScrollState()
5958
let coroutineScope = rememberCoroutineScope()

Sources/SkipUI/SkipUI/Containers/TabView.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,7 @@ public struct TabView : View, Renderable {
242242
// Isolate access to current route within child Composable so route nav does not force us to recompose
243243
navigateToCurrentRoute(tabBackStacks: tabBackStacks, selectedTabIndex: selectedTabIndex, tabRenderables: tabRenderables)
244244

245-
let tabBarPreferences = rememberSaveable(stateSaver: context.stateSaver as! Saver<Preference<ToolbarBarPreferences>, Any>) { mutableStateOf(Preference<ToolbarBarPreferences>(key: TabBarPreferenceKey.self)) }
246-
let tabBarPreferencesCollector = PreferenceCollector<ToolbarBarPreferences>(key: TabBarPreferenceKey.self, state: tabBarPreferences)
245+
let (tabBarPreferences, tabBarPreferencesCollector) = rememberSaveablePreferenceCollector(key: TabBarPreferenceKey.self, stateSaver: context.stateSaver as! Saver<Preference<ToolbarBarPreferences>, Any>)
247246

248247
let safeArea = EnvironmentValues.shared._safeArea
249248
/// 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`.

Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,30 @@ struct PreferenceCollector<Value> {
166166
}
167167
}
168168

169+
/// Wraps `rememberSaveable` for a `Preference<V>` with defensive null handling.
170+
///
171+
/// After certain Android configuration changes (e.g. a system font scale change) the activity is recreated and the
172+
/// in-memory map backing our `ComposeStateSaver` is lost, so saved keys no longer resolve to their values and the
173+
/// restored `MutableState` ends up holding null. Reading `.reduced` on a null `Preference` then crashes. Reset to
174+
/// the value produced by `initial` whenever the state value is null. See https://github.com/skiptools/skip-ui/issues/300.
175+
@Composable func rememberSaveablePreference<V>(stateSaver: Saver<Preference<V>, Any>, initial: () -> Preference<V>) -> MutableState<Preference<V>> {
176+
let state = rememberSaveable(stateSaver: stateSaver) { mutableStateOf(initial()) }
177+
if (state.value as Any?) == nil {
178+
state.value = initial()
179+
}
180+
return state
181+
}
182+
183+
/// Combines `rememberSaveablePreference` with the matching `PreferenceCollector` so callers don't have to repeat
184+
/// the value type or the key. The generic `V` is supplied once on the `stateSaver` cast and inferred elsewhere.
185+
/// Pass `collectorKey` for cases where producers contribute under a different key than the `PreferenceKey` companion
186+
/// (e.g. when the producer keys on the value type itself rather than the `PreferenceKey` type).
187+
@Composable func rememberSaveablePreferenceCollector<V>(key: Any.Type, stateSaver: Saver<Preference<V>, Any>, collectorKey: Any? = nil, isErasable: Bool = true) -> (state: MutableState<Preference<V>>, collector: PreferenceCollector<V>) {
188+
let state = rememberSaveablePreference(stateSaver: stateSaver) { Preference<V>(key: key) }
189+
let collector = PreferenceCollector<V>(key: collectorKey ?? key, state: state, isErasable: isErasable)
190+
return (state: state, collector: collector)
191+
}
192+
169193
struct PreferenceNode<Value>: Equatable {
170194
let id: Int
171195
let value: Value
@@ -218,8 +242,8 @@ extension View {
218242
public func onPreferenceChange(key: Any, defaultValue: Any?, reducer: @escaping (Any?, Any?) -> Any?, action: @escaping (Any?) -> Void) -> any View {
219243
#if SKIP
220244
return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in
221-
let preference = rememberSaveable(stateSaver: context.stateSaver as! Saver<Preference<Any?>, Any>) {
222-
mutableStateOf(Preference<Any?>(key: key, initialValue: defaultValue, reducer: reducer))
245+
let preference = rememberSaveablePreference(stateSaver: context.stateSaver as! Saver<Preference<Any?>, Any>) {
246+
Preference<Any?>(key: key, initialValue: defaultValue, reducer: reducer)
223247
}
224248
let preferenceCollector = PreferenceCollector<Any?>(key: key, state: preference)
225249
let currentAction = rememberUpdatedState(action)

Sources/SkipUI/SkipUI/Layout/Presentation.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,7 @@ private let AlertDialogMaxWidth: Dp = 560.dp
9292

9393
// SKIP INSERT: @OptIn(ExperimentalMaterial3Api::class)
9494
@Composable func SheetPresentation(isPresented: Binding<Bool>, isFullScreen: Bool, context: ComposeContext, content: () -> any View, onDismiss: (() -> Void)?) {
95-
let interactiveDismissDisabledPreference = rememberSaveable(stateSaver: context.stateSaver as! Saver<Preference<Bool>, Any>) { mutableStateOf(Preference<Bool>(key: InteractiveDismissDisabledPreferenceKey.self)) }
96-
let interactiveDismissDisabledCollector = PreferenceCollector<Bool>(key: InteractiveDismissDisabledPreferenceKey.self, state: interactiveDismissDisabledPreference)
95+
let (interactiveDismissDisabledPreference, interactiveDismissDisabledCollector) = rememberSaveablePreferenceCollector(key: InteractiveDismissDisabledPreferenceKey.self, stateSaver: context.stateSaver as! Saver<Preference<Bool>, Any>)
9796

9897
let sheetState = rememberModalBottomSheetState(skipPartiallyExpanded: true)
9998
let isPresentedValue = isPresented.get()
@@ -130,8 +129,8 @@ private let AlertDialogMaxWidth: Dp = 560.dp
130129
let sheetDepth = EnvironmentValues.shared._sheetDepth
131130
var systemBarEdges: Edge.Set = isFullScreen ? .all : [.top, .bottom]
132131

133-
let detentPreferences = rememberSaveable(stateSaver: context.stateSaver as! Saver<Preference<PresentationDetentPreferences>, Any>) { mutableStateOf(Preference<PresentationDetentPreferences>(key: PresentationDetentPreferenceKey.self)) }
134-
let detentPreferencesCollector = PreferenceCollector<PresentationDetentPreferences>(key: PresentationDetentPreferences.self, state: detentPreferences)
132+
// Producers contribute under the value type rather than the `PreferenceKey` type, so override `collectorKey`.
133+
let (detentPreferences, detentPreferencesCollector) = rememberSaveablePreferenceCollector(key: PresentationDetentPreferenceKey.self, stateSaver: context.stateSaver as! Saver<Preference<PresentationDetentPreferences>, Any>, collectorKey: PresentationDetentPreferences.self)
135134
let reducedDetentPreferences = detentPreferences.value.reduced
136135

137136
if !isFullScreen && verticalSizeClass != .compact {

0 commit comments

Comments
 (0)