diff --git a/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift b/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift index 0d0911fe..fcb833c7 100644 --- a/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift +++ b/Sources/SkipUI/SkipUI/Environment/PreferenceKey.swift @@ -181,16 +181,35 @@ struct PreferenceNode: Equatable { return lhs.value == rhs.value } } + +/// The modifier produced by `preference(key:value:)`. +/// +/// Contributes through the standard collector at render time like any side effect, but +/// carries its key and value as inspectable fields so that presentation containers can +/// harvest statically-applied preferences from an evaluated renderable chain *before* +/// their first composition (see `SheetPresentation`), instead of waiting for the +/// contribution to settle a composition later. +final class PreferenceModifier: SideEffectModifier { + let key: Any + let value: Any? + + init(key: Any, value: Any?) { + self.key = key + self.value = value + super.init() + self.action = { context in + PreferenceValues.shared.contribute(context: context, key: key, value: value) + return ComposeResult.ok + } + } +} #endif extension View { // SKIP @bridge public func preference(key: Any, value: Any?) -> any View { #if SKIP - return ModifiedContent(content: self, modifier: SideEffectModifier { context in - PreferenceValues.shared.contribute(context: context, key: key, value: value) - return ComposeResult.ok - }) + return ModifiedContent(content: self, modifier: PreferenceModifier(key: key, value: value)) #else return self #endif diff --git a/Sources/SkipUI/SkipUI/Layout/Presentation.swift b/Sources/SkipUI/SkipUI/Layout/Presentation.swift index a5b4cd16..240274de 100644 --- a/Sources/SkipUI/SkipUI/Layout/Presentation.swift +++ b/Sources/SkipUI/SkipUI/Layout/Presentation.swift @@ -92,15 +92,17 @@ 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 = 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() if isPresentedValue || sheetState.isVisible { // Don't fully evaluate content until we set up the presented environment. For now we just want - // to get at the modifiers to look for `BackDismissDisabled` + // to get at the modifiers to look for `BackDismissDisabled` and statically-applied + // presentation preferences (see `harvestPreference`) let contentRenderables = ComposeBuilder.from(content).Evaluate(context: context, options: EvaluateOptions(isKeepNonModified: true).value) + // Seed from the harvested static preference so the first composition already gates + // gestures correctly; dynamic updates keep flowing through the collector + let interactiveDismissDisabledPreference = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: InteractiveDismissDisabledPreferenceKey.self, initialValue: harvestPreference(key: InteractiveDismissDisabledPreferenceKey.self, on: contentRenderables) as? Bool)) } + let interactiveDismissDisabledCollector = PreferenceCollector(key: InteractiveDismissDisabledPreferenceKey.self, state: interactiveDismissDisabledPreference) let topInset = remember { mutableStateOf(0.dp) } let topInsetPx = with(LocalDensity.current) { topInset.value.toPx() } let handleHeight = isFullScreen ? 0.dp : 8.dp @@ -108,18 +110,26 @@ private let AlertDialogMaxWidth: Dp = 560.dp let handlePadding = isFullScreen ? 0.dp : 10.dp let handlePaddingPx = with(LocalDensity.current) { handlePadding.toPx() } let sheetMaxWidth = isFullScreen ? Dp.Unspecified : BottomSheetDefaults.SheetMaxWidth - let shape = GenericShape { size, _ in - let y = topInsetPx - handleHeightPx - handlePaddingPx - addRect(Rect(offset = Offset(x: Float(0.0), y: y), size: Size(width: size.width, height: size.height - y))) + // Remember the shape keyed on its inputs: a fresh (unequal) instance per pass is an + // unstable ModalBottomSheet argument that forces the sheet machinery to recompose on + // every presenter recomposition + let shape = remember(topInsetPx, handleHeightPx, handlePaddingPx) { + GenericShape { size, _ in + let y = topInsetPx - handleHeightPx - handlePaddingPx + addRect(Rect(offset = Offset(x: Float(0.0), y: y), size: Size(width: size.width, height: size.height - y))) + } } let interactiveDismissDisabled = isFullScreen || interactiveDismissDisabledPreference.value.reduced // Implementing backDismissDisabled as a preference doesn't work because preferences require an extra composition // and only the first composition of `ModalBottomSheetProperties` is taken into account. So we require the - // modifier directly on the content view - let backDismissDisabled = isBackDismissDisabled(on: contentRenderables) - let onDismissRequest = { - isPresented.set(false) - } + // modifier directly on the content view. Harvested once per presentation for the same + // reason: later values are ignored anyway, and a per-pass recomputation destabilizes + // the ModalBottomSheet arguments + let backDismissDisabled = remember { isBackDismissDisabled(on: contentRenderables) } + // Stable dismiss callback: a fresh closure per pass is an unstable ModalBottomSheet + // argument (see `shape` above) + let currentIsPresented = rememberUpdatedState(isPresented) + let onDismissRequest: () -> Void = remember { { currentIsPresented.value.set(false) } } let properties = ModalBottomSheetProperties(shouldDismissOnBackPress: !backDismissDisabled) ModalBottomSheet(onDismissRequest: onDismissRequest, sheetState: sheetState, sheetMaxWidth: sheetMaxWidth, sheetGesturesEnabled: !interactiveDismissDisabled, containerColor: androidx.compose.ui.graphics.Color.Unspecified, shape: shape, dragHandle: nil, contentWindowInsets: { WindowInsets(0.dp, 0.dp, 0.dp, 0.dp) }, properties: properties) { @@ -130,10 +140,15 @@ private let AlertDialogMaxWidth: Dp = 560.dp let sheetDepth = EnvironmentValues.shared._sheetDepth var systemBarEdges: Edge.Set = isFullScreen ? .all : [.top, .bottom] - let detentPreferences = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: PresentationDetentPreferenceKey.self)) } + // Seed both preference states from the statically-applied modifiers harvested off + // the evaluated content, so the sheet's FIRST composition uses the final detent + // geometry and drag-indicator visibility instead of defaults that settle (with a + // visible jump) one composition later. Dynamic preference updates still flow + // through the collectors exactly as before + let detentPreferences = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: PresentationDetentPreferenceKey.self, initialValue: harvestPreference(key: PresentationDetentPreferences.self, on: contentRenderables) as? PresentationDetentPreferences)) } let detentPreferencesCollector = PreferenceCollector(key: PresentationDetentPreferences.self, state: detentPreferences) let reducedDetentPreferences = detentPreferences.value.reduced - let dragIndicatorPreferences = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: PresentationDragIndicatorPreferenceKey.self)) } + let dragIndicatorPreferences = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: PresentationDragIndicatorPreferenceKey.self, initialValue: harvestPreference(key: PresentationDragIndicatorPreferences.self, on: contentRenderables) as? PresentationDragIndicatorPreferences)) } let dragIndicatorPreferencesCollector = PreferenceCollector(key: PresentationDragIndicatorPreferences.self, state: dragIndicatorPreferences) let reducedDragIndicatorVisibility = dragIndicatorPreferences.value.reduced.visibility @@ -258,6 +273,31 @@ func isBackDismissDisabled(on renderables: kotlin.collections.List) return false } +/// One-shot harvest of a statically-applied `preference(key:value:)` value from evaluated +/// renderables, mirroring `isBackDismissDisabled`. +/// +/// Used to seed presentation preference state so that the presentation's first composition +/// uses final values (detent geometry, drag indicator, interactive dismiss) instead of +/// defaults that only settle after the collected preferences propagate a composition later. +/// Only sees preferences applied to the presented content's own modifier chain — values +/// applied deeper in the tree still arrive through the collector as before. Returns the +/// first match, matching the collector's outermost-wins reduction for these keys. +func harvestPreference(key: Any, on renderables: kotlin.collections.List) -> Any? { + for renderable in renderables { + let value = renderable.forEachModifier(perform: { modifier in + if let preferenceModifier = modifier as? PreferenceModifier, preferenceModifier.key == key { + return preferenceModifier.value + } else { + return nil + } + }) + if value != nil { + return value + } + } + return nil +} + final class DisableScrollToDismissConnection : NestedScrollConnection { override func onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource) -> Offset { return available.copy(x: Float(0.0)) @@ -272,21 +312,27 @@ final class DisableScrollToDismissConnection : NestedScrollConnection { @Composable func ConfirmationDialogPresentation(title: Text?, isPresented: Binding, context: ComposeContext, actions: any View, message: (any View)? = nil) { let sheetState = rememberModalBottomSheetState(skipPartiallyExpanded: true) if isPresented.get() || sheetState.isVisible { - // Collect buttons and message text - let actionRenderables = actions.Evaluate(context: context, options: 0) - let composableActions: kotlin.collections.List = actionRenderables.mapNotNull { - let stripped = $0.strip() - return stripped as? Button ?? stripped as? Link ?? stripped as? NavigationLink - } - let messageRenderables: kotlin.collections.List = message?.Evaluate(context: context, options: 0) ?? listOf() - let messageText = messageRenderables.mapNotNull { - $0.strip() as? Text - }.firstOrNull() + // Stable dismiss callback: a fresh closure per pass is an unstable ModalBottomSheet + // argument that forces the sheet machinery to recompose on every presenter recomposition + let currentIsPresented = rememberUpdatedState(isPresented) + let onDismissRequest: () -> Void = remember { { currentIsPresented.value.set(false) } } + ModalBottomSheet(onDismissRequest: onDismissRequest, sheetState: sheetState, containerColor: androidx.compose.ui.graphics.Color.Transparent, dragHandle: nil, contentWindowInsets: { WindowInsets(0.dp, 0.dp, 0.dp, 0.dp) }) { + // Collect buttons and message text. Evaluated inside the dialog's own composition: + // in the presenter's scope these full evaluations re-ran the actions and message + // view bodies on every presenter recomposition while the dialog was open + let actionRenderables = actions.Evaluate(context: context, options: 0) + let composableActions: kotlin.collections.List = actionRenderables.mapNotNull { + let stripped = $0.strip() + return stripped as? Button ?? stripped as? Link ?? stripped as? NavigationLink + } + let messageRenderables: kotlin.collections.List = message?.Evaluate(context: context, options: 0) ?? listOf() + let messageText = messageRenderables.mapNotNull { + $0.strip() as? Text + }.firstOrNull() - ModalBottomSheet(onDismissRequest: { isPresented.set(false) }, sheetState: sheetState, containerColor: androidx.compose.ui.graphics.Color.Transparent, dragHandle: nil, contentWindowInsets: { WindowInsets(0.dp, 0.dp, 0.dp, 0.dp) }) { // Add padding to always keep the sheet away from the top of the screen. It should tap to dismiss like the background let interactionSource = remember { MutableInteractionSource() } - Box(modifier: Modifier.fillMaxWidth().height(128.dp).clickable(interactionSource: interactionSource, indication: nil, onClick: { isPresented.set(false) })) + Box(modifier: Modifier.fillMaxWidth().height(128.dp).clickable(interactionSource: interactionSource, indication: nil, onClick: onDismissRequest)) let stateSaver = remember { ComposeStateSaver() } let scrollState = rememberScrollState() @@ -536,8 +582,11 @@ final class DisableScrollToDismissConnection : NestedScrollConnection { } } } + // Stable dismiss callback: a fresh closure per pass is an unstable Dialog argument + let currentIsPresented = rememberUpdatedState(isPresented) + let onDismissRequest: () -> Void = remember { { currentIsPresented.value.set(false) } } SkipAlertDialog( - onDismissRequest: { isPresented.set(false) }, + onDismissRequest: onDismissRequest, neutralButtons: neutralButtonsList, confirmButton: { if let r = confirmRenderable {