From f28d748b46e397b3722761f507f4d1445946445f Mon Sep 17 00:00:00 2001 From: Abe White Date: Fri, 6 Dec 2024 11:29:35 -0600 Subject: [PATCH] Update material3 to latest alpha. Support interactiveDismissDisabled --- README.md | 33 ++++++++++- Sources/SkipUI/Skip/skip.yml | 2 +- .../SkipUI/SkipUI/Animation/Transition.swift | 4 +- .../SkipUI/SkipUI/Commands/EditActions.swift | 4 +- Sources/SkipUI/SkipUI/Commands/Menu.swift | 2 +- .../SkipUI/Compose/ComposeLayouts.swift | 2 +- .../SkipUI/Compose/ComposeModifierView.swift | 18 ++---- Sources/SkipUI/SkipUI/Containers/List.swift | 6 +- .../SkipUI/SkipUI/Containers/TabView.swift | 4 +- Sources/SkipUI/SkipUI/Containers/VStack.swift | 2 +- .../SkipUI/SkipUI/Layout/Presentation.swift | 58 ++++++++++++++++--- Sources/SkipUI/SkipUI/System/Gesture.swift | 6 +- Sources/SkipUI/SkipUI/View/View.swift | 15 ++--- 13 files changed, 110 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index fd70c596..19229386 100644 --- a/README.md +++ b/README.md @@ -1180,7 +1180,7 @@ Support levels: .fullScreenCover @@ -1223,6 +1223,17 @@ Support levels: ✅ .inset + + ✅ + +
+ .interactiveDismissDisabled + +
+ + ✅ .italic @@ -1563,6 +1574,7 @@ Support levels: .sheet (example) @@ -2315,6 +2327,25 @@ SkipUI supports both of these models. Using `.navigationDestinations`, however, Compose imposes an additional restriction as well: we must be able to stringify `.navigationDestination` key types. See [Restrictions on Identifiers](#restrictions-on-identifiers) below. +#### Modals + +Skip supports standard modal presentations. Android apps typically allow users to dismiss modals with the Android back button. Skip allows you to selectively disable this behavior with the Android-only `backDismissDisabled(_ isDisabled: Bool = true)` SwiftUI modifier. If you use this modifier, you **must** put it on the top-level view embedded in your `.sheet` or `.fullScreenCover`, as in the following example: + +```swift +SomeContentView() + .sheet(isPresented: $isSheetPresented) { + SheetContentView() + #if os(Android) + .backDismissDisabled() + #endif + } +``` + +Due to Compose limitations, changing the value passed to `.backDismissDisabled(_: Bool = true)` while the modal is presented has no effect. Only the value at the time of presentation is considered. +{: class="callout warning"} + +Note that you might want to pair `backDismissDisabled` with SwiftUI's `.interactiveDismissDisabled` modifier to also disable dismissing via dragging the sheet down. + ### Restrictions on Identifiers Compose requires all state values to be serializable. This restriction is typically transparent to your code, because when you use property wrappers like `@State`, SkipUI automatically tracks your state objects and gives Compose serializable identifiers in their place. Some SwiftUI values, however, must be stored directly in Compose, including `navigationDestination` values and `List` item identifiers. When this is the case, SkipUI creates a `String` from the value you supply using the following algorithm: diff --git a/Sources/SkipUI/Skip/skip.yml b/Sources/SkipUI/Skip/skip.yml index 564d5631..eafaf5f5 100644 --- a/Sources/SkipUI/Skip/skip.yml +++ b/Sources/SkipUI/Skip/skip.yml @@ -26,7 +26,7 @@ settings: - 'library("androidx-compose-animation", "androidx.compose.animation", "animation").withoutVersion()' - 'library("androidx-compose-material", "androidx.compose.material", "material").withoutVersion()' - 'library("androidx-compose-material-icons-extended", "androidx.compose.material", "material-icons-extended").withoutVersion()' - - 'library("androidx-compose-material3", "androidx.compose.material3", "material3").withoutVersion()' + - 'library("androidx-compose-material3", "androidx.compose.material3", "material3").version("1.4.0-alpha04")' - 'library("androidx-compose-foundation", "androidx.compose.foundation", "foundation").withoutVersion()' - 'library("androidx-appcompat", "androidx.appcompat", "appcompat").versionRef("androidx-appcompat")' - 'library("androidx-appcompat-resources", "androidx.appcompat", "appcompat-resources").versionRef("androidx-appcompat")' diff --git a/Sources/SkipUI/SkipUI/Animation/Transition.swift b/Sources/SkipUI/SkipUI/Animation/Transition.swift index 247fd452..e653d746 100644 --- a/Sources/SkipUI/SkipUI/Animation/Transition.swift +++ b/Sources/SkipUI/SkipUI/Animation/Transition.swift @@ -441,12 +441,12 @@ struct TransitionModifierView: ComposeModifierView { init(view: View, transition: Transition) { self.transition = transition - super.init(view: view, role: ComposeModifierRole.transition) + super.init(view: view) } /// Extract the transition from the given view's modifiers. static func transition(for view: View) -> Transition? { - guard let modifierView = view.strippingModifiers(until: { $0 == ComposeModifierRole.transition }, perform: { $0 as? TransitionModifierView }) else { + guard let modifierView = view.strippingModifiers(until: { $0 is TransitionModifierView }, perform: { $0 as? TransitionModifierView }) else { return nil } return modifierView.transition diff --git a/Sources/SkipUI/SkipUI/Commands/EditActions.swift b/Sources/SkipUI/SkipUI/Commands/EditActions.swift index 876100d7..8f034cb8 100644 --- a/Sources/SkipUI/SkipUI/Commands/EditActions.swift +++ b/Sources/SkipUI/SkipUI/Commands/EditActions.swift @@ -42,7 +42,7 @@ final class EditActionsModifierView: ComposeModifierView { var isMoveDisabled: Bool? init(view: View, isDeleteDisabled: Bool? = nil, isMoveDisabled: Bool? = nil) { - super.init(view: view, role: ComposeModifierRole.editActions) + super.init(view: view) let wrappedEditActionsView = Self.unwrap(view: view) self.isDeleteDisabled = isDeleteDisabled ?? wrappedEditActionsView?.isDeleteDisabled self.isMoveDisabled = isMoveDisabled ?? wrappedEditActionsView?.isMoveDisabled @@ -50,7 +50,7 @@ final class EditActionsModifierView: ComposeModifierView { /// Return the edit actions modifier information for the given view. static func unwrap(view: View) -> EditActionsModifierView? { - return view.strippingModifiers(until: { $0 == .editActions }, perform: { $0 as? EditActionsModifierView }) + return view.strippingModifiers(until: { $0 is EditActionsModifierView }, perform: { $0 as? EditActionsModifierView }) } } diff --git a/Sources/SkipUI/SkipUI/Commands/Menu.swift b/Sources/SkipUI/SkipUI/Commands/Menu.swift index ebd08ac1..498bff35 100644 --- a/Sources/SkipUI/SkipUI/Commands/Menu.swift +++ b/Sources/SkipUI/SkipUI/Commands/Menu.swift @@ -159,7 +159,7 @@ public final class Menu : View { } @Composable private static func ComposeDropdownMenuItem(for view: ComposeBuilder, context: ComposeContext, isSelected: Bool? = nil, action: () -> Void) { - let label = view.collectViews(context: context).first?.strippingModifiers(perform: { $0 as? Label }) + let label = view.collectViews(context: context).first?.strippingModifiers { $0 as? Label } if let isSelected { let selectedIcon: @Composable () -> Void if isSelected { diff --git a/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift b/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift index a7b939ee..07d02d9f 100644 --- a/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift +++ b/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift @@ -38,7 +38,7 @@ import androidx.compose.ui.unit.dp // If our content has a zIndex, we need to pull it into our modifiers so that it applies within the original // parent container. Otherwise the Box we use below would hide it - if let zIndex = view.strippingModifiers(until: { $0 == .zIndex }, perform: { $0 as? ZIndexModifierView }) { + if let zIndex = view.strippingModifiers(until: { $0 is ZIndexModifierView }, perform: { $0 as? ZIndexModifierView }) { modifier = zIndex.consume(with: modifier) } diff --git a/Sources/SkipUI/SkipUI/Compose/ComposeModifierView.swift b/Sources/SkipUI/SkipUI/Compose/ComposeModifierView.swift index 5fb82212..664654f1 100644 --- a/Sources/SkipUI/SkipUI/Compose/ComposeModifierView.swift +++ b/Sources/SkipUI/SkipUI/Compose/ComposeModifierView.swift @@ -10,27 +10,21 @@ import androidx.compose.runtime.Composable /// Recognized modifier roles. public enum ComposeModifierRole { case accessibility - case editActions - case gesture case id - case listItem case spacing - case tabItem case tag - case transition case unspecified - case zIndex } /// Used internally by modifiers to apply changes to the context supplied to modified views. -class ComposeModifierView: View { +public class ComposeModifierView: View { let view: View let role: ComposeModifierRole var action: (@Composable (inout ComposeContext) -> ComposeResult)? var composeContent: (@Composable (any View, ComposeContext) -> Void)? /// Constructor for subclasses. - init(view: any View, role: ComposeModifierRole = .unspecified) { + public init(view: any View, role: ComposeModifierRole = .unspecified) { // Don't copy view // SKIP REPLACE: this.view = view self.view = view @@ -38,13 +32,13 @@ class ComposeModifierView: View { } /// A modfiier that performs an action, optionally modifying the `ComposeContext` passed to the modified view. - init(targetView: any View, role: ComposeModifierRole = .unspecified, action: @Composable (inout ComposeContext) -> ComposeResult) { + public init(targetView: any View, role: ComposeModifierRole = .unspecified, action: @Composable (inout ComposeContext) -> ComposeResult) { self.init(view: targetView, role: role) self.action = action } /// A modifier that takes over the composition of the modified view. - init(contentView: any View, role: ComposeModifierRole = .unspecified, composeContent: @Composable (any View, ComposeContext) -> Void) { + public init(contentView: any View, role: ComposeModifierRole = .unspecified, composeContent: @Composable (any View, ComposeContext) -> Void) { self.init(view: contentView, role: role) self.composeContent = composeContent } @@ -61,8 +55,8 @@ class ComposeModifierView: View { } } - func strippingModifiers(until: (ComposeModifierRole) -> Bool = { _ in false }, perform: (any View?) -> R) -> R { - if until(role) { + func strippingModifiers(until: (ComposeModifierView) -> Bool = { _ in false }, perform: (any View?) -> R) -> R { + if until(self) { return perform(self) } else { return view.strippingModifiers(until: until, perform: perform) diff --git a/Sources/SkipUI/SkipUI/Containers/List.swift b/Sources/SkipUI/SkipUI/Containers/List.swift index 2d0d4612..53ff6c08 100644 --- a/Sources/SkipUI/SkipUI/Containers/List.swift +++ b/Sources/SkipUI/SkipUI/Containers/List.swift @@ -371,7 +371,7 @@ public final class List : View { return } - let itemModifierView = view.strippingModifiers(until: { $0 == .listItem }, perform: { $0 as? ListItemModifierView }) + let itemModifierView = view.strippingModifiers(until: { $0 is ListItemModifierView }, perform: { $0 as? ListItemModifierView }) var itemModifier: Modifier = Modifier if itemModifierView?.background == nil { itemModifier = itemModifier.background(BackgroundColor(styling: styling.withStyle(ListStyle.plain), isItem: isItem)) @@ -836,10 +836,10 @@ final class ListItemModifierView: ComposeModifierView, ListItemAdapting { var separator: Visibility? init(view: View, background: View? = nil, separator: Visibility? = nil) { - let modifierView = view.strippingModifiers(until: { $0 == .listItem }, perform: { $0 as? ListItemModifierView }) + let modifierView = view.strippingModifiers(until: { $0 is ListItemModifierView }, perform: { $0 as? ListItemModifierView }) self.background = background ?? modifierView?.background self.separator = separator ?? modifierView?.separator - super.init(view: view, role: ComposeModifierRole.listItem) + super.init(view: view) } @Composable func shouldComposeListItem() -> Bool { diff --git a/Sources/SkipUI/SkipUI/Containers/TabView.swift b/Sources/SkipUI/SkipUI/Containers/TabView.swift index 1c44bd5f..d870213f 100644 --- a/Sources/SkipUI/SkipUI/Containers/TabView.swift +++ b/Sources/SkipUI/SkipUI/Containers/TabView.swift @@ -82,7 +82,7 @@ public struct TabView : View { tabViews = content.collectViews(context: tabItemContext).filter { !$0.isSwiftUIEmptyView } } let tabItems = tabViews.map { view in - view.strippingModifiers(until: { $0 == .tabItem }, perform: { $0 as? TabItemModifierView }) + view.strippingModifiers(until: { $0 is TabItemModifierView }, perform: { $0 as? TabItemModifierView }) } let navController = rememberNavController() @@ -366,7 +366,7 @@ struct TabItemModifierView: ComposeModifierView { init(view: View, @ViewBuilder label: () -> any View) { self.label = ComposeBuilder.from(label) - super.init(view: view, role: .tabItem) + super.init(view: view) } @Composable public override func ComposeContent(context: ComposeContext) { diff --git a/Sources/SkipUI/SkipUI/Containers/VStack.swift b/Sources/SkipUI/SkipUI/Containers/VStack.swift index a53db009..50050b2e 100644 --- a/Sources/SkipUI/SkipUI/Containers/VStack.swift +++ b/Sources/SkipUI/SkipUI/Containers/VStack.swift @@ -154,7 +154,7 @@ final class VStackComposer: RenderingComposer { return } // If the Text has spacing modifiers, no longer special case its spacing - let isText = view.strippingModifiers(until: { $0 == .spacing }) { $0 is Text } + let isText = view.strippingModifiers(until: { $0.role == .spacing }) { $0 is Text } var contentContext = context(false) if let lastViewWasText { let spacing = lastViewWasText && isText ? Self.textSpacing : Self.defaultSpacing diff --git a/Sources/SkipUI/SkipUI/Layout/Presentation.swift b/Sources/SkipUI/SkipUI/Layout/Presentation.swift index f2855886..d26a680d 100644 --- a/Sources/SkipUI/SkipUI/Layout/Presentation.swift +++ b/Sources/SkipUI/SkipUI/Layout/Presentation.swift @@ -6,6 +6,7 @@ import Foundation #if SKIP +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -35,6 +36,7 @@ import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.SheetValue import androidx.compose.material3.Surface import androidx.compose.material3.rememberModalBottomSheetState @@ -81,12 +83,15 @@ let overlayPresentationCornerRadius = 16.0 if HandlePresentationSizeClassChange(isPresented: isPresented, isDismissingForSizeClassChange: isDismissingForSizeClassChange) { return } - + + 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() let shouldBePresented = !isDismissingForSizeClassChange.value && isPresentedValue if shouldBePresented || sheetState.isVisible { - let contentView = ComposeBuilder.from(content) + let contentViews = ComposeBuilder.from(content).collectViews(context: context) let topInset = remember { mutableStateOf(0.dp) } let topInsetPx = with(LocalDensity.current) { topInset.value.toPx() } let handleHeight = isFullScreen ? 0.dp : 8.dp @@ -97,10 +102,13 @@ let overlayPresentationCornerRadius = 16.0 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 + let backDismissDisabled = contentViews.first?.strippingModifiers(until: { $0 is BackDismissDisabledModifierView }) { ($0 as? BackDismissDisabledModifierView)?.isDisabled == true } ?? false let onDismissRequest = { isPresented.set(false) } - ModalBottomSheet(onDismissRequest: onDismissRequest, sheetState: sheetState, containerColor: androidx.compose.ui.graphics.Color.Unspecified, shape: shape, dragHandle: nil, contentWindowInsets: { WindowInsets(0.dp, 0.dp, 0.dp, 0.dp) }) { + let properties = ModalBottomSheetProperties(shouldDismissOnBackPress: !backDismissDisabled) + ModalBottomSheet(onDismissRequest: onDismissRequest, sheetState: sheetState, 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) { let isEdgeToEdge = EnvironmentValues.shared._isEdgeToEdge == true let sheetDepth = EnvironmentValues.shared._sheetDepth let verticalSizeClass = EnvironmentValues.shared.verticalSizeClass @@ -109,7 +117,7 @@ let overlayPresentationCornerRadius = 16.0 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 { systemBarEdges.remove(.top) if !isEdgeToEdge { @@ -164,8 +172,8 @@ let overlayPresentationCornerRadius = 16.0 } $0.setdismiss(DismissAction(action: { isPresented.set(false) })) } in: { - PreferenceValues.shared.collectPreferences([detentPreferencesCollector]) { - contentView.Compose(context: context) + PreferenceValues.shared.collectPreferences([interactiveDismissDisabledCollector, detentPreferencesCollector]) { + contentViews.forEach { $0.Compose(context: context) } } } } @@ -825,7 +833,7 @@ extension View { } ) - return sheet(isPresented: isPresented, onDismiss: onDismiss) { + return fullScreenCover(isPresented: isPresented, onDismiss: onDismiss) { if let unwrappedItem = item.wrappedValue { content(unwrappedItem) } @@ -845,6 +853,20 @@ extension View { #endif } + public func interactiveDismissDisabled(_ isDisabled: Bool = true) -> some View { + #if SKIP + return preference(key: InteractiveDismissDisabledPreferenceKey.self, value: isDisabled) + #else + return self + #endif + } + + #if SKIP + public func backDismissDisabled(_ isDisabled: Bool = true) -> some View { + return BackDismissDisabledModifierView(view: self, isDisabled: isDisabled) + } + #endif + public func presentationDetents(_ detents: Set) -> some View { #if SKIP // TODO: Add support for multiple detents @@ -960,4 +982,26 @@ final class PresentationModifierView: ComposeModifierView { view.Compose(context: context) } } + +/// Used disable the back button from dismissing a presentation. +struct BackDismissDisabledModifierView: ComposeModifierView { + let isDisabled: Bool + + init(view: View, isDisabled: Bool) { + self.isDisabled = isDisabled + super.init(view: view) + } +} + +struct InteractiveDismissDisabledPreferenceKey: PreferenceKey { + typealias Value = Bool + + // SKIP DECLARE: companion object: PreferenceKeyCompanion + final class Companion: PreferenceKeyCompanion { + let defaultValue = false + func reduce(value: inout Bool, nextValue: () -> Bool) { + value = nextValue() + } + } +} #endif diff --git a/Sources/SkipUI/SkipUI/System/Gesture.swift b/Sources/SkipUI/SkipUI/System/Gesture.swift index 46d2ce54..666194a5 100644 --- a/Sources/SkipUI/SkipUI/System/Gesture.swift +++ b/Sources/SkipUI/SkipUI/System/Gesture.swift @@ -350,11 +350,11 @@ final class GestureModifierView: ComposeModifierView { var gestures: [ModifiedGesture] init(view: View, gesture: Gesture) { - super.init(view: view, role: ComposeModifierRole.gesture) + super.init(view: view) gestures = [gesture.modified] // Compose wants you to collect all e.g. tap gestures into a single pointerInput modifier, so we collect all our gestures - if let wrappedGestureView = view.strippingModifiers(until: { $0 == .gesture }, perform: { $0 as? GestureModifierView }) { + if let wrappedGestureView = view.strippingModifiers(until: { $0 is GestureModifierView }, perform: { $0 as? GestureModifierView }) { gestures += wrappedGestureView.gestures wrappedGestureView.gestures = [] } @@ -376,7 +376,7 @@ final class GestureModifierView: ComposeModifierView { var ret = modifier // If the gesture is placed directly on a shape, we attempt to constrain hits to the shape - if let shape = view.strippingModifiers(until: { $0 != .accessibility }, perform: { $0 as? ModifiedShape }), let touchShape = shape.asComposeTouchShape(density: density) { + if let shape = view.strippingModifiers(until: { $0.role != .accessibility }, perform: { $0 as? ModifiedShape }), let touchShape = shape.asComposeTouchShape(density: density) { ret = ret.clip(touchShape) } diff --git a/Sources/SkipUI/SkipUI/View/View.swift b/Sources/SkipUI/SkipUI/View/View.swift index 80d11bdb..2bfa9884 100644 --- a/Sources/SkipUI/SkipUI/View/View.swift +++ b/Sources/SkipUI/SkipUI/View/View.swift @@ -99,8 +99,8 @@ extension View { /// Strip modifier views. /// - /// - Parameter until: Return `true` to stop stripping at a modifier with a given role. - public func strippingModifiers(until: (ComposeModifierRole) -> Bool = { _ in false }, perform: (any View?) -> R) -> R { + /// - Parameter until: Return `true` to stop stripping at a modifier with a given modifier. + public func strippingModifiers(until: (ComposeModifierView) -> Bool = { _ in false }, perform: (any View?) -> R) -> R { return perform(self) } } @@ -578,11 +578,6 @@ extension View { return self } - @available(*, unavailable) - public func interactiveDismissDisabled(_ isDisabled: Bool = true) -> some View { - return self - } - @available(*, unavailable) public func keyboardShortcut(_ key: KeyEquivalent, modifiers: EventModifiers = .command) -> some View { return self @@ -803,7 +798,7 @@ extension View { public func padding(_ insets: EdgeInsets) -> some View { #if SKIP // Certain views apply their padding themselves - guard !strippingModifiers(until: { $0 == .spacing }, perform: { + guard !strippingModifiers(until: { $0.role == .spacing }, perform: { $0 is LazyVGrid || $0 is LazyHGrid || $0 is LazyVStack || $0 is LazyHStack }) else { return environment(\._contentPadding, insets) @@ -1148,7 +1143,7 @@ struct TagModifierView: ComposeModifierView { /// Extract the existing tag modifier view from the given view's modifiers. static func strip(from view: View, role: ComposeModifierRole) -> TagModifierView? { - return view.strippingModifiers(until: { $0 == role }, perform: { $0 as? TagModifierView }) + return view.strippingModifiers(until: { $0.role == role }, perform: { $0 as? TagModifierView }) } } @@ -1163,7 +1158,7 @@ final class ZIndexModifierView : ComposeModifierView { init(targetView: View, zIndex: Double) { self.zIndex = zIndex - super.init(targetView: targetView, role: .zIndex) { + super.init(targetView: targetView) { if zIndex != 0.0 { $0.modifier = $0.modifier.zIndex(Float(zIndex)) }