BLACK FRIDAY: Save 50% on all my Swift books and bundles! >>

How to fix slow List updates in SwiftUI

Make it faster with this one weird trick!

Paul Hudson       @twostraws

If you have a SwiftUI list with lots of rows, you might find it's really slow to update when you sort or filter those rows – code that should run instantly might take one or two seconds, or if you have lots of items one or two minutes.

Note: This fix is no longer required; Apple resolved this issue in iOS 14.

I'm going to show you what code causes the problem, then show you the one line of SwiftUI code that fixes it, and finally the most important part: explain why the problem occurs so you aren't just adding code without understanding it.

Let's go to Xcode…

 

Prefer video? The screencast below contains everything in this tutorial and more – subscribe to my YouTube channel for more like this.

 

Save 50% in my WWDC sale.

SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

Our problem code

Here's a concise piece of SwiftUI code that demonstrates our problem:

struct ContentView: View {
    @State var items = Array(1...600)

    var body: some View {
        VStack {
            Button("Shuffle") {
                self.items.shuffle()
            }

            List(items, id: \.self) {
                Text("Item \($0)")
            }
        }
    }
}

There I have a view that has one property, which is an array of 600 integers from 1 to 600.

Inside the body there's a VStack with a Button that shuffles the items every time it's pressed, and a List that shows all the items. Shuffling the array is how we're simulating you changing the items, because it forces the list to update its rows.

If you run that code in the simulator you'll see the button and list of items, and if you press the button you'll see nothing happens – at least at first. If we wait a little longer… boom, the list updates. And if you press the button again, the same thing happens – in fact it happens every time you sort or filter the list.

Press it one last time, but this time I want you to open Xcode's debug navigator first so you can see the CPU usage. When you press Shuffle you'll see it maxes out the CPU for the whole time while the items are being shuffled, so it's not like the program is just going to sleep.

The one-line fix for slow SwiftUI lists

I'm going to show you exactly why this happens in just a moment, but first I want to show you the one-line fix. Back in Xcode, add this modifier to the list – not to the items in the list, but to the list itself:

.id(UUID())

So, your code should look like this:

List(items, id: \.self) {
    Text("Item \($0)")
}
.id(UUID())

Now if you build and run the code again you'll see you can press Shuffle as often as you like and it updates instantly. If you press it really fast you might see the CPU usage go up, but that's hardly surprising.

So, that's the problem and that's how it fixed. But I don't want you just copying code into your project and hoping for the best, because that doesn't teach you anything and you're missing an opportunity to learn more about how SwiftUI works.

What makes it slow?

Comment out our one-line fix so we're back to the old code again, and then go to the Product menu and choose Profile. This will launch Instruments for our app, which is Xcode's built-in performance analysis tool.

There are lots of ways Instruments can examine our code, but the best option here is Time Profiler because it reports what our code was doing while it was running. Now press Record, which will cause the app to launch in the simulator while Time Profiler is watching.

This top row is the CPU usage, and there's a little spike there when the app launches, but it settles down to zero because our app is just idling. Now watch what happens when you switch to the simulator and press Shuffle: bang! That CPU usage spikes up to max, and just basically stays there for a few seconds while the list is being updated.

So, the CPU is being maxed out, but that doesn't tell us anything we didn't already know. To see why it's being maxed out we need to look in the Time Profilers inspectors, and in particular we should be looking for the heaviest stack trace. This is a brilliant feature in Instruments that tells us what one piece of code took the most time in our program, so if you wanted to look for just one piece of code to optimize this is usually a good place to start.

You can see some things are white and others gray; the white code was our own code, whereas the gray stuff is Apple's framework code. You can see next to each method name is a number telling us how many milliseconds were spent in each method, and you can see that thousands of milliseconds were spent in PlatformViewChild.update(), thousands of milliseconds in ListCoreBatchUpdates.formUpdates(), thousands in computeRemovesAndInserts(), and so on.

Finally, you reach CollectionChanges.formChanges, and in my test 4833 milliseconds were spent there. Below that 2818 milliseconds in Collection.commonPrefix, and below that 1433 milliseconds in this protocol witness for Collection.subscript.read. Between these three methods is a 3.4 second gap, and the work just carries on going down afterwards.

So, our heaviest stack trace – what one piece of code caused the most work – is telling us that Collection.formChanges is ultimately responsible for 4.8 seconds of work. And over in the main Time Profiler output you can see that the total CPU time across the entire run is only 5.17 seconds. That means calling Collection.formChanges was the vast majority of our work.

It's a different list, promise!

Back in our code, you might be able to see what the problem is. Our list shows all 600 strings in the items array, and the array is marked with @State. That's a Swift property wrapper that allows our view's value to change, but it also means that when the array does change the body property will be reinvoked – it will update the view to reflect those changes.

The list itself hasn't changed, but the things inside the list – the rows that come from itemshave changed, because they are now in a new order. So, List decides it wants to see how the items have changed, so it goes through its original items and the new items and figures out which items are new, which have been removed, and which have just been moved. This is a really neat feature, because it allows the list to animate its changes, and it's also why List needs to have that id parameter – it needs to be able to identify each row uniquely, so it can tell when they move.

The problem is, it's trying to compare 600 row items against 600 other row items, and that's extremely slow. Worse, if we had used 10,000 rows the code would effectively never finish; it would just take too long.

Go ahead and uncomment our fix for this, which is to use the id() modifier with a new UUID. Every time you create a UUID you get a different string of letters and numbers that are guaranteed to be unique – you can try it yourself on the terminal by calling the command uuidgen a few times.

Remember, SwiftUI reinvokes the body property every time an @State property changes, which means we'll get a new UUID every time the array changes. That new UUID then goes into the id() modifier, which is used to uniquely identify views. We've told SwiftUI that this list has a unique identifier, and that identifier is a new UUID every time.

So, what happens is that SwiftUI takes a look at the List of 600 items it had before, takes a look at the new List of 600 items, and decides they are different lists, so it just replaces one with the other. Without the id() modifier, SwiftUI realizes the list is actually the same one and so it doesn't change it. Instead, it goes inside the list and starts comparing all the items to figure out what changed.

That's why our one-line fix works so well: we're making SwiftUI think the list itself is different so it doesn't try to look inside it to figure out the differences itself. When I showed this tip to Dave DeLong, he described it with a brilliant UIKit analogy: it's the equivalent of calling reloadData() on a UITableView instead of animating changes.

Now, there is a downside to using id() like this: you won't get your update animated. Remember, we're effectively telling SwiftUI the old list has gone away and there's a new list now, which means it won't try to move rows around in an animated way. However, if your app freezes for 10 seconds because of SwiftUI calculating the differences, losing a little animation is a small price to pay!

Save 50% in my WWDC sale.

SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 4.2/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.