In this part of The Ultimate Guide to List Views, we will look at Swipe Actions. Swipe Actions are used in many apps, most prominently in Apple’s own Mail app. They provide a well-known and easy-to-use UI affordance to allow users to perform actions on list items.
UIKit has supported Swipe Actions since iOS 11, but SwiftUI didn’t support Swipe Actions until WWDC 2021.
In this post, we will look at the following features:
- Swipe-to-delete using the
onDelete
modifier - Deleting and moving items using
EditButton
and the.editMode
environmental value - Using Swipe Actions (this is the most flexible approach, which also gives us a wealth of styling options)
Swipe-to-delete
This feature was available in SwiftUI right from the beginning. It is pretty straight-forward to use, but also pretty basic (or rather inflexible). To add swipe-to-delete to a List
view, all you need to do is apply the onDelete
modifier to a ForEach
loop inside a List
view. This modifier expects a closure with one parameter that contains an IndexSet
, indicating which rows to delete.
Here is a code snippet that shows a simple List
with an onDelete
modifier. When the user swipes to delete, the closure will be called, which will consequently remove the respective row from the array of items backing the List
view:
struct SwipeToDeleteListView: View { @State fileprivate var items = [ Item(title: "Puzzle", iconName: "puzzlepiece", badge: "Nice!"), Item(title: "Controller", iconName: "gamecontroller", badge: "Clicky!"), Item(title: "Shopping cart", iconName: "cart", badge: "$$$"), Item(title: "Gift", iconName: "giftcard", badge: ":-)"), Item(title: "Clock", iconName: "clock", badge: "Tick tock"), Item(title: "People", iconName: "person.2", badge: "2"), Item(title: "T-Shirt", iconName: "tshirt", badge: "M") ] var body: some View { List { ForEach(items) { item in Label(item.title, systemImage: item.iconName) } .onDelete { indexSet in items.remove(atOffsets: indexSet) } } } }
It’s actually quite convenient that onDelete
passes an IndexSet
to indicate which item(s) should be deleted, as Array
provides a method remove(atOffsets:)
that takes an IndexSet
.
It is worth noting that you cannot apply onDelete
to List
directly – you need to use a ForEach
loop instead and nest it inside a List
. I am not entirely sure why the SwiftUI team decided to implement it this way – if you have any clue (or work on the SwiftUI team), please get in touch with me!
Moving and Deleting items using EditMode
For some applications, it makes sense to let users rearrange items by dragging them across the list. SwiftUI makes implementing this super easy – all you need to do is apply the onMove
view modifier to a List
and then update the underlying data structure accordingly.
Here is a snippet that shows how to implement this for a simple array:
List { ForEach(items) { item in Label(item.title, systemImage: item.iconName) } .onDelete { indexSet in items.remove(atOffsets: indexSet) } .onMove { indexSet, index in items.move(fromOffsets: indexSet, toOffset: index) } }
Again, this is made easy thanks to Array.move
, which expects exactly the parameters that we receive in onDelete
’s closure.
To turn on edit mode for a List
, there are two options:
- Using the
.editMode
environment value - Using the
EditButton
view
Under the hood, both approaches make use of SwiftUI’s environment. The following snippet demonstrates how to use the EditButton
to allow the user to turn on edit mode for the list:
List { ForEach(items) { item in Label(item.title, systemImage: item.iconName) } .onDelete { indexSet in items.remove(atOffsets: indexSet) } .onMove { indexSet, index in items.move(fromOffsets: indexSet, toOffset: index) } } .toolbar { EditButton() }
Swipe Actions
For anything that goes beyond swipe-to-delete and EditMode
, SwiftUI now supports Swipe Actions. This new API gives us a lot of control over how to display swipe actions:
- We can define different swipe actions per row
- We can specify the text, icon and tint color to use for each individual action
- We can add add actions to the leading and trailing edge of a row
- We can enable of disable full swipe for the first action on either end of the row, allowing users to trigger the action by completely swiping the row to the respective edge
Basic Swipe Actions
Let’s look at a simple example how to use this new API. To register a Swipe Action for a List
row, we need to call the swipeActions
view modifier. Inside the closure of the view modifier, we can set up one (or more) Button
s to implement the action itself.
The following code snippet demonstrates how to add a simple swipe action to a List
view:
List(viewModel.items) { item in Text(item.title) .fontWeight(item.isRead ? .regular : .bold) .swipeActions { Button (action: { viewModel.markItemRead(item) }) { if let isRead = item.isRead, isRead == true { Label("Read", systemImage: "envelope.badge.fill") } else { Label("Unread", systemImage: "envelope.open.fill") } } .tint(.blue) } }
It’s worth noting that the swipeActions
modifier is invoked on the view that represents the row. In this case, it is a simple Text
view, but for more advanced lists, this might as well be a HStack
or VStack
. This is different from the onDelete
modifier (which needs to be applied to a ForEach
loop inside a List
view) and it gives us the flexibility to apply a different set of actions depending on the row.
Also note that each swipe action is represented by a Button
. If you use any other view, SwiftUI will not register it, and no action will be shown. Likewise, if you try to apply the swipeActions
modifier to the List
or a ForEach
loop, the modifier will be ignored.
Specifying the edge
By default, swipe actions will be added to the trailing edge of the row. This is why, in the previous example, the mark as read/unread action was added to the trailing edge. To add the action to the leading edge (just like in Apple’s Mail app), all we have to do is specify the edge
parameter, like so:
List(viewModel.items) { item in Text(item.title) .fontWeight(item.isRead ? .regular : .bold) .swipeActions(edge: .leading) { Button (action: { viewModel.markItemRead(item) }) { if let isRead = item.isRead, isRead == true { Label("Read", systemImage: "envelope.badge.fill") } else { Label("Unread", systemImage: "envelope.open.fill") } } .tint(.blue) }
To add actions to either edge, we can call the swipeActions
modifier multiple times, specifying the edge we want to add the actions to.
If you add swipe actions to both the leading and trailing edge, it is a good idea to be explicit about where you want to add the actions. In the following code snippet, we add add one action to the leading edge, and another one to the trailing edge.
List(viewModel.items) { item in Text(item.title) .fontWeight(item.isRead ? .regular : .bold) .swipeActions(edge: .leading) { Button (action: { viewModel.markItemRead(item) }) { if let isRead = item.isRead, isRead == true { Label("Read", systemImage: "envelope.badge.fill") } else { Label("Unread", systemImage: "envelope.open.fill") } } .tint(.blue) } .swipeActions(edge: .trailing) { Button(role: .destructive, action: { viewModel.deleteItem(item) } ) { Label("Delete", systemImage: "trash") } } }
You might notice that we used the role
parameter on the Button
to indicate it is .destructive
– this instructs SwiftUI to use a red background colour for this button. We still have to implement deleting the item ourselves, though. And since the action
closure of the Button
is inside the scope of the current row, it is now much easier directly access the current list item
– another advantage of this API design over the previous design for onDelete
.
Swipe Actions and onDelete
After reading the previous code snippet, you might be wondering why we didn’t use the onDelete
view modifier instead of implementing a delete action ourselves. The answer is quite simple: as stated in the documentation, SwiftUI will stop synthesising the delete functionality once you use the swipeActions
modifier.
Adding more Swipe Actions
To add multiple swipe actions to either edge, we can call the swipeActions
modifier multiple times:
List(viewModel.items) { item in Text(item.title) .fontWeight(item.isRead ? .regular : .bold) .swipeActions(edge: .leading) { Button (action: { viewModel.markItemRead(item) }) { if let isRead = item.isRead, isRead == true { Label("Read", systemImage: "envelope.badge.fill") } else { Label("Unread", systemImage: "envelope.open.fill") } } .tint(.blue) } .swipeActions(edge: .trailing) { Button(role: .destructive, action: { viewModel.deleteItem(item) } ) { Label("Delete", systemImage: "trash") } } .swipeActions(edge: .trailing) { Button (action: { selectedItem = item } ) { Label("Tag", systemImage: "tag") } .tint(Color(UIColor.systemOrange)) } }
If this makes you feel uneasy, you can also add multiple buttons to the same swipeActions
modifier. The following code snippet results in the same UI as the previous one:
List(viewModel.items) { item in Text(item.title) .fontWeight(item.isRead ? .regular : .bold) .badge(item.badge) .swipeActions(edge: .leading) { Button (action: { viewModel.markItemRead(item) }) { if let isRead = item.isRead, isRead == true { Label("Read", systemImage: "envelope.badge.fill") } else { Label("Unread", systemImage: "envelope.open.fill") } } .tint(.blue) } .swipeActions(edge: .trailing) { Button(role: .destructive, action: { viewModel.deleteItem(item) } ) { Label("Delete", systemImage: "trash") } Button (action: { selectedItem = item } ) { Label("Tag", systemImage: "tag") } .tint(Color(UIColor.systemOrange)) } }
If you add multiple swipe actions to the same edge, they will be shown from the outside in. I.e. the first button will always appear closest to the respective edge.
Please note that, although there doesn’t seem to be any limit as to how many swipe actions you can add to either edge of a row, the number of actions that a user can comfortably use depends on their device. For example, a list row on an iPhone 13 in portrait orientation can fit up to five swipe actions, but they completely fill up the entire row, which not only looks strange, but also leads to some issues when trying to tap the right button. Smaller devices, like an iPhone 6 or even and iPhone 5, can fit even fewer swipe actions. Three or four swipe actions seem to be a sensible limit that should work on most devices.
Full Swipe
By default, the first action for any given swipe direction can be invoked by using a full swipe. You can deactivate this behaviour by setting the allowsFullSwipe
parameter to false
:
.swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive, action: { viewModel.deleteItem(item) } ) { Label("Delete", systemImage: "trash") } }
Styling Your Swipe Actions
As mentioned before, setting the role
of a swipe action’s Button
to .destructive
will automatically tint the button red. If you don’t specify a role
, the Button
will be tinted in light grey. You can specify any other colour by using the tint
modifier on a swipe action’s Button
– like so:
swipeActions(edge: .trailing) { Button (action: { selectedItem = item } ) { Label("Tag", systemImage: "tag") } .tint(Color(UIColor.systemOrange)) }
Inside the Button
, you can display both text labels and / or icons, using Image
, Text
, or Label
.
Conclusion
SwiftUI makes it easy to implement Swipe Actions, a very popular UI pattern that allows users to invoke contextual actions on list items. In comparison to force-touching or long-pressing a list row to invoke a context menu, Swipe Actions offer a more convenient, fluent, way to interact with your app’s UI.
Thanks for reading, and hope to see you again for the final part of the series when we talk about nested lists (a.k.a trees)!