Swipe actions is not a new concept in iOS or macOS programming. We've been using such actions for years in Apple's applications, with a quite typical example being the Mail app. Even though they exist for a long time in UIKit, swipe actions were not available in SwiftUI until WWDC 2021, when they were eventually announced. In this post we are about to explore them, and have a hands-on experience on how to integrate swipe actions in SwiftUI.
A demo scenario
Suppose that we have the following list of old movies:

Credits: Top rated movies from IMDB at the time of writing this post.
The goal is to add swipe actions to both the leading and trailing edge on each row, so we can do the following:
- Indicate whether the user has watched a movie or not.
- Indicate whether the user watched that movie alone or with friends, i.e. in a cinema.
- Remove a movie from the list in case the user didn't like it.
Here's what we'll end up with:

For the first two cases, we'll show images as visual indications on watched movies. For the last case, the movie will simply be removed from the list.
Note
Download the sample project.
The following code implements the list:
struct ContentView: View {
@ObservedObject var movies = Movies()
var body: some View {
NavigationView {
List {
ForEach(movies.moviesData) { movie in
movieInfo(for: movie)
}
}
.navigationTitle("Movies List")
}
}
}
Movies is an ObservableObject custom type. It contains the moviesData array with the data of each single movie. It also has a few properties that update a movie in the moviesData array according to the selected task from the soon-to-come swipe actions.
class Movies: ObservableObject {
@Published var moviesData: [MovieData] = [
MovieData(title: "The Godfather", image: "godfather"),
MovieData(title: "The Dark Knight", image: "dark_knight"),
MovieData(title: "Pulp Fiction", image: "pulp_fiction"),
MovieData(title: "Forrest Gump", image: "forrest_gump"),
MovieData(title: "Inception", image: "inception"),
MovieData(title: "The Matrix", image: "matrix"),
MovieData(title: "Se7en", image: "seven"),
MovieData(title: "The Silence of the Lambs", image: "silence_of_lambs"),
MovieData(title: "Saving Private Ryan", image: "saving_private_ryan"),
MovieData(title: "The Green Mile", image: "the_green_mile")
]
func toggleWatched(for movieID: String) {
guard let index = moviesData.firstIndex(where: { $0.id == movieID }) else { return }
moviesData[index].watched.toggle()
}
func toggleWatchedWithFriends(for movieID: String) {
guard let index = moviesData.firstIndex(where: { $0.id == movieID }) else { return }
moviesData[index].watchedWithFriends.toggle()
}
func removeMovie(with movieID: String) {
guard let index = moviesData.firstIndex(where: { $0.id == movieID }) else { return }
moviesData.remove(at: index)
}
}
Each movie is represented with the MovieData custom type:
struct MovieData: Identifiable {
var id = UUID().uuidString
var title: String
var image: String
var watched = false
var watchedWithFriends = false
}
Also, the movieInfo(for:) method shown in the previous code snippet is a @ViewBuilder method that implements the contents of each row:
@ViewBuilder
func movieInfo(for movie: MovieData) -> some View {
HStack {
Image(movie.image)
.padding(.trailing)
Text(movie.title)
.font(.headline)
if movie.watched || movie.watchedWithFriends {
Spacer()
}
if movie.watched {
Image(systemName: "eye")
}
if movie.watchedWithFriends {
Image(systemName: "person.3")
}
}
}
Having seen all the above, let's add the first swipe action to the list rows.
Adding a swipe action
We add swipe actions to the rows of a list by applying the swipeActions view modifier on the content of each row:
List {
ForEach(movies.moviesData) { movie in
movieInfo(for: movie)
.swipeActions {
}
}
}
The content of each row here is implemented in the movieInfo(for:) method, so that's where the swipeActions modifier is applied.
Here is the first important detail now. Using the modifier as shown above adds a swipe action to the trailing edge of the row by default. However, we want to change the watched state of movies with a swipe action on the leading edge.
To manage that, it's necessary to specify the edge as an argument:
.swipeActions(edge: .leading, content: {
})
There is also another argument that we can supply swipeActions with:
.swipeActions(edge: .leading, allowsFullSwipe: true, content: {
})
The meaning of the allowsFullSwipe is the following:
When true, then a full swipe on the row will perform the first (outermost) action automatically. When false, then the first action should be tapped (or clicked on macOS) as the swipe gesture won't trigger it. By default it's true, so we can simply omit it here.
Swipe action's content
Focusing on the content closure now, here we have to implement one or more buttons that represent and perform the actual actions. The order of the buttons matters, as the first one matches to the outermost action.
That said, right next you can see the first button's implementation that changes the watched state of a movie:
.swipeActions(edge: .leading, content: {
Button {
movies.toggleWatched(for: movie.id)
} label: {
Label("Watched", systemImage: !movie.watched ? "eye" : "eye.slash")
}
})
toggleWatched(for:) method updates the watched state of the movie with the given id, and subsequently it triggers the appearance of a matching image to the row.
Note
When using SF Symbol images as the button's label, the system automatically applies the fill variation of the symbol, so the action is emphasized and distinguished easily.
Tip
Use an Image view or a Text view as the button's label instead of the Label view if you want so.
Here's what the app does so far:

See that both the full swipe gesture and the tap on the action's button have the same result. Also notice that the displayed image of the swipe action depends on the watched state.
Changing the action's tint color
No doubt, we've managed to achieve a great functionality with just a few lines of code. However, you'll notice above that the button's background color is gray. That's the default color, unless we explicitly change it with the tint() modifier applied to the button; we pass the desired color as argument:
.swipeActions(edge: .leading, content: {
Button { ... } label: {
...
}
.tint(.green)
})
Now we have a different result:

Adding another swipe action
For the sake of the demonstration, we'll add one more action next to the previous one; it will help us indicate whether a movie has been watched with friends or not.
Doing so is quite easy, as all it takes is to add one more button to the content closure of the swipeActions modifier. Keeping in mind that the order of buttons matters, we'll add the new one after the previous button:
movieInfo(for: movie)
.swipeActions(edge: .leading, content: {
...
Button {
movies.toggleWatchedWithFriends(for: movie.id)
} label: {
Image(systemName: "person.3")
}
.tint(.indigo)
})
toggleWatchedWithFriends(for:) method updates the data matching to the selected row. Besides that, once again here we're specifying a custom tint color with the tint() modifier applied to the button.
Here is the result of the above addition:

Removing rows with a swipe action
Let's give our users one more capability now; to remove movies from the list.
We'll do that by adding a new swipe action, but this time to the trailing edge of the row. It'd be against the best user experience to have it next to the other two actions.
In order to add swipe actions to an edge that we have not previously used, it's necessary to use another swipeActions modifier right after the first one. Since we want the new swipe action to appear in the trailing edge, we'll do so without passing any parameters to the modifier; we could have done so, but it's not necessary as trailing is the edge used by default:
List {
ForEach(movies.moviesData) { movie in
movieInfo(for: movie)
.swipeActions(edge: .leading, content: { ... })
.swipeActions {
}
}
}
Inside the new swipeActions modifier's content closure we'll implement one more button that will remove the selected movie from the data source:
.swipeActions {
Button(role: .destructive) {
withAnimation(.linear(duration: 0.4)) {
movies.removeMovie(with: movie.id)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
The removeMovie(with:) method in the Movies type will remove the proper movie based on the given id value. The removal of the row will be animated. Note here that instead of providing a specific tint color, I set the button's role to destructive. By attaching a role to the button, the system will automatically assign a proper tint color; in this case it's going to be red.
Here is the result of the above addition:

Summary
You can download the project from the link below, and try out everything discussed in this post. There is no doubt that integrating swipe actions in SwiftUI lists is easy, and there are just a couple of details to keep in mind when using them. I hope you enjoyed this post, and found it useful. Thank you for reading!