Drag to Dismiss
A modifier that lets a photo, card, or sheet follow a two-axis drag, rounds and shrinks it with progress over a styled scrim, reports its phase so the UI can say "release to close", then springs back or flies off along the drag vector past a distance or velocity threshold.
- Type
- DragToDismiss
- Files
- DragToDismiss.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 2.0.0
Gallery
A trip album that pages with depth, captions on color blocks, and a pinch to peek at the centred photo.
Photos-style dismiss: drag in any direction, the view rounds and shrinks as it travels, the scrim thins, and a far enough drag or a fast flick sends it off screen along the direction it was moving.
Notes
- Apply it to the full-size root of a sheet or full-screen cover, or to a card wrapped in
.frame(maxWidth: .infinity, maxHeight: .infinity), so the scrim covers the screen while only the content moves. - Commit happens past
thresholdpoints of travel or whenpredictedEndTranslationprojects past twice that. The fly-off follows the release direction, travels far enough to clear the container, and runs faster for faster flicks.onDismissfires when the fly-off is logically complete. - Scroll cooperation: a scroll view inside the content keeps priority until it is at its top. On iOS 18 the modifier watches the scroll view with
onScrollGeometryChange; on iOS 17 feed itscrollOffsetyourself. Upward drags at the top go to the scroll view, and the scroll view is disabled for the duration of a captured drag so it cannot bounce underneath. - Bind
progressand scale your presenter from 0.94 toward 1 with it for sheet-like depth. - Phases run idle, dragging, armed (a rigid tick at the threshold), returning, committing. VoiceOver's escape gesture commits a downward dismiss. Reduce Motion keeps the scale at 1 and shortens the springs.
style:sets the scrim (#141414at 0.5 by default,.clearfor none) and the shrink at full progress.phase:binds the current phase so the UI can switch a "Drag to close" hint to "Release to close" when armed.- The
#Previewis the single card the modifier moves: a butter boarding pass, with the modifier's own scrim behind it. - For a full gallery built around this gesture, see the Pro Gallery screen.
Usage
DragToDismissExample().preferredColorScheme(.light)Parameters
| Parameter | Description |
|---|---|
threshold | Drag distance in points that arms the dismiss. A flick whose projected travel passes twice this commits from a shorter drag. |
cornerRadius | Corner radius the view reaches at full progress; it starts square so full-screen content keeps its edges at rest. |
progress | Optional binding that receives the dismiss progress (0 at rest, 1 armed) so the presenter can scale its background from 0.94 back to 1. |
scrollOffset | Vertical offset of a scroll view inside the content. The drag is only captured at 0. On iOS 18 the modifier observes the inner scroll view itself. |
phase | Optional binding that receives the current phase (idle, dragging, armed, returning, committing), for a "Release to close" hint or to dim chrome while dragging. |
style | Scrim color and opacity at rest, and how much the view shrinks at full progress. .standard is a near-black #141414 scrim at 0.5 with a 0.15 shrink. |
onDismiss | Called once the view has flown off; end the presentation here. |
Source
import SwiftUI
/// Photos-style dismiss: drag in any direction, the view rounds and shrinks as it travels, the scrim thins,
/// and a far enough drag or a fast flick sends it off screen along the direction it was moving.
///
/// - Parameters:
/// - threshold: Drag distance in points that arms the dismiss. A flick whose projected travel passes twice this commits from a shorter drag.
/// - cornerRadius: Corner radius the view reaches at full progress; it starts square so full-screen content keeps its edges at rest.
/// - progress: Optional binding that receives the dismiss progress (0 at rest, 1 armed) so the presenter can scale its background from 0.94 back to 1.
/// - scrollOffset: Vertical offset of a scroll view inside the content. The drag is only captured at 0. On iOS 18 the modifier observes the inner scroll view itself.
/// - phase: Optional binding that receives the current phase (idle, dragging, armed, returning, committing), for a "Release to close" hint or to dim chrome while dragging.
/// - style: Scrim color and opacity at rest, and how much the view shrinks at full progress. `.standard` is a near-black `#141414` scrim at 0.5 with a 0.15 shrink.
/// - onDismiss: Called once the view has flown off; end the presentation here.
public extension View {
func dragToDismiss(
threshold: CGFloat = 110,
cornerRadius: CGFloat = 28,
progress: Binding<CGFloat>? = nil,
scrollOffset: CGFloat = 0,
phase: Binding<DragToDismiss.Phase>? = nil,
style: DragToDismiss.Style = .standard,
onDismiss: @escaping () -> Void
) -> some View {
modifier(DragToDismiss(threshold: threshold, cornerRadius: cornerRadius, progress: progress, scrollOffset: scrollOffset, phase: phase, style: style, onDismiss: onDismiss))
}
}
/// Two-axis drag with a five-phase state machine; the fly-off distance and duration come from the release velocity.
public struct DragToDismiss: ViewModifier {
public enum Phase: Sendable { case idle, dragging, armed, returning, committing }
/// How the scrim and the traveling view look.
public struct Style: Sendable {
/// Scrim color behind the view.
public var scrim: Color
/// Scrim opacity at rest; it thins to zero with progress.
public var scrimOpacity: Double
/// Fraction the view shrinks by at full progress, 0–0.5.
public var shrink: CGFloat
public init(scrim: Color = Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255), scrimOpacity: Double = 0.5, shrink: CGFloat = 0.15) {
self.scrim = scrim
self.scrimOpacity = scrimOpacity
self.shrink = shrink
}
/// Near-black `#141414` scrim at 0.5, 0.15 shrink.
public static let standard = Style()
/// No scrim, for cards that sit over live content.
public static let clear = Style(scrimOpacity: 0)
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var phase: Phase = .idle
@State private var translation: CGSize = .zero
@State private var containerSize = CGSize(width: 390, height: 844)
@State private var observedScrollOffset: CGFloat = 0
@State private var hasScrollView = false
@State private var yielded = false
private let threshold: CGFloat
private let cornerRadius: CGFloat
private let progressBinding: Binding<CGFloat>?
private let scrollOffset: CGFloat
private let phaseBinding: Binding<Phase>?
private let style: Style
private let onDismiss: () -> Void
public init(
threshold: CGFloat = 110,
cornerRadius: CGFloat = 28,
progress: Binding<CGFloat>? = nil,
scrollOffset: CGFloat = 0,
phase: Binding<Phase>? = nil,
style: Style = .standard,
onDismiss: @escaping () -> Void
) {
self.phaseBinding = phase
self.style = style
self.threshold = threshold
self.cornerRadius = cornerRadius
self.progressBinding = progress
self.scrollOffset = scrollOffset
self.onDismiss = onDismiss
}
private var distance: CGFloat { hypot(translation.width, translation.height) }
private var progress: CGFloat { phase == .committing ? 1 : min(1, distance / threshold) }
private var atTop: Bool { scrollOffset <= 0.5 && observedScrollOffset <= 0.5 }
public func body(content: Content) -> some View {
let dragging = phase == .dragging || phase == .armed
content
.scrollDisabled(dragging)
.modifier(ScrollObserver(offset: $observedScrollOffset, seen: $hasScrollView))
.clipShape(.rect(cornerRadius: cornerRadius * progress, style: .continuous))
.scaleEffect(reduceMotion ? 1 : 1 - min(max(style.shrink, 0), 0.5) * progress)
.offset(translation)
.opacity(phase == .committing ? 0 : 1)
.background {
// Sits in the layout frame, so it stays put while the content travels.
style.scrim
.opacity(style.scrimOpacity * (1 - progress))
.ignoresSafeArea()
}
.background {
GeometryReader { proxy in
Color.clear.onAppear { containerSize = proxy.size }
.onChange(of: proxy.size) { _, size in containerSize = size }
}
}
.simultaneousGesture(drag)
.sensoryFeedback(.impact(flexibility: .rigid), trigger: phase) { _, new in new == .armed }
.onChange(of: progress) { _, new in progressBinding?.wrappedValue = new }
.onChange(of: phase) { _, new in phaseBinding?.wrappedValue = new }
.accessibilityAction(.escape) { commit(toward: CGSize(width: 0, height: 1), speed: 0) }
}
private var drag: some Gesture {
DragGesture(minimumDistance: 4, coordinateSpace: .local)
.onChanged { value in
guard phase != .committing else { return }
if phase == .idle || phase == .returning {
// Decide once per gesture whether this drag belongs to an inner scroll view.
let upward = value.translation.height < 0 && abs(value.translation.height) > abs(value.translation.width)
yielded = !atTop || (hasScrollView && upward)
if yielded { return }
}
guard !yielded else { return }
translation = value.translation
let armed = distance > threshold
withAnimation(.interactiveSpring(duration: 0.15)) { phase = armed ? .armed : .dragging }
}
.onEnded { value in
defer { yielded = false }
guard !yielded, phase == .dragging || phase == .armed else { return }
let predicted = value.predictedEndTranslation
let flick = CGSize(width: predicted.width - translation.width, height: predicted.height - translation.height)
let projected = hypot(predicted.width, predicted.height)
if distance > threshold || projected > threshold * 2 {
let speed = hypot(flick.width, flick.height)
let direction = speed > 40 ? flick : translation
commit(toward: direction, speed: speed)
} else {
phase = .returning
withAnimation(.spring(duration: reduceMotion ? 0.2 : 0.45, bounce: reduceMotion ? 0 : 0.28)) {
translation = .zero
} completion: {
if phase == .returning { phase = .idle }
}
}
}
}
/// Flies off along `direction`, far enough to clear the container; faster flicks get shorter animations.
private func commit(toward direction: CGSize, speed: CGFloat) {
let length = max(hypot(direction.width, direction.height), 1)
let unit = CGSize(width: direction.width / length, height: direction.height / length)
let exit = hypot(containerSize.width, containerSize.height) * 1.1
let travel = max(exit, speed * 1.5)
let end = CGSize(width: translation.width + unit.width * travel, height: translation.height + unit.height * travel)
// predictedEndTranslation assumes ~0.25 s of deceleration, so speed / 0.25 is points per second.
let duration = reduceMotion ? 0.2 : min(0.45, max(0.22, travel / max(speed * 4, 1)))
withAnimation(.spring(duration: duration, bounce: 0), completionCriteria: .logicallyComplete) {
phase = .committing
translation = end
} completion: {
onDismiss()
}
}
/// iOS 18 reads the inner scroll view directly; earlier systems rely on the `scrollOffset` parameter.
private struct ScrollObserver: ViewModifier {
@Binding var offset: CGFloat
@Binding var seen: Bool
func body(content: Content) -> some View {
if #available(iOS 18, *) {
content.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y + geometry.contentInsets.top
} action: { _, new in
seen = true
offset = new
}
} else {
content
}
}
}
}
// MARK: - Example
private func dragColor(_ hex: UInt32) -> Color {
Color(uiColor: dragUIColor(hex))
}
/// A color that resolves to `light` or `dark` with the current appearance.
private func dragColor(light: UInt32, dark: UInt32) -> Color {
let l = dragUIColor(light), d = dragUIColor(dark)
return Color(uiColor: UIColor { $0.userInterfaceStyle == .dark ? d : l })
}
private func dragUIColor(_ hex: UInt32) -> UIColor {
UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
}
/// The card the modifier moves, and nothing else. Drag it anywhere: it rounds and shrinks as it travels, the modifier's
/// own scrim thins with it, and a far drag or a fast flick sends it off along the drag. It returns so it can be tried again.
private struct DragToDismissExample: View {
@State private var shown = true
var body: some View {
ZStack {
if shown {
Self.pass
.frame(maxWidth: .infinity, maxHeight: .infinity)
.dragToDismiss(cornerRadius: 40) { shown = false }
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(dragColor(light: 0xF3F2EE, dark: 0x121212))
.ignoresSafeArea()
.task(id: shown) {
guard !shown else { return }
try? await Task.sleep(for: .seconds(0.9))
withAnimation(.smooth(duration: 0.35)) { shown = true }
}
}
/// A butter boarding pass with the route as a heavy headline and times as light numerals.
static var pass: some View {
let ink = dragColor(0x141414)
return VStack(alignment: .leading, spacing: 0) {
HStack {
Text("BOARDING PASS")
.font(.caption.weight(.bold))
.tracking(1)
Spacer()
Text("Group 2")
.font(.footnote.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(dragColor(0x9CC2FF), in: .capsule)
}
Text("SFO\n\(Text("to LIS").foregroundStyle(ink.opacity(0.55)))")
.font(.system(size: 56, weight: .bold))
.tracking(-2.4)
.padding(.top, 28)
HStack(alignment: .firstTextBaseline, spacing: 28) {
field("DEPARTS", "07:45")
field("GATE", "B12")
field("SEAT", "14A")
}
.padding(.top, 28)
Rectangle()
.fill(ink.opacity(0.14))
.frame(height: 1.5)
.padding(.vertical, 22)
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Maya Lindqvist").font(.headline)
Text("Flight SP 208 · Boards 07:10").font(.subheadline).opacity(0.62)
}
Spacer()
Image(systemName: "qrcode")
.font(.system(size: 40))
}
}
.foregroundStyle(ink)
.padding(24)
.frame(width: 330)
.background(dragColor(0xFFD976), in: .rect(cornerRadius: 34, style: .continuous))
.shadow(color: .black.opacity(0.28), radius: 30, y: 16)
.accessibilityElement(children: .combine)
.accessibilityLabel("Boarding pass, San Francisco to Lisbon, departs 7:45, gate B12, seat 14A")
.accessibilityHint("Drag or use the escape gesture to close")
}
private static func field(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.caption2.weight(.bold)).tracking(1).opacity(0.6)
Text(value).font(.system(size: 30, weight: .light)).monospacedDigit().tracking(-0.8)
}
}
}
#Preview("Light") {
DragToDismissExample().preferredColorScheme(.light)
}
#Preview("Dark") {
DragToDismissExample().preferredColorScheme(.dark)
}Install
Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.
Terminal
Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected
Or copy the source above into your app.
Building a whole app? See Swift Pieces Pro →
Status Morph
One continuous stroke that spins as a loading arc, closes into a ring, floods into a solid block and draws a check in dark ink on success, or a cross with a nudge on failure, with an optional morphing caption.
Live Stat
A metric tile with a light display-scale value that rolls with a spring and dims its decimals, a solid delta block, a sparkline you hold and scrub to retarget the value live, a press that expands the line into a taller chart with low and high, and a solid color block variant.