The quest to make SwiftUI swipe-to-delete not look and feel terrible

I don’t know if I’m the only one who feels this way, but I hate the way the swipe-to-delete function looks and feels in SwiftUI. In UIKit, after you swipe a cell, the background animates to fill the rest of the cell with destructive red and the cell collapses. This feels like it’s finishing the swipe you started and disposing of that cell. It’s great!

Some abomination
UIKit’s beautiful swipe-to-delete

There are two issues I have with SwiftUI’s behavior:

  • When you swipe to delete a cell, the cell starts returning back to normal after the swipe, reducing the amount of red. It feels like it’s undoing your action.
  • If you delete the bottom cell in a list, it doesn’t animate properly; it stays visible until the cell finishes collapsing, then it suddenly disappears.

For this second issue, I found a relatively easy fix if you’re using a plain-style table, but it comes with a side-effect. If you simply add a blank cell at the end of the list, then that blank cell with cover the cell being deleted, preventing the issue. This can be done by simply adding a blank Text after your ForEach in your List.

List {
    ForEach(...) {
        ...
    }
    Text("")
}

This does, unfortunately have the unwanted side-effect of leaving an empty cell at the end of your list. If you are using a plain-style table, this will only be noticeable when the list is taller than the screen and you scroll to the bottom, but it doesn’t look too bad, and many users might not even notice.

The last cell being covered when it’s deleted

The first issue was a much harder fix, however.

Animating cell deletion

In order to make the cell swipe-to-delete look and feel better, I sought to recreate the animation you see in UIKit. After much trial and error, I got results that I’m reasonably satisfied with. Unfortunately, I haven’t developed a way to easily implement this onto any list, which is the reason this article isn’t a tutorial. I will, though, show the process and results, so hopefully you can find a way to implement it in your own projects.

The basic view

The basic view I was trying to create was a list of strings. Here’s a simple SwiftUI implementation of that:

struct BasicView: View {
    @State var items: [String] = [
        "One",
        "Two",
        "Three",
        "Four",
        "Five",
        "Six"
    ]
    
    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                Text(item)
            }
            Text("")
        }
    }
}

Notice the Text("") at the end to improve the animation for deleting the last cell.

Marking cells for deletion

In order to animate a cell’s deletion, the cell itself needs to know it’s going to be deleted so it can perform an animation. The way I’m going to be marking cells for deletion is by using a set of strings, since strings are the objects I’m displaying in my list. If the set contains a cell’s string, that cell is marked for deletion. If you swipe to delete a cell, it adds that cell’s string to the set. I found it important to use a set here as opposed to an array, as it is much more efficient to check if an object exists in a set than if it exists in an array.

@State var delete = Set<String>()

var body: some View {
    List {
        ...
        }.onDelete { indexSet in
            self.remove(at: indexSet)
        }
        Text("")
    }
}

func remove(at indexSet: IndexSet) {
    guard let index = indexSet.first else { return }
    delete.insert(items[index])
}

Here we define a State variable to hold the set of cells to delete, and when you swipe a cell, it adds it to that set. This does not actually delete the cell yet. Right now we’re just trying to get the animation in, then we can delete it.

Adding an animated red background

In order to add a red background for when you delete the cell, I used the .listRowBackground attribute on the Text in the ForEach. This allows you to use any view as the background of the cell.

Text(item)
    .listRowBackground(
        GeometryReader { geo in
            Rectangle()
                .foregroundColor(Color.red)
                .frame(width: geo.size.width * self.delete.contains(item).cgFloat)
                .animation(.default)
        }
)

The first thing we have in the background view is a GeometryReader. This allows us to grab info about the size of the view. Inside that is a Rectangle. This rectangle’s width is set to the width of the GeometryProxy (the width of the cell), times this term self.delete.contains(item).cgFloat, which will always be either 0 or 1. This checks if the set contains the current cell. If so, the width of the rectangle should be the full width of the cell. If not, the width should be zero. This depends on an extension of Bool that I use in a lot of my projects:

extension Bool {
    var int: Int { self ? 1 : 0 }
    var cgFloat: CGFloat { self ? 1 : 0 }
}

This simply lets you treat a Bool as 0 for false and 1 for true. Then adding .animation(.default) means that as soon as the cell’s item is added to the set, it will the red rectangle filling the cell. Let’s see that in action:

We now have an animation, but it’s not quite right

This is looking pretty good, but the rectangle is expanding from the center. Getting it to expand from the right is actually a bit more complicated than I first expected. If you’ve seen tutorials on making a progress bar in SwiftUI, I basically did the same thing as that.

Text(item)
    .listRowBackground(
        GeometryReader { geo in
            ZStack(alignment: .trailing) {
                Rectangle()
                    .frame(width: geo.size.width)
                    .foregroundColor(Color.clear)
                Rectangle()
                    .foregroundColor(Color.red)
                    .frame(width: geo.size.width * (self.delete[item] ?? false).cgFloat)
                    .animation(.default)
            }
        }
)

Here we’re creating a ZStack with trailing alignment to make the rectangle animate from the right where we swipe from. The reason we have the extra, clear rectangle is to give context to our red rectangle. Without it, the ZStack‘s frame would be the exact same size as our red rectangle, so setting an alignment wouldn’t affect its position at all, since it’s already taking up the entire frame.

Animation looking good!

Now the animation is looking good! We just need to make it actually delete the cell now.

Deleting the cell

Deleting the cell after performing the animation wasn’t quite as easy as I was hoping, but it wasn’t all that hard either.

We can’t simply add a line to remove the cell’s object from the list in the remove(at: ) function, because then the animation wouldn’t play. What we have to do instead is to add a very slight delay removing the cell so the animation can start first. I did this by using DispatchQueue to add a 0.1 second delay before removing the cell.

func remove(at indexSet: IndexSet) {
    guard let index = indexSet.first else { return }
    delete.insert(items[index])
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
        self.delete.remove(self.items[index])
        self.items.remove(at: index)
    }
}

With that, we have what I think is a pretty good swipe-to-delete animation. It’s still not as good as UIKit’s, but it’s much better than before.

The final, fixed SwiftUI swipe-to-delete

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: