New in SwiftUI 3: Custom Swipe Action with Conditional Actions

In previous article, we covered swipe action modifier in detail. In this article we will look at another practical example of swipe action modifier but main focus will be to build conditional swipe modifier.

Final product will look like this:


Let’s get started 🎬

We will follow MVVM(Model View View Model) design pattern to build this page so let’s start with Model. We will create Mail model

struct Mail: Identifiable {
    let id = UUID()
    var from: String
    var initials: String
    var background: Color
    var subject: String
    var detail: String
    var readStatus: ReadStatus
    var time: String
}

Our model will be conforming to identifiable protocol so we can iterate over items and SwiftUI can manage its state at the same time be able to identify each element in model uniquely.

ReadStatus is an enum representing two states of mail’s read status:

enum ReadStatus {
    case read
    case unread
}

IRL scenario, data will come from some backend service but to keep our focus on iOS and SwiftUI development, we will use dummy data that will come from an extension written for Mail . We will have static sampleData computed property as an extension which will return an array of Mail as shown below:

extension Mail {
    static var sampleData: [Mail] {
        [
            Mail(from: "Albus Dumbledore", initials: "AD", background: Color.pink.opacity(0.5), subject: "About Nicolas Flamel", detail: "To the well-organized mind, death is but the next great adventure.", readStatus: .unread, time: "8:25 PM"),
            Mail(from: "Kingsley Shacklebolt", initials: "KS", background: Color.orange.opacity(0.5), subject: "Battle of Hogwarts", detail: "Every human life is worth the same, and worth saving.", readStatus: .read, time: "12:25 PM"),
            Mail(from: "Hermione Granger", initials: "HG", background: Color.blue.opacity(0.5), subject: "Wrong vs Right", detail: "Dumbledore says people find it far easier to forgive others for being wrong than being right.", readStatus: .read, time: "12:25 PM"),
            Mail(from: "Luna Lovegood", initials: "LL", background: Color.purple.opacity(0.5), subject: "Crumple-Horned Snorkack", detail: "You can laugh, but people used to believe there were no such things as the Blibbering Humdinger or the Crumple-Horned Snorkack!", readStatus: .unread, time: "2:25 PM"),
            Mail(from: "Albus Dumbledore", initials: "AD", background: Color.pink.opacity(0.5), subject: "Thing about pain", detail: "Numbing the pain for a while will make it worse when you finally feel it", readStatus: .unread, time: "7:55 AM"),
        ]
    }
}

Once we have our model ready, let’s move on to ViewModel side.

Our view model will have two published properties. mails published property will store all the mails and pinned property will store mails pinned by the user.

View model will also take responsibility to fetch data so in this case we will request data from Mail extension.

Apart from fetching data, keeping track of pinned mail items our view model will be responsible for making any update to data so our view doesn’t have to worry about executing logic on data and can focus on building the presentation part of the app.

class MailHomeViewModel: ObservableObject {
    @Published var mails: [Mail] = []
    @Published var pinned: [Mail] = []
    
    func getAll() {
        mails = Mail.sampleData
    }
    
    func updateReadStatus(mail: Mail, readStatus: ReadStatus) {
        if let idx = getIndex(for: mail) {
            mails[idx].readStatus = readStatus
        }
    }
    
    func moveToPinned(mail: Mail) {
        if let idx = getIndex(for: mail) {
            pinned.append(mail)
            mails.remove(at: idx)
        }
    }
    
    private func getIndex(for mail: Mail) -> Int? {
        if let idx = mails.firstIndex(where: { $0.id == mail.id }) {
            return idx
        }
        return nil
    }
}

Now, we can move on to build UX side of the page ✨

Let’s first build MailCell which will represent a single mail row in the list of mails.

struct MailCell: View {
    var mail: Mail
    
    var body: some View {
        HStack(alignment: .top) {
            HStack {
                Circle()
                    .fill(Color.blue)
                    .frame(width: 5, height: 5)
                    .opacity(mail.readStatus == .read ? 0.0 : 1.0)
                Text(mail.initials)
                    .padding()
                    .background(mail.background)
                    .clipShape(Circle())
            }
            
            VStack(alignment: .leading) {
                HStack {
                    Text(mail.from)
                        .bold()
                    
                    Spacer()
                    
                    Text(mail.time)
                        .font(.subheadline)
                        .foregroundColor(.gray)
                }
                
                Text(mail.subject)
                    .bold()
                    .font(.subheadline)
                
                Text(mail.detail)
                    .font(.caption)
                    .foregroundColor(.gray)
            }
        }
    }
}

Once we have the cell ready, we can use this cell inside the list of mails.

Our MailHome view will have an @ObservedObject variable of MailHomeViewModel type. This variable will observe changes inside the viewModel and will publish changes for properties that are decorated with @Published property wrapper.

struct MailHome: View {
    
    @ObservedObject var vm = MailHomeViewModel()
    
    var body: some View {
        NavigationView {
            List {
                if !vm.pinned.isEmpty {
                    Section(header: Text("Pinned mails")) {
                        ForEach(vm.pinned) { mail in
                            MailCell(mail: mail)
                                .swipeActions(allowsFullSwipe: false) {
                                    readUnreadActionButton(mail: mail)
                                }
                        }
                    }
                }
                
                if !vm.mails.isEmpty {
                    Section(header: vm.pinned.isEmpty ? Text("") : Text("Unpinned mails")) {
                        ForEach(vm.mails) { mail in
                            MailCell(mail: mail)
                                .swipeActions(allowsFullSwipe: false) {
                                    readUnreadActionButton(mail: mail)
                                }
                                .swipeActions(edge: .leading, allowsFullSwipe: false) {
                                    pinnedActionButton(mail: mail)
                                }
                        }
                    }
                }
                
            }.navigationTitle("Inbox")
                .onAppear {
                    vm.getAll()
                }
        }
    }
    
    private func readUnreadActionButton(mail: Mail) -> some View {
        Button {
            vm.updateReadStatus(mail: mail, readStatus: mail.readStatus == .read ? .unread : .read)
        } label: {
            Label(mail.readStatus == .read ? "Unread" : "Read", systemImage: mail.readStatus == .read ? "envelope.fill" : "envelope.open.fill")
        }
        .tint(.red)
    }
    
    private func pinnedActionButton(mail: Mail) -> some View {
        Button {
            vm.moveToPinned(mail: mail)
        } label: {
            Label("Pin", systemImage: "pin.fill")
        }
        .tint(.yellow)
    }
}

Notice updateReadStatus function call inside readUnreadActionButton function, this is how we can leverage power of viewModel to change state for mail as well as keep track of that change so our custom action can change from read state to unread and back.


With that, we have reached the end of this article. Thank you once again for reading, if you liked it, don’t forget to subscribe.