Skip to content

About

Swipe from the screen edge to close modal screens in UIKit and SwiftUI. An animated bubble indicator grows from the edge while the content stays still. Fully customizable look, gesture and animations.

Resources

Stars

7 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

EdgeSwipeBack

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.

EdgeSwipeBack demo

Default bubble, frosted glass, custom SwiftUI ring and custom UIKit pill indicators

Default · Frosted glass · Custom SwiftUI indicator · Custom UIKit indicator — full video

Features

  • 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 UIView that adopts EdgeSwipeBackIndicatorView, 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.

Requirements

  • iOS 15+
  • Swift 6 / Xcode 26+

Installation

Swift Package Manager

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"])
]

Quick start

SwiftUI

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)
        }
}

UIKit

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)

Customization

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)

Behavior

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.

Appearance

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.

Animation

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.

Custom indicators

The interaction sends a fresh EdgeSwipeBackIndicatorContext on every gesture change and every animation frame. The context includes:

  • state.progress: visual progress, where 1 means fully grown (can go past 1 while rubber-banding)
  • state.dragProgress: raw translation / triggerDistance
  • state.isArmed: true when releasing now would trigger the action
  • state.phase: .dragging, .cancelling, .committing or .idle
  • state.opacity: extra opacity applied by commit effects
  • state.edge: the physical edge, .left or .right
  • anchor: the point on the edge where the indicator should be centered, already clamped to the safe area
  • containerSize and configuration

SwiftUI

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.

UIKit

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.

How it works

  • EdgeSwipeBackInteraction adds 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.

Demo app

The Demo folder contains an app with all SwiftUI and UIKit examples, plus a Playground where you can tune every setting live.

Demo home screen Midnight style Trailing edge Playground

The Xcode project is generated with XcodeGen:

cd Demo
xcodegen generate
open EdgeSwipeBackDemo.xcodeproj

To 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.

Running tests

xcodebuild test -scheme EdgeSwipeBack -destination 'platform=iOS Simulator,name=iPhone 17 Pro'

License

EdgeSwipeBack is available under the MIT license. See LICENSE for details.

About

Swipe from the screen edge to close modal screens in UIKit and SwiftUI. An animated bubble indicator grows from the edge while the content stays still. Fully customizable look, gesture and animations.

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages