As an iOS Developer, you know that user experience (UX) is everything. The most successful apps in the Apple ecosystem not only work well but feel natural, fluid, and intuitive. One of the most ingrained gestures in the muscle memory of iPhone and iPad users is swiping down to close a screen or modal.
In this comprehensive tutorial, we are going to dive into the world of Swift programming to learn how to implement a swipe to dismiss in SwiftUI. We will explore everything from the simplest native solutions to creating a fully custom gesture system using DragGesture, ensuring that our Swift code is elegant, scalable, and works in cross-platform projects (iOS, macOS, and watchOS) using Xcode.
1. The “Swipe to Dismiss” Paradigm in the Apple Ecosystem
Before writing a single line of code in Xcode, it’s crucial to understand how and when to apply this design pattern.
In iOS, the swipe to dismiss in SwiftUI is the gold standard for modal views (sheets). When a user opens a detail view, settings, or a quick form, they expect to be able to close it by dragging it towards the bottom of the screen. On watchOS, swipes are fundamental due to the small screen size. However, on macOS, the paradigm changes drastically: Mac users interact with a cursor (or trackpad) and expect close buttons (X) or the use of the Escape key.
As a modern iOS Developer, your goal when using SwiftUI is to write code that intelligently adapts to each platform without duplicating efforts.
2. The Native Solution: Using .sheet in SwiftUI
If you are developing a standard app and need to present a view that the user can dismiss by swiping, SwiftUI does the heavy lifting for you. Since its early versions, the .sheet modifier includes this behavior by default on iOS.
Open Xcode, create a new cross-platform project, and take a look at this basic implementation:
import SwiftUI
struct NativeSwipeToDismissView: View {
@State private var showSheet = false
var body: some View {
VStack {
Button("Show Native Modal") {
showSheet = true
}
.buttonStyle(.borderedProminent)
.padding()
}
.sheet(isPresented: $showSheet) {
DetailView()
// From iOS 16 onwards, we can add a visual indicator
.presentationDragIndicator(.visible)
}
}
}
struct DetailView: View {
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationView {
ZStack {
Color.blue.opacity(0.1).ignoresSafeArea()
Text("Swipe down to close")
.font(.headline)
}
.navigationTitle("Detail")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Close") {
dismiss()
}
}
}
}
}
}
Advantages of the native method:
- Zero effort: The swipe gesture works perfectly out of the box on iOS.
- Accessibility: It supports VoiceOver and other Apple accessibility services automatically.
- Integration: The
.presentationDragIndicator(.visible)modifier (introduced in recent Swift updates) adds the small top “pill” that indicates to the user that the view is draggable.
3. The Challenge: Custom Views and Full Screen Covers
The problem for an iOS Developer arises when the design demands something that the native .sheet cannot provide. For example:
- A floating popup in the center of the screen.
- A
.fullScreenCoverwhich, by default, does not include the swipe to dismiss gesture. - Highly customized visual transitions.
This is where we must apply advanced Swift programming and build our own swipe to dismiss in SwiftUI using a DragGesture.
4. Building a Custom Swipe to Dismiss with DragGesture
To achieve a fluid drag, we need to follow three logical steps:
- Track how much the user has dragged their finger across the screen (
offset). - Move the view on the screen in real-time based on that offset.
- Decide, upon releasing the finger, if the drag was enough to close the view or if the view should return to its original position (bounce effect).
Let’s create a custom modal view:
import SwiftUI
struct CustomSwipeToDismissView: View {
@State private var showCustomModal = false
var body: some View {
ZStack {
// Main Content
VStack {
Button("Show Custom Modal") {
withAnimation(.spring()) {
showCustomModal = true
}
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Custom Modal Layer
if showCustomModal {
Color.black.opacity(0.4)
.ignoresSafeArea()
.onTapGesture {
closeModal()
}
CustomModalContent(isPresented: $showCustomModal)
.transition(.move(edge: .bottom))
}
}
}
private func closeModal() {
withAnimation(.spring()) {
showCustomModal = false
}
}
}
The Drag Gesture Logic
Now, let’s implement CustomModalContent where the magic of the swipe to dismiss in SwiftUI happens.
struct CustomModalContent: View {
@Binding var isPresented: Bool
// 1. State to store the current offset
@State private var dragOffset: CGSize = .zero
var body: some View {
VStack {
Capsule()
.frame(width: 40, height: 6)
.foregroundColor(.gray.opacity(0.5))
.padding(.top, 10)
Spacer()
Text("This is a custom modal!")
.font(.title2)
.bold()
.multilineTextAlignment(.center)
Spacer()
Button("Close") {
dismiss()
}
.buttonStyle(.bordered)
.padding(.bottom, 20)
}
.frame(maxWidth: .infinity)
.frame(height: 300)
.background(
RoundedRectangle(cornerRadius: 30)
.fill(Color(UIColor.systemBackground))
.shadow(radius: 20)
)
.padding(.horizontal)
// 2. Apply the offset to the view
.offset(y: dragOffset.height > 0 ? dragOffset.height : 0)
// 3. Add the gesture
.gesture(
DragGesture()
.onChanged { value in
// Update the offset only if it's downwards
if value.translation.height > 0 {
dragOffset = value.translation
}
}
.onEnded { value in
// 4. Decision logic on release
let threshold: CGFloat = 100 // Pixels needed to close
if value.translation.height > threshold || value.velocity.height > 500 {
// If it passed the threshold or was swiped very fast, close
dismiss()
} else {
// If not, return to the original position with an animation
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
dragOffset = .zero
}
}
}
)
}
private func dismiss() {
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
isPresented = false
dragOffset = .zero
}
}
}
Code Analysis
offset(y: dragOffset.height > 0 ? dragOffset.height : 0): This Swift programming line ensures the view can only be dragged downwards. If the user tries to drag upwards (negative values), the view remains fixed.value.velocity.height: A good iOS Developer knows that sometimes users don’t drag all the way to the bottom, but rather give a quick “flick” downwards. Reading the gesture’s velocity allows us to close the view if the momentum is strong, drastically improving UX..spring(): In SwiftUI, spring-type animations are essential for replicating real-world physics. When the view does not cross the threshold, it bounces slightly back into place.
5. Cross-Platform Adaptation (iOS, macOS, watchOS)
The promise of SwiftUI is “Learn once, apply everywhere.” However, compiling this code directly for macOS in Xcode will present usability issues. On a Mac, dragging a modal panel downwards with a mouse is not a standard interaction.
To make our code truly professional, we will use Swift compiler directives to adapt the behavior to each operating system.
Let’s create a custom view modifier to encapsulate this logic, so we can reuse it throughout our Xcode project.
import SwiftUI
// Create a reusable view modifier
struct SwipeToDismissModifier: ViewModifier {
var onDismiss: () -> Void
@State private var dragOffset: CGSize = .zero
func body(content: Content) -> some View {
#if os(iOS) || os(watchOS)
// Gesture implementation for touch screens
content
.offset(y: dragOffset.height > 0 ? dragOffset.height : 0)
.gesture(
DragGesture()
.onChanged { value in
if value.translation.height > 0 {
dragOffset = value.translation
}
}
.onEnded { value in
if value.translation.height > 100 || value.velocity.height > 300 {
onDismiss()
} else {
withAnimation(.spring()) {
dragOffset = .zero
}
}
}
)
#else
// Implementation for macOS (no dragging, closing depends on buttons)
content
#endif
}
}
// Extension for ease of use
extension View {
func customSwipeToDismiss(onDismiss: @escaping () -> Void) -> some View {
self.modifier(SwipeToDismissModifier(onDismiss: onDismiss))
}
}
How to apply this now?
Thanks to this refactoring using advanced Swift programming, applying your swipe to dismiss in SwiftUI to any view is as simple as adding a single line of code:
// In your modal view:
.customSwipeToDismiss {
withAnimation(.spring()) {
isPresented = false
}
}
On iOS and watchOS, the view will automatically gain the ability to be dragged downwards. On macOS, the modifier will return the content intact, preventing weird mouse interactions, and forcing the user to use the “Close” button (which you should provide in the UI).
6. Performance Considerations and Best Practices
As an iOS Developer, when working with continuous gestures (onChanged) in Xcode, you must be careful not to perform heavy calculations during the drag. SwiftUI redraws the view on every frame of the movement.
- Avoid complex states: Ensure that variables that mutate during the
DragGesture(likedragOffset) only affect layout modifiers (like.offsetor.opacity) and do not trigger redraws of massive lists or network calls. - Use interactive backgrounds: In our example, the transparent space around the modal also listens for taps (
onTapGesture) to close the view. This is a standard convention; users expect tapping “outside” the modal to close it. - Opacity control: An extra touch of quality is making the dark background (
Color.black.opacity(...)) become more transparent as the user drags the view down, linking the opacity todragOffset.height.
Conclusion
Implementing a custom swipe to dismiss in SwiftUI is a rite of passage for any iOS Developer. It shows that you not only know how to use the default components of Xcode but that you also understand the spatial math, animations, and state management required to manipulate the UI to your liking using Swift programming.
If you have any questions about this article, please contact me and I will be happy to help you . You can contact me on my X profile or on my Instagram profile.