SwiftUI List performane

I have a performance issue with List when I have a large amount of data that is replaced. Given the code below, a data set of about 3500 name items are loaded from a fetch request. Depending of the selected gender in the segmented picker these items are filtered and the displayed in the List. When the List first render I have no performance issue with the loading and rendering of items. It scrolls nicely and smoothly through 1700 items. But as soon as I switch gender through the segemented picker it takes about 30-45 seconds to render the List again.


I think this has to do with removing 1700 items and the inserting 1500 items again from the List. Is there a best practice how to reload a large amount of items in SwiftUI? Or can I reset the List before I load it again, since there is no issue initially.


Anyone else having issue same issue?


struct NameList: View {
    
    @ObservedObject fileprivate var global = GlobalSettings()
    
    @FetchRequest(
        entity: Name.entity(),
        sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)]
    ) var names: FetchedResults<Name>
    
    @State var selectedGender = Defaults.gender
    
    var body: some View {
        
        let filtered = names.filter { $0.gender == self.selectedGender }
        
        return NavigationView {
            
            VStack {
                
                Picker("Gender", selection: $global.gender) {

                    Text(Gender.female.rawValue.uppercased())
                        .tag(Gender.female)

                    Text(Gender.male.rawValue.uppercased())
                        .tag(Gender.male)

                    Text(Gender.unisex.rawValue.uppercased())
                        .tag(Gender.unisex)

                }
                .pickerStyle(SegmentedPickerStyle())
                .padding()
                
                List( filtered, id: \.self) { (item: Name) in
                   NameListRow(item: item)
                }
            }
            
        }
        
        .onReceive(Defaults.publisher(for: \.gender)) { (gender) in
            self.selectedGender = gender
        }
        
    }
    
}

Accepted Reply

My guess is your problem is that you are trying to filter the list directly or the array you pass to the list. Which leads to to whole thing being a big performance hog. Maybe try what Apple themselves showed in their WWDC videos: do not pass your array to „List(array){}“, instead create a List without passing anything „List{}“ and then inside the brackets do a for each with your array, where yo decide via classic SwiftUI „if“ when to render certain elements. This gives you multiple benefits and seems the be the recommended way in case you want to modify list content based on certain States. Apple used this method in their tutorials, to filter favorite items in a list if I remember correctly.


Update: Ok, I made small test yesterday and this approach also didnt work. Will keep an eye on this in case soem solution comes into my mind


Update 3: A collegue found a simple solution:

Instead of a complicated workaround, just empty the List array and then set the new filters array. It may be necessary to introduce a delay so that emptying the listArray won't be omitted by the followed write.

List(listArray){item in ... }


self.listArray = [] 
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
     self.listArray = newList
}

Replies

We've been seeing a similar issue, although 30-45 seconds is a crazy amount of time (our delays were on the order of a second or two, which is still far too slow). You might want to profile in instruments to see just what is taking all the time, or even just pause the debugger and grab a stack to see if there's anything suspicious there.


One workaround we use is to just render the entire unfiltered list, and individually hide any rows that should be filtered. E.g., instead of:


List(filtered, id: \.self) { (item: Name) in
    NameListRow(item: item)
}


You would do:


List(names, id: \.self) { (item: Name) in 
    item.gender == self.selectedGender ? NameListRow(item: item) : nil
}



Obviously this isn't a good general-purpose solution to filtering (since you wouldn't want to do it with millions of source rows where the filter would return only a handful), but if you are just switching between two sets of hundreds or thousands of records, rendering the whole thing and selectively hiding individual rows had made it much smoother for us.

I'd guess the time is being spent scanning the view hierarchy to determine which row views have been moved, added, or deleted. This may step a ways down the graph, so if that's the case it may be worth looking for ways to short-circuit that.


One option is to explicitly set an identifier on the row view using the

.id()
modifier. You should be able to pass the item's
NSManagedObjectID
for that. Another way to do the same thing (if
List
is written how I'd expect and is setting an identifier implicitly from its input) is to use
\.objectID
as the identifier in the
List
constructor.


Another way might be to have your

NameListRow
view conform to
Equatable
, either implicitly or explicitly using
item.objectID
to determine equality. Once you have that then you can use
NameListRow(item: item).equatable()
to return an 'equatable' version of your view, i.e. one which will use a single equality call to determine if it needs to be updated—and presumably to tell if it should be removed from or added to a list.


On top of this, it may be worth looking at defining your

@FetchRequest
property using the initializer that takes an
NSFetchRequest
directly. You might need to define your own initializer (and initialize
self._names = FetchRequest(...)
as a result) but that would potentially free you up to modify the predicate on the fetch request when the gender toggle changes, rather than doing your own filtering. I'm not sure how much difference that would make, to be honest, but there may be benefits from pushing as much as possible out to CoreData to handle.

By rendering the entire list and individually hide any rows that should be filtered out improves the performance to milliseconds, which is great. However, by returning nil I end up with an empty row, taking up space. And that is not what I want. Any solution to that ? ( I writing it exactly as your example)

Unfortunately, using \.objectID as identifier does not improve performance at all.


List(filtered, id: \.objectID) { (item: Name) in  
    NameListRow(item: item)  
}

I have tried to create a fetch request that change the predicate when toggle gender, but that does not improve performance either. It's not the fetch request or the filtering that takes time. It is the rendering, scanning the view hierarchy for changes as you mention.

Did the explicit

.id(item.objectID)
or
.equatable()
calls make any change at all? The latter is intended to make view hierarchy recursion unnecessary, so with that in place it theoretically would only need to visit the nodes for the list rows themselves and nothing below.

I have the exact same problem. It seems that SwiftUI is not very smart, it compares the entire list, not only the visible rows. And what is worst, it processes each row, so if you have complex rows showing more data from relationships, is even slower.


I decided NOT doing long list with SwiftUI for now, but now I have another problems because my app uses a synchronizer for updating the coredata and when something is modified in the background, does not trigger the UI update, not even using @FetchRequest


SwiftUI is still BETA IMHO

Don't know if this is what you meant, but none of them worked:


.id(item.objectID)


List(filtered, id: \.objectID) { (item: Name) in
    NameListRow(item: item)
        .id(item.objectID)
}


.equatable() (not sure if I'm using it correctly)


List(filtered, id: \.objectID) { (item: Name) in
    NameListRow(item: item).equatable()
}


struct NameListRow: View, Equatable {
    
    static func == (lhs: NameListRow, rhs: NameListRow) -> Bool {
        lhs.item.objectID == rhs.item.objectID
    }

    ...

}

My guess is your problem is that you are trying to filter the list directly or the array you pass to the list. Which leads to to whole thing being a big performance hog. Maybe try what Apple themselves showed in their WWDC videos: do not pass your array to „List(array){}“, instead create a List without passing anything „List{}“ and then inside the brackets do a for each with your array, where yo decide via classic SwiftUI „if“ when to render certain elements. This gives you multiple benefits and seems the be the recommended way in case you want to modify list content based on certain States. Apple used this method in their tutorials, to filter favorite items in a list if I remember correctly.


Update: Ok, I made small test yesterday and this approach also didnt work. Will keep an eye on this in case soem solution comes into my mind


Update 3: A collegue found a simple solution:

Instead of a complicated workaround, just empty the List array and then set the new filters array. It may be necessary to introduce a delay so that emptying the listArray won't be omitted by the followed write.

List(listArray){item in ... }


self.listArray = [] 
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
     self.listArray = newList
}

This really worked with great performance. Maybe not what I would expected from the framework, but until SwiftUI become more mature I think this would be an accepted answer.


Final solution that worked great:


struct NameList: View {
      
    @ObservedObject fileprivate var global = GlobalSettings()
      
    @FetchRequest(
        entity: Name.entity(),
        sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)]
    ) var names: FetchedResults<Name>
      
    @State var selectedGender = Defaults.gender
      
    @State var filtered = [Name]()
    
    var body: some View {
          
        NavigationView {
              
            VStack {
                  
                Picker("Gender", selection: $global.gender) {
  
                    Text(Gender.female.rawValue.uppercased())
                        .tag(Gender.female)
  
                    Text(Gender.male.rawValue.uppercased())
                        .tag(Gender.male)
  
                    Text(Gender.unisex.rawValue.uppercased())
                        .tag(Gender.unisex)
  
                }
                .pickerStyle(SegmentedPickerStyle())
                .padding()
                  
                List( filtered, id: \.objectID) { (item: Name) in
                   NameListRow(item: item)
                }
            }
              
        }
          
        .onReceive(Defaults.publisher(for: \.gender)) { (gender) in
            self.selectedGender = gender
            self.filterNames()
        }
        
        .onAppear {
            self.filterNames()
        }
          
    }
    
    fileprivate func filterNames() {
        self.filtered = []
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
            self.filtered = self.names.filter { $0.gender == self.selectedGender }
        }
    }
      
}

Happy to help, I didn’t have this problem yet, but I may in the future. I am sure Apple will improve SwiftUI in fix such special cases. Until then. We at least found a simple solution, even if it may not be perfect as you can see the list being empty for a second before it takes the new array.

Something that might make it a little more swifty is something in the publisher chain that would set an 'empty' value when a change appears, then publish the real value 100 milliseconds later—sort of like an old car where you had to double-clutch each time you changed gear. You might build that as a modifier on the publisher that you can just attach it to the Defaults publisher:


.onReceive(Defaults.publisher(for: \.gender).doubleClutched(.milliseconds(100))) { gender in
    self.selectedGender = gender
    self.filterNames()
}


I haven't explored Combine yet, so I can't suggest an implementation off the cuff like I can with SwiftUI, but from what I've read so far it seems like it should be possible to insert something like this into the chain of a repeating (is that the term they use?) publisher.

Genius

Is the time delay (e.g. 100 milliseconds) really necessary? Would just re-setting the "real" value of the array in the next run loop cycle be sufficient?

This helped me!


https://www.youtube.com/watch?v=h0SgafWwoh8

I have the similar problem.


I have a simple core data (one of the attributes called "finished" (Bool) ), and I use the data to make a List ("ListView").

When click the items on the List, it goes to a Detail List ("DetailView").

On the Detail List, I have a button to SHOW the status of "finished", and UPDATE its record in core data. I can use it to change and update the record successfully.

When I click the toggle button, at backend, the record's updated.


However, the main problem is it cannot reflect the changes in Detail List. Can I REFRESH / RELOAD the Detail List? How to do it? What's the best way to do this task?


Many thanks!