Skip to content

Instantly share code, notes, and snippets.

@AndrewBennet
Last active December 30, 2024 10:41
Show Gist options
  • Save AndrewBennet/7e04fb73dc602f4c578e2e39cc4e344b to your computer and use it in GitHub Desktop.
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
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