Last active
December 30, 2024 10:41
-
-
Save AndrewBennet/7e04fb73dc602f4c578e2e39cc4e344b to your computer and use it in GitHub Desktop.
Defines a view extension like `interactiveDismissDisabled(_:)`, but one that accepts a callback that is invoked when the sheet's dismissal was attempted by the user by dragging the view
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
/// Modifier for detecting dismiss attempts via user swipe whilst those are disabled. | |
struct DismissAttemptDetectionModifier: ViewModifier { | |
/// Whether dismiss is to be disabled. When `false`, the view modifier has no effect. | |
let dismissDisabled: Bool | |
/// Called if the sheet is dragged down in a fashion considered to be a dismiss attempt, whilst `dismissDisabled` is `true`. | |
let onDismissAttempted: () -> Void | |
/// The normal global minY value of the content. | |
@State private var frameMinY: CGFloat? | |
/// The current distance that the sheet has been dragged, from its normal resting position. | |
@State private var dragDistance: CGFloat = 0 | |
/// Whether the user is currently dragging the sheet further down than is required to be considered a dismiss attempt. | |
@State private var isPerformingLargeDrag: Bool = false | |
/// The distance that a user must drag the view before it is considered a dismiss attempt. | |
private let dragDistanceThreshold: CGFloat = 40 | |
private func captureFrameMinY(from geometry: GeometryProxy) { | |
frameMinY = geometry.frame(in: .global).minY | |
} | |
func body(content: Content) -> some View { | |
content | |
.background { | |
GeometryReader { geometry in | |
Color.clear | |
.onAppear { | |
// Once this view appears, remember the minY | |
captureFrameMinY(from: geometry) | |
} | |
.onChange(of: geometry.size) { _ in | |
// If the frame size changes, recalculate the minY | |
captureFrameMinY(from: geometry) | |
} | |
// If the keyboard is shown or hidden, capture the min Y, because | |
// we might be presented as a form, in which case the keyboard showing | |
// will cause the minY position to change. | |
.task { | |
for await _ in NotificationCenter.default.notifications(named: UIResponder.keyboardDidShowNotification).map({ _ in }) { | |
captureFrameMinY(from: geometry) | |
} | |
} | |
.task { | |
for await _ in NotificationCenter.default.notifications(named: UIResponder.keyboardDidHideNotification).map({ _ in }) { | |
captureFrameMinY(from: geometry) | |
} | |
} | |
.onChange(of: geometry.frame(in: .global).minY) { newMinY in | |
guard let frameMinY else { | |
// Capture the minY if this code runs and we have not captured it yet. | |
frameMinY = newMinY | |
return | |
} | |
if !dismissDisabled { return } | |
// Calculate the drag distance: the distance between the current Y position | |
// and the frame's minY position. | |
dragDistance = newMinY - frameMinY | |
// If the dragDistance is 0, then the drag has either been released or it has been | |
// carefully dragged back to its original position. If, however, isPerformingLargeDrag | |
// is true, then that means the last change of the Y position was a fairly large drag. | |
// Thus, the change must be from a drag release from large drag position, which is | |
// considered a dismiss attempt. If isPerformingLargeDrag is false, then the release was | |
// either from a small drag, or the drag was not dropped but dragged all the way back to | |
// the resting position. | |
if dragDistance == 0 && isPerformingLargeDrag { | |
isPerformingLargeDrag = false | |
// Run the callback in the next run loop, to workaround inscrutable SwiftUI crashes | |
// that were observed if this callback was used to present a confirmation dialog. | |
Task { | |
onDismissAttempted() | |
} | |
return | |
} | |
// See above: a "large drag" is one where the current position is sufficiently large in size. | |
isPerformingLargeDrag = dragDistance > dragDistanceThreshold | |
} | |
} | |
} | |
// Use the normal function that disables drag-to-dismiss. | |
.interactiveDismissDisabled(dismissDisabled) | |
} | |
} | |
extension View { | |
/** | |
Conditionally prevents interactive dismissal of sheet presentations, and calls the provided callback if dismiss was attempted. | |
*/ | |
func interactiveDismissDisabled(_ isDisabled: Bool = true, onDismissAttempt action: @escaping () -> Void) -> some View { | |
self.modifier(DismissAttemptDetectionModifier(dismissDisabled: isDisabled, onDismissAttempted: action)) | |
} | |
} | |
@available(iOS 17.0, *) | |
#Preview { | |
@Previewable @State var isPresented = false | |
@Previewable @State var confirmationPresented = false | |
Button("Present") { | |
isPresented = true | |
}.sheet(isPresented: $isPresented) { | |
List { | |
ForEach(0...25, id: \.self) { number in | |
Text("\(number)") | |
} | |
} | |
.alert("Dismiss Attempted", isPresented: $confirmationPresented) { | |
Button("Cancel") { } | |
Button("Dismiss") { | |
isPresented = false | |
} | |
} | |
.interactiveDismissDisabled(true) { | |
confirmationPresented = true | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment