29

How can I use custom Swipe Actions in SwiftUI?

I tried to use the UIKit Framework to get these working in SwiftUI. But that doesn't work for me.

import SwiftUI
import UIKit



    init() {
        override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
            let important = importantAction(at: indexPath)
            return UISwipeActionsConfiguration(actions: [important])
        }
        func importantAction(at indexPath: IndexPath) -> UIContextualAction {
            let action = UIContextualAction(style: .normal, title: "Important") { (action, view, completion) in
                print("HI")
            }
            action.backgroundColor = UIColor(hue: 0.0861, saturation: 0.76, brightness: 0.94, alpha: 1.0) /* #f19938 */
            action.image = UIImage(named: "pencil")
            return action
        }
    }






struct TestView: View {

      NavigationView {
               List {
                    ForEach(appointmentsViewModel.appointments.identified(by: \.id)) { appointment in Row_Appointments(appointment: appointment)
                }.onDelete(perform: delete)
            }
        }
    }
}

kontiki
  • 37,663
  • 13
  • 111
  • 125
Max
  • 683
  • 2
  • 8
  • 12
  • Your code - as is - won't build. The `init` is part of something, but what? More, what *exactly* are you trying to do, and what do you mean by "that doesn't work for me"? Are you saying that the `UIViewControllerRepresentable` doesn't work? That things in `UIKit` won't work? Maybe that a swipe action is being intercepted by your `onDelete`? Please, maybe with more details we can help you. –  Jul 19 '19 at 14:45
  • 1
    It looks like Max wants to add a trailing swipe action to his list item to mark the item “Important”. Since `tableView(_:trailingSwipeActionsConfigurationForRow:)` is a `UITableViewDelegate` method, and SwiftUI doesn't let you set the delegate for the `UITableView` it creates as an implementation detail, Max's attempt is unlikely to succeed. – rob mayoff Jul 19 '19 at 21:16
  • In iOS 15 we can finally use native Swipe Actions - see [this answer](https://stackoverflow.com/a/67877931/8697793). – pawello2222 Jun 11 '21 at 18:11

7 Answers7

23

If your deployment target is iOS 15 (or newer), then you can use the swipeActions modifier to customize the swipe actions of a list item.

This also applies to watchOS 8 and macOS 12.

These operating systems will be released in late 2021.

Prior to the late 2021 version of SwiftUI, there is no support for custom swipe actions for List items.

If you need to target an older version, you would probably be better off implementing a different user interface, like adding a toggle button as a subview of your list item, or adding a context menu to your list item.

rob mayoff
  • 375,296
  • 67
  • 796
  • 848
8

iOS 15+

In iOS 15 we can finally use native Swipe Actions:

func swipeActions<T>(edge: HorizontalEdge = .trailing, allowsFullSwipe: Bool = true, content: () -> T) -> some View where T : View

They can be attached to the ForEach container just like onMove or onDelete:

List {
    ForEach(appointmentsViewModel.appointments.identified(by: \.id)) { appointment in
        Row_Appointments(appointment: appointment)
    }
    .swipeActions(edge: .trailing) {
        Button {
            print("Hi")
        } label: {
            Label("Important", systemImage: "pencil")
        }
    }
}
pawello2222
  • 46,897
  • 22
  • 145
  • 209
5

Based on Michał Ziobro answer using Introspect to simplify table view delegate setup.

Note that this will override the table view delegate and might BREAK some of the existing table view behaviours. While things such as header hight can be fixed by adding the method to custom delegate yourself, other might not be fixable.

struct ListSwipeActions: ViewModifier {

    @ObservedObject var coordinator = Coordinator()

    func body(content: Content) -> some View {

        return content
            .introspectTableView { tableView in
                tableView.delegate = self.coordinator
            }
    }

    class Coordinator: NSObject, ObservableObject, UITableViewDelegate {

        func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
            return .delete
        }

        func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {

            let archiveAction = UIContextualAction(style: .normal, title: "Title") { action, view, completionHandler in
                // update data source
                completionHandler(true)
            }
            archiveAction.image = UIImage(systemName: "archivebox")!
            archiveAction.backgroundColor = .systemYellow

            let configuration = UISwipeActionsConfiguration(actions: [archiveAction])

            return configuration
        }
    }
}

extension List {
    func swipeActions() -> some View {
        return self.modifier(ListSwipeActions())
    }
}
Ivan Rep
  • 369
  • 5
  • 8
  • @AntonShevtsov really? you tested it? – LetsGoBrandon Aug 28 '20 at 12:11
  • 1
    This seems to work for regular `willDisplay` callbacks, but nerfs any method that returns a value. Things like section headers will disappear (since UIKit relies on the delegate to host these). – AverageHelper Oct 17 '20 at 05:35
  • 2
    While this works for the swipe actions it essentially breaks any interaction with the rest of the SwiftUI table. Apparently the overridden delegate handles all of this, but re-delegating the methods to the original won‘t help. – Koraktor Jan 24 '21 at 10:27
  • @Koraktor what specific feature isn't fixable? – Ivan Rep Jan 25 '21 at 11:40
  • I have NavigationLinks inside a List. As soon as I use your modifier they stop working. Probably because the new delegate does not handle the taps on the cells. – Koraktor Jan 25 '21 at 11:42
3

It is able to be done in the way something like this:

           List {
                ForEach(items) { (item) in

                    Text("\(item.title)")
                }
                .onDelete(perform: self.delete)
            }.swipeActions()

Then you need to add this swipeActions() modifier

struct ListSwipeActions: ViewModifier {

    @ObservedObject var coordinator = Coordinator()

    func body(content: Content) -> some View {

        return content
            .background(TableViewConfigurator(configure: { tableView in
                delay {
                    tableView.delegate = self.coordinator
                }
            }))
    }

    class Coordinator: NSObject, ObservableObject, UITableViewDelegate {

        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            print("Scrolling ....!!!")
        }

        func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
            return .delete
        }

        func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {

            let isArchived = false
            let title = isArchived ? NSLocalizedString("Unarchive", comment: "Unarchive") : NSLocalizedString("Archive", comment: "Archive")

            let archiveAction = UIContextualAction(style: .normal, title: title, handler: {
                (action, view, completionHandler) in

                // update data source
                completionHandler(true)
            })
            archiveAction.title = title
            archiveAction.image = UIImage(systemName: "archivebox")!
            archiveAction.backgroundColor = .systemYellow

            let configuration = UISwipeActionsConfiguration(actions: [archiveAction])

            return configuration
        }
    }
}

extension List {

    func swipeActions() -> some View {
        return self.modifier(ListSwipeActions())
    }
}

And have TableViewConfigurator that searches for table view behind the List

struct TableViewConfigurator: UIViewControllerRepresentable {

    var configure: (UITableView) -> Void = { _ in }

    func makeUIViewController(context: Context) -> UIViewController {

        UIViewController()
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {


        let tableViews = UIApplication.nonModalTopViewController()?.navigationController?.topViewController?.view.subviews(ofType: UITableView.self) ?? [UITableView]()

        for tableView in tableViews {
            self.configure(tableView)
        }
    }
}
Michał Ziobro
  • 10,759
  • 11
  • 88
  • 143
3

Delighted to see that iOS 15 brings the long awaited .swipeActions view modifier to List in SwiftUI with an easy to use API.

List {
    ForEach(store.messages) { message in
        MessageCell(message: message)
            .swipeActions(edge: .leading) {
                Button { store.toggleUnread(message) } label: {
                    if message.isUnread {
                        Label("Read", systemImage: "envelope.open")
                    } else {
                        Label("Unread", systemImage: "envelope.badge")
                    }
                }
            }
            .swipeActions(edge: .trailing) {
                Button(role: .destructive) {
                    store.delete(message)
                } label: {
                    Label("Delete", systemImage: "trash")
                }
                Button { store.flag(message) } label: {
                    Label("Flag", systemImage: "flag")
                }
            }
        }
    }
}

Actions appear in the order listed, starting from the originating edge working inwards.

The example above produces:

swipe actions

Note that swipeActions override the onDelete handler if provided that is available on ForEach

Read more in Apple's developer docs

Dan Barclay
  • 5,827
  • 2
  • 19
  • 22
0

I wanted the same and have now the following implementation.

The SwipeController checks when to execute a swipe action and performs the SwipeAction, for now you can add your swipe actions under the print lines in the executeAction function. But it is better make an abstract class from this.

Then in the SwipeLeftRightContainer struct we have most of the logic in the DragGesture. What it does is while your dragging its gonna change the offset and then make calls to the SwipeController to see if the threshold for swipe left or right are reached. Then when you finish the dragging it will come into the onEnded callback of the DragGesture. Here we will reset the offset and let the SwipeController decide to execute an action.

Keep in mind lot of the variables in the view are static for an iPhone X so you should change them to what fits best.

Also what this does is creating an action for left and right swipe, but you can adjust it to your own use ofcourse.

import SwiftUI

/** executeRight: checks if it should execute the swipeRight action
    execute Left: checks if it should execute the swipeLeft action
    submitThreshold: the threshold of the x offset when it should start executing the action
*/
class SwipeController {
    var executeRight = false
    var executeLeft = false
    let submitThreshold: CGFloat = 200
    
    func checkExecutionRight(offsetX: CGFloat) {
        if offsetX > submitThreshold && self.executeRight == false {
            Utils.HapticSuccess()
            self.executeRight = true
        } else if offsetX < submitThreshold {
            self.executeRight = false
        }
    }
    
    func checkExecutionLeft(offsetX: CGFloat) {
        if offsetX < -submitThreshold && self.executeLeft == false {
            Utils.HapticSuccess()
            self.executeLeft = true
        } else if offsetX > -submitThreshold {
            self.executeLeft = false
        }
    }
    
    func excuteAction() {
        if executeRight {
            print("executed right")
        } else if executeLeft {
            print("executed left")
        }
        
        self.executeLeft = false
        self.executeRight = false
    }
}

struct SwipeLeftRightContainer: View {
    
    var swipeController: SwipeController = SwipeController()
    
    @State var offsetX: CGFloat = 0
    
    let maxWidth: CGFloat = 335
    let maxHeight: CGFloat = 125
    let swipeObjectsOffset: CGFloat = 350
    let swipeObjectsWidth: CGFloat = 400
    
    @State var rowAnimationOpacity: Double = 0
    var body: some View {
        ZStack {
            Group {
                HStack {
                    Text("Sample row")
                    Spacer()
                }
            }.padding(10)
            .zIndex(1.0)
            .frame(width: maxWidth, height: maxHeight)
            .cornerRadius(5)
            .background(RoundedRectangle(cornerRadius: 10).fill(Color.gray))
            .padding(10)
            .offset(x: offsetX)
            .gesture(DragGesture(minimumDistance: 5).onChanged { gesture in
                withAnimation(Animation.linear(duration: 0.1)) {
                    offsetX = gesture.translation.width
                }
                swipeController.checkExecutionLeft(offsetX: offsetX)
                swipeController.checkExecutionRight(offsetX: offsetX)
            }.onEnded { _ in
                withAnimation(Animation.linear(duration: 0.1)) {
                    offsetX = 0
                    swipeController.prevLocX = 0
                    swipeController.prevLocXDiff = 0
                    self.swipeController.excuteAction()
                }
            })
            Group {
                ZStack {
                    Rectangle().fill(Color.red).frame(width: swipeObjectsWidth, height: maxHeight).opacity(opacityDelete)
                    Image(systemName: "multiply").font(Font.system(size: 34)).foregroundColor(Color.white).padding(.trailing, 150)
                }
            }.zIndex(0.9).offset(x: swipeObjectsOffset + offsetX)
            Group {
                ZStack {
                    Rectangle().fill(Color.green).frame(width: swipeObjectsWidth, height: maxHeight).opacity(opacityLike)
                    Image(systemName: "heart").font(Font.system(size: 34)).foregroundColor(Color.white).padding(.leading, 150)
                }
            }.zIndex(0.9).offset(x: -swipeObjectsOffset + offsetX)
        }
    }
    
    var opacityDelete: Double {
        if offsetX < 0 {
            return Double(abs(offsetX) / 50)
        }
        return 0
    }
    
    var opacityLike: Double {
        if offsetX > 0 {
            return Double(offsetX / 50)
        }
        return 0
    }
}

struct SwipeListView: View {
    
    var body: some View {
        ScrollView {
            ForEach(0..<10) { index in
                SwipeLeftRightContainer().listRowInsets(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
            }
        }
    }
    
}

struct SwipeLeftRight_Previews: PreviewProvider {
    static var previews: some View {
        SwipeListView()
    }
}
ARR
  • 2,074
  • 1
  • 19
  • 28
  • 1
    provide a full answer, – Arvind Patel Feb 26 '21 at 05:23
  • I tried this and maybe we can get something good out but to be honest to use the scroll view is difficult and it don't come near to what I get if I have a native implementation with UITableView. Did you improve your code In the meanwhile ? – Marc T. Apr 11 '21 at 09:07
0

Now with IOS 15 , Swift 5.5 we can add a Swipe action like this

struct ContentView: View {
    @State private var total = 0

    var body: some View {
        NavigationView {
            List {
                ForEach(1..<100) { i in
                    Text("\(i)")
                        .swipeActions(edge: .leading) {
                            Button {
                                total += i
                            } label: {
                                Label("Add \(i)", systemImage: "plus.circle")
                            }
                            .tint(.indigo)
                        }
                        .swipeActions(edge: .trailing) {
                            Button {
                                total -= i
                            } label: {
                                Label("Subtract \(i)", systemImage: "minus.circle")
                            }
                        }
                }
            }
            .navigationTitle("Total: \(total)")
        }
    }
}
Shehata Gamal
  • 98,760
  • 8
  • 65
  • 87