Reading time: 7 min

This recipe shows how to add custom row swipe actions to a SwiftUI List, supporting multiple custom buttons on either side, as well as full swipe functionality.

The end result looks like this:

preview

The solution depends on which SwiftUI version you have to support:

  1. SwiftUI 3 (iOS 15, macOS 12) supports swipe actions natively.
  2. SwiftUI 1 and 2 (iOS 13-14, macOS 10.15-11) has no native support for swipe actions, so read on to see a working solution for all SwiftUI versions.
Recipe for all versions

TL;DR You can find the full source in this gist.

Start off by adding a custom view that represents a single button in a swipe action stack. It renders either an image or a text on an optional background (tint), just like in Swift 3 native code. Each button has a fixed maximum width:

struct SwipeActionButton: View, Identifiable {
  static let width: CGFloat = 70

  let id = UUID()
  let text: Text?
  let icon: Image?
  let action: () -> Void
  let tint: Color?

  init(text: Text? = nil,
       icon: Image? = nil,
       action: @escaping () -> Void,
       tint: Color? = nil) {
    self.text = text
    self.icon = icon
    self.action = action
    self.tint = tint ?? .gray
  }

  var body: some View {
    ZStack {
      tint
      VStack {
        icon?
          .foregroundColor(.white)
        if icon == nil {
          text?
            .foregroundColor(.white)
        }
      }
      .frame(width: SwipeActionButton.width)
    }
  }
}

Now it's time for the ViewModifier that adds the swipe action functionality for the given list row. There's a lot of code here, but also enough comments to clear things up.

// Adds custom swipe actions to a given view
struct SwipeActionView: ViewModifier {
  // How much does the user have to swipe at least to reveal buttons on either side
  private static let minSwipeableWidth = SwipeActionButton.width * 0.8

  // Buttons at the leading (left-hand) side
  let leading: [SwipeActionButton]
  // Can you full swipe the leading side
  let allowsFullSwipeLeading: Bool
  // Buttons at the trailing (right-hand) side
  let trailing: [SwipeActionButton]
  // Can you full swipe the trailing side
  et allowsFullSwipeTrailing: Bool

  private let totalLeadingWidth: CGFloat!
  private let totalTrailingWidth: CGFloat!

  @State private var offset: CGFloat = 0
  @State private var prevOffset: CGFloat = 0

  init(leading: [SwipeActionButton] = [],
       allowsFullSwipeLeading: Bool = false,
       trailing: [SwipeActionButton] = [],
       allowsFullSwipeTrailing: Bool = false) {
    self.leading = leading
    self.allowsFullSwipeLeading = allowsFullSwipeLeading && !leading.isEmpty
    self.trailing = trailing
    self.allowsFullSwipeTrailing = allowsFullSwipeTrailing && !trailing.isEmpty
    totalLeadingWidth = SwipeActionButton.width * CGFloat(leading.count)
    totalTrailingWidth = SwipeActionButton.width * CGFloat(trailing.count)
  }

  func body(content: Content) -> some View {
    // Use a GeometryReader to get the size of the view on which we're adding
    // the custom swipe actions.
    GeometryReader { geo in
      // Place leading buttons, the wrapped content and trailing buttons
      // in an HStack with no spacing.
      HStack(spacing: 0) {        
        // If any swiping on the left-hand side has occurred, reveal
        // leading buttons. This also resolves button flickering.
        if offset > 0 {
          // If the user has swiped enough for it to qualify as a full swipe,
          // render just the first button across the entire swipe length.
          if fullSwipeEnabled(edge: .leading, width: geo.size.width) {
            button(for: leading.first)
              .frame(width: offset, height: geo.size.height)
          } else {
            // If we aren't in a full swipe, render all buttons with widths
            // proportional to the swipe length.
            ForEach(leading) { actionView in
              button(for: actionView)
              .frame(width: individualButtonWidth(edge: .leading),
                     height: geo.size.height)
            }
          }
        }

        // This is the list row itself
        content
          // Add horizontal padding as we removed it to allow the
          // swipe buttons to occupy full row height.
          .padding(.horizontal, 16)
          .frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
          .offset(x: (offset > 0) ? 0 : offset)

        // If any swiping on the right-hand side has occurred, reveal
        // trailing buttons. This also resolves button flickering.
        if offset < 0 {
          Group {
            // If the user has swiped enough for it to qualify as a full swipe,
            // render just the last button across the entire swipe length.
            if fullSwipeEnabled(edge: .trailing, width: geo.size.width) {
              button(for: trailing.last)
                .frame(width: -offset, height: geo.size.height)
            } else {
              // If we aren't in a full swipe, render all buttons with widths
              // proportional to the swipe length.
              ForEach(trailing) { actionView in
                button(for: actionView)
                  .frame(width: individualButtonWidth(edge: .trailing),
                        height: geo.size.height)
              }
            }
          }
          // The leading buttons need to move to the left as the swipe progresses.
          .offset(x: offset)
        }
      }
      // animate the view as `offset` changes
      .animation(.spring(), value: offset)
      // allows the DragGesture to work even if there are now interactable
      // views in the row
      .contentShape(Rectangle())
      // The DragGesture distates the swipe. The minimumDistance is there to
      // prevent the gesture from interfering with List vertical scrolling.
      .gesture(DragGesture(minimumDistance: 10,
                           coordinateSpace: .local)
        .onChanged { gesture in
          // Compute the total swipe based on the gesture values.
          var total = gesture.translation.width + prevOffset
          if !allowsFullSwipeLeading {
            total = min(total, totalLeadingWidth)
          }
          if !allowsFullSwipeTrailing {
            total = max(total, -totalLeadingWidth)
          }
          offset = total
        }
        .onEnded { _ in
          // Adjust the offset based on if the user has swiped enough to reveal 
          // all the buttons or not. Also handles full swipe logic.
          if offset > SwipeActionView.minSwipeableWidth && !leading.isEmpty {
            if !checkAndHandleFullSwipe(for: leading, edge: .leading, width: geo.size.width) {
              offset = totalLeadingWidth
            }
          } else if offset < -SwipeActionView.minSwipeableWidth && !trailing.isEmpty {
            if !checkAndHandleFullSwipe(for: trailing, edge: .trailing, width: -geo.size.width) {
              offset = -totalTrailingWidth
            }
          } else {
            offset = 0
          }
          prevOffset = offset
        })
    }
    // Remove internal row padding to allow the buttons to occupy full row height
    .listRowInsets(EdgeInsets())
  }

  // Checks if full swipe is supported and currently active for the given edge.
  // The current threshold is at half of the row width.
  private func fullSwipeEnabled(edge: Edge, width: CGFloat) -> Bool {
    let threshold = abs(width) / 2
    switch (edge) {
    case .leading:
      return allowsFullSwipeLeading && offset > threshold
    case .trailing:
      return allowsFullSwipeTrailing && -offset > threshold
    }
  }

  // Creates the view for each SwipeActionButton. Also assigns it
  // a tap gesture to handle the click and reset the offset.
  private func button(for button: SwipeActionButton?) -> some View {
    button?
      .onTapGesture {
        button?.action()
        offset = 0
        prevOffset = 0
      }
  }

  // Calculates width for each button, proportional to the swipe.
  private func individualButtonWidth(edge: Edge) -> CGFloat {
    switch edge {
    case .leading:
      return (offset > 0) ? (offset / CGFloat(leading.count)) : 0
    case .trailing:
      return (offset < 0) ? (abs(offset) / CGFloat(trailing.count)) : 0
    }
  }

  // Checks if the view is in full swipe. If so, trigger the action on the
  // correct button (left- or right-most one), make it full the entire row
  // and schedule everything to be reset after a while.
  private func checkAndHandleFullSwipe(for collection: [SwipeActionButton],
                                       edge: Edge,
                                       width: CGFloat) -> Bool {
    if fullSwipeEnabled(edge: edge, width: width) {
      offset = width * CGFloat(collection.count) * 1.2
      ((edge == .leading) ? collection.first : collection.last)?.action()
      DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
        offset = 0
        prevOffset = 0
      }
      return true
    } else {
      return false
    }
  }

  private enum Edge {
    case leading, trailing
  }
}

Finally, wrap the modifier in an extension to make it easier to use:

extension View {
  func swipeActions(leading: [SwipeActionButton] = [],
                    allowsFullSwipeLeading: Bool = false,
                    trailing: [SwipeActionButton] = [],
                    allowsFullSwipeTrailing: Bool = false) -> some View {
    modifier(SwipeActionView(leading: leading,
                             allowsFullSwipeLeading: allowsFullSwipeLeading,
                             trailing: trailing,
                             allowsFullSwipeTrailing: allowsFullSwipeTrailing))
  }
}

Here's some sample code that showcases the full functionality of swipeAction:

List(1..<20) {
  Text("List view item at row \($0)")
    .frame(alignment: .leading)
    .swipeActions(leading: [
      SwipeActionButton(text: Text("Text"), action: {
        print("Text")
      }),
      SwipeActionButton(icon: Image(systemName: "flag"), action: {
      print("Flag")
      }, tint: .green)
    ],
    allowsFullSwipeLeading: true,
    trailing: [
      SwipeActionButton(text: Text("Read"),
                        icon: Image(systemName: "envelope.open"),
                        action: {
        print("Read")
      }, tint: .blue),
      SwipeActionButton(icon: Image(systemName: "trash"), action: {
        print("Trash")
      }, tint: .red)
    ],
    allowsFullSwipeTrailing: true)
} 

SwiftUI 3 solution

In SwiftUI 3, swipe actions are supported out of the box. You can add Buttons on either side and specify if you'd like to support full swipes:

List(1..<20) {
  Text("List view item at row \($0)")
    .swipeActions(edge: .leading, allowsFullSwipe: true) {
      Button {
        print("Text")
      } label: {
        Text("Text")
      }
      Button {
        print("Flag")
      } label: {
        Image(systemName: "flag")
      }
      .tint(.green)
    }
    .swipeActions(edge: .trailing, allowsFullSwipe: true) {
      Button(role: .destructive) {
       print("Trash")
      } label: {
       Label("Delete", systemImage: "trash")
      }
      .tint(.red)
      Button {
        print("Read")
      } label: {
        Label("Read", systemImage: "envelope.open")
      }
      .tint(.blue)
    }
}

Next Post Previous Post