SwiftUI

Swipe Actions in SwiftUI 3

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 Listview, 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) Buttons 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)!