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
func fullScreenCover(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> any View) -> some View
- - Note that covers are dismissible via swipe and the back button on Android.
+ - See Modals
@@ -1223,6 +1223,17 @@ Support levels:
✅ |
.inset |
+
+ ✅ |
+
+
+ .interactiveDismissDisabled
+
+
+ |
+
✅ |
.italic |
@@ -1563,6 +1574,7 @@ Support levels:
.sheet
(example)
func sheet(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> any View) -> some View
+ - See Modals
@@ -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))
}