Swipe from the screen edge to close modal screens — for UIKit and SwiftUI.
iOS gives you swipe-to-go-back inside navigation stacks, but modal screens (sheets, full screen covers, present(_:animated:)) can only be closed with a button or by swiping down. EdgeSwipeBack adds the familiar edge swipe to any modal screen, with an animated indicator instead of moving the content: a soft bubble with a chevron grows out of the edge, follows your finger, and the screen closes when you let go.
Every color, size, curve, haptic and animation can be customized, and you can replace the indicator with your own UIKit or SwiftUI view.
Default · Frosted glass · Custom SwiftUI indicator · Custom UIKit indicator — full video
- UIKit and SwiftUI:
controller.addEdgeSwipeBack()or.edgeSwipeBack(). - Matches the native feel: the content stays put, the bubble follows the finger vertically, rubber-bands past the threshold and gives haptic feedback when releasing would close the screen.
- Fully customizable: bubble color, size, curvature, blur, border and shadow; icon, colors and scale; trigger distance, fling velocity, hit area; progress curve, cancel spring, commit effect and dismiss animation.
- Bring your own indicator: any
UIViewthat adoptsEdgeSwipeBackIndicatorView, or any SwiftUI view. - Works alongside other gestures: scroll views, lists and navigation pop gestures wait for the edge swipe only while a touch starts inside the edge area.
- Leading or trailing edge, with automatic right-to-left support.
- Smart default action: pops when there is something to pop, otherwise dismisses the modal.
- Respects Reduce Motion, has no dependencies, and is built with Swift 6 strict concurrency.
- iOS 15+
- Swift 6 / Xcode 26+
In Xcode, choose File ▸ Add Package Dependencies… and enter the repository URL. Or add it to Package.swift:
dependencies: [
.package(url: "https://github.com/<your-account>/EdgeSwipeBack.git", from: "1.0.0")
],
targets: [
.target(name: "YourApp", dependencies: ["EdgeSwipeBack"])
]Apply .edgeSwipeBack() to the root view of the presented content:
import EdgeSwipeBack
struct ContentView: View {
@State private var isPresented = false
var body: some View {
Button("Add device") { isPresented = true }
.fullScreenCover(isPresented: $isPresented) {
AddDeviceView()
.edgeSwipeBack()
}
}
}By default the modifier calls the environment's dismiss without animation, like the reference design. Pass dismissAnimated: true to keep the system animation, or provide your own action:
AddDeviceView()
.edgeSwipeBack(dismissAnimated: true)
AddDeviceView()
.edgeSwipeBack { router.close() }Disable the gesture temporarily, for example while there are unsaved changes:
EditDeviceView()
.edgeSwipeBack(isEnabled: !hasUnsavedChanges)Inside a NavigationStack, apply the modifier to pushed screens as well. Environment dismiss pops them, and on the root screen it closes the modal:
NavigationStack {
DeviceList()
.edgeSwipeBack()
.navigationDestination(for: Device.self) { device in
DeviceDetails(device: device)
.edgeSwipeBack(dismissAnimated: true)
}
}import EdgeSwipeBack
let controller = AddDeviceViewController()
controller.modalPresentationStyle = .fullScreen
controller.addEdgeSwipeBack()
present(controller, animated: true)Choose what happens when the gesture is committed:
controller.addEdgeSwipeBack(action: .automatic()) // pop if possible, otherwise dismiss (default)
controller.addEdgeSwipeBack(action: .dismiss(animated: true)) // always dismiss
controller.addEdgeSwipeBack(action: .pop()) // always pop
controller.addEdgeSwipeBack(action: .custom { coordinator.finish() })For a modal UINavigationController, install it on the navigation controller. .automatic pops pushed screens and closes the modal from the root:
let navigation = UINavigationController(rootViewController: DeviceListViewController())
navigation.addEdgeSwipeBack()
present(navigation, animated: true)addEdgeSwipeBack returns the underlying EdgeSwipeBackInteraction, a regular UIInteraction that you can also attach to any view directly:
let interaction = EdgeSwipeBackInteraction { [weak self] in
self?.dismiss(animated: false)
}
interaction.shouldBegin = { [weak self] in self?.hasUnsavedChanges == false }
interaction.onStateChange = { state in print(state.progress, state.isArmed) }
view.addInteraction(interaction)Everything lives in EdgeSwipeBackConfiguration, which is split into behavior, appearance and animation. The defaults reproduce the reference design.
var configuration = EdgeSwipeBackConfiguration(edge: .leading)
// Behavior
configuration.behavior.triggerDistance = 90
configuration.behavior.edgeHitWidth = 32
configuration.behavior.armHaptic = .impact(.medium)
// Bubble
configuration.appearance.bubbleColor = UIColor.systemBackground.withAlphaComponent(0.2)
configuration.appearance.armedBubbleColor = .systemBlue
configuration.appearance.blurStyle = .systemThinMaterial
configuration.appearance.bubbleWidth = 46
configuration.appearance.bubbleHeight = 220
configuration.appearance.bubbleCurvature = 0.7
// Icon
configuration.appearance.icon = UIImage(systemName: "arrow.left")
configuration.appearance.iconColor = .systemBlue
configuration.appearance.armedIconColor = .white
// Animation
configuration.animation.progressCurve = .rubberBand(overshoot: 0.35)
configuration.animation.commitEffect = .expand
configuration.animation.cancelTiming = .spring(response: 0.45, dampingRatio: 0.6)
// SwiftUI
DetailsView().edgeSwipeBack(configuration: configuration)
// UIKit
controller.addEdgeSwipeBack(configuration: configuration)| Property | Default | Description |
|---|---|---|
edge |
.leading |
.leading, .trailing, .left or .right. Leading and trailing follow the layout direction. |
edgeHitWidth |
28 |
Width of the area next to the edge where the gesture can start. |
triggerDistance |
80 |
Distance the finger has to travel before releasing commits the gesture. |
velocityThreshold |
900 |
A fling faster than this (pt/s) commits early. A fling back towards the edge always cancels. |
minimumFlingDistance |
32 |
Minimum distance for a fling to commit. |
tracksTouchVertically |
true |
Whether the indicator follows the finger up and down. |
verticalPadding |
8 |
Keeps the indicator center this far inside the safe area. |
armHaptic / disarmHaptic |
.impact(.light) / nil |
Haptic feedback when crossing the threshold. |
actionTiming |
.immediately |
Fire the action on release, or .afterCommitAnimation. |
takesPriorityOverScrollViews |
true |
Scroll views wait for the edge swipe when a touch starts in the edge area. |
| Property | Default | Description |
|---|---|---|
bubbleColor / armedBubbleColor |
light gray, dark gray in Dark Mode / nil |
Fill colors. Dynamic colors are supported. |
bubbleWidth |
34 |
How far the bubble sticks out at full progress. |
bubbleHeight |
168 |
Height of the bubble at full progress. |
bubbleHeightGrowth |
0.2 |
Fraction of the height that grows with progress. |
bubbleCurvature |
0.55 |
0 gives a sharp spike, 1 a round bump. |
blurStyle |
nil |
Optional blur behind the bubble, masked to its shape. |
borderColor / borderWidth |
nil / 0 |
Optional outline. |
shadowColor / shadowOpacity / shadowRadius / shadowOffset |
black / 0.08 / 10 / .zero |
Bubble shadow. |
fadesInWithProgress |
true |
Fade the bubble in as it grows. |
icon |
chevron.left |
Any image. SF Symbols get iconPointSize and iconWeight applied. |
iconColor / armedIconColor |
gray / .label |
Icon tint. |
armedIconScale |
1.12 |
Icon scale while armed. |
iconPosition |
0.42 |
Horizontal icon position as a fraction of the bubble width. |
mirrorsIconForRightEdge |
true |
Flip the icon when the gesture comes from the right edge. |
| Property | Default | Description |
|---|---|---|
progressCurve |
.rubberBand(overshoot: 0.18) |
Maps drag distance to visual progress: .linear, .rubberBand, .easeOut or .custom { … }. |
cancelTiming |
.spring(response: 0.32, dampingRatio: 0.82) |
How the indicator retracts when cancelled: .spring, .easeOut, .easeInOut or .linear. |
commitEffect |
.fade |
What the indicator does on commit: .none, .fade, .collapse or .expand. |
commitTiming |
.easeOut(duration: 0.15) |
Timing of the commit effect. |
armTransitionDuration |
0.15 |
Duration of the color and scale change when crossing the threshold. |
respectsReduceMotion |
true |
Replace springs with short ease-out curves when Reduce Motion is on. |
The interaction sends a fresh EdgeSwipeBackIndicatorContext on every gesture change and every animation frame. The context includes:
state.progress: visual progress, where1means fully grown (can go past1while rubber-banding)state.dragProgress: rawtranslation / triggerDistancestate.isArmed:truewhen releasing now would trigger the actionstate.phase:.dragging,.cancelling,.committingor.idlestate.opacity: extra opacity applied by commit effectsstate.edge: the physical edge,.leftor.rightanchor: the point on the edge where the indicator should be centered, already clamped to the safe areacontainerSizeandconfiguration
The indicator view fills the whole container and ignores safe areas, so you can position it with .position(_:):
DetailsView()
.edgeSwipeBack { context in
let progress = context.state.progress
Circle()
.fill(context.state.isArmed ? Color.accentColor : Color(.systemBackground))
.overlay(Image(systemName: "arrow.backward"))
.frame(width: 52, height: 52)
.shadow(radius: 8)
.opacity(context.state.opacity * min(1, progress * 3))
.position(x: -26 + 54 * min(progress, 1.3), y: context.anchor.y)
}You can reuse the built-in bubble shape with your own styling:
.edgeSwipeBack { context in
EdgeSwipeBackBubbleShape(context: context)
.fill(LinearGradient(colors: [.purple, .blue], startPoint: .top, endPoint: .bottom))
}context.bubbleGeometry returns the bubble's path, tip and bounding rect if you want to place extra decorations.
Adopt EdgeSwipeBackIndicatorView. The view is sized to the container and receives every frame:
final class PillIndicatorView: UIView, EdgeSwipeBackIndicatorView {
private let pill = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
pill.layer.cornerRadius = 20
addSubview(pill)
}
required init?(coder: NSCoder) { fatalError() }
func update(with context: EdgeSwipeBackIndicatorContext) {
let progress = min(context.state.progress, 1)
pill.frame = CGRect(x: -60 + 72 * progress, y: context.anchor.y - 20, width: 60, height: 40)
pill.backgroundColor = context.state.isArmed ? .systemRed : .label
alpha = context.state.opacity
}
}
controller.addEdgeSwipeBack(indicatorView: PillIndicatorView())To use a SwiftUI indicator from UIKit, wrap it in EdgeSwipeBackHostingIndicatorView:
controller.addEdgeSwipeBack(
indicatorView: EdgeSwipeBackHostingIndicatorView { context in
RingIndicator(context: context)
}
)You can also subclass EdgeSwipeBackBubbleView and call super.update(with:) to add decorations to the default bubble.
EdgeSwipeBackInteractionadds a pan gesture recognizer that fails immediately when a touch starts outside the edge area, so taps and scrolling elsewhere are never delayed.- Inside the edge area, the gesture only begins for a mostly horizontal swipe away from the edge. Scroll views and navigation pop gestures are set up to wait for it.
- A small display-link animator drives the cancel and commit animations, so custom indicators, including SwiftUI ones, get every frame.
- The SwiftUI modifier installs the same interaction on the view of the nearest hosting controller: the sheet, full screen cover or navigation destination containing the modified view.
The Demo folder contains an app with all SwiftUI and UIKit examples, plus a Playground where you can tune every setting live.
The Xcode project is generated with XcodeGen:
cd Demo
xcodegen generate
open EdgeSwipeBackDemo.xcodeprojTo open a specific example directly (handy for screenshots), launch with -demoExample <name>. Names: sheet, fullScreen, glass, midnight, rightEdge, ringIndicator, gradientBubble, navigation, guarded, uikit.pageSheet, uikit.customView, uikit.navigation, playground, and so on.
xcodebuild test -scheme EdgeSwipeBack -destination 'platform=iOS Simulator,name=iPhone 17 Pro'EdgeSwipeBack is available under the MIT license. See LICENSE for details.





