List updates very slowly and blocks UI

I am building an app using SwiftUI that should display work orders I get from a web service. I have successfully got them from a web service using the following function:

func getAllWorkOrders(completion: @escaping ([WorkOrder]) -> ()) {
        guard let url = URL(string: "http://somedomain/api/workOrders")
            else {
                fatalError("URL is not correct")
        }
        
        let conn = URLSession(configuration: URLSessionConfiguration.ephemeral, delegate: self, delegateQueue: nil)
        conn.dataTask(with: url) { data, _, _ in
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full)
            let workOrders = try! decoder.decode([WorkOrder].self, from: data!)
            DispatchQueue.main.async {
                completion(workOrders)
            }
        }.resume()
}

while my WorkOrder struct look like this:

struct WorkOrder: Identifiable, Codable {
    var id: Int
    var ticket: Int?
    var date: Date
    var description: String
    var astue: Bool
    var state: WorkOrderState
    var jobs: [WorkOrderJob]
    var project: Project
}

I have also created a view model which looks like this:

class WorkOrderListViewModel: BindableObject {
    var workOrders = [WorkOrder]() {
        didSet {
            didChange.send()
        }
    }
    var astue = false {
        didSet {
            didChange.send()
        }
    }
    var workOrderState = 0 {
        didSet {
            didChange.send()
        }
    }
    var searchTerm = "" {
        didSet {
            didChange.send()
        }
    }
    var filteredWorkOrders: [WorkOrder] {
        var filtered = workOrders
        if astue {
            filtered = filtered.filter {
                $0.astue == true
            }
        }
        if workOrderState > 0 && workOrderState < 5 {
            filtered = filtered.filter {
                $0.state.id == workOrderState
            }
        }
        if !searchTerm.isEmpty {
            filtered = filtered.filter {
                $0.description.lowercased().contains(searchTerm.lowercased()) || "\($0.ticket ?? 0)".contains(searchTerm.lowercased())
            }
        }
        return filtered
    }
    init() {
        fetchWorkOrders()
    }
    
    func fetchWorkOrders() {
        WebService().getAllWorkOrders {
            self.workOrders = $0
        }
    }
    
    let didChange = PassthroughSubject<Void, Never>()
}

In it, I have a couple of filtering criteria: searchTerm, workOrderState and astue. I have a filtering view bound to those 3 properties. Given that web service is quite primitive and there is no paging or filtering (which means that I can get only all work orders, approximately 2k of them), and I need to filter them by one or many of the criteria, I have a calculated propery filteredWorkOrders in the view model which does the required filtering. My problem arises when I do some filtering. In UI, a have a TextField bound to searchTerm, SegmentedControl bound to workOrderState and Toogle bound to astue. When I apply one of the filtering criteria which drastically changes the number of records (for example when switching from workOrderState=0 to workOrderState=1 where it goes from 2k to just 50), UI gets unresponsive and the filtering lasts 6-7 seconds. However, when changes aren't that dramatic (adding astue=true which makes count of work orders go from 50 to 5) it happens in an instant.


SwiftUI view for the list and a row look like this:

struct WorkOrders : View {
    
    @ObjectBinding var data = WorkOrderListViewModel()
    var body: some View {
        List {
            SearchField(data: data)
            ForEach(self.data.filteredWorkOrders) { workOrder in
                NavigationLink(destination: WorkOrderDetails(workOrder: workOrder)) {
                        WorkOrderRow(workOrder: workOrder)
                    }
            }
        }
            .listStyle(.grouped)
            .navigationBarTitle(Text("Work orders (\(String(format: "%d", data.filteredWorkOrders.count)))"), displayMode: .large)
            .navigationBarItems(trailing: Button(action: refresh) {
                Image(systemName: "arrow.clockwise")
            })
    }
    
    private func refresh() {
        data.fetchWorkOrders()
    }
}
struct WorkOrderRow : View {
    @State var workOrder: WorkOrder
    var body: some View {
        HStack {
            ZStack {
                Circle()
                    .fill(backgroundColor())
                    .frame(width: 20, height: 20, alignment: Alignment(horizontal: .leading, vertical: .center))
                
            }
            VStack(alignment: .leading) {
                HStack {
                    if workOrder.astue {
                        Image(systemName: "bolt.fill")
                    }
                    Text(String(format: "%d", workOrder.ticket ?? 0))
                        .font(.caption)
                    
                    Spacer()
                    Image(systemName: "calendar")
                    Text(workOrder.dateString)
                        .font(.caption)
                }
                
                Text(workOrder.description)
                    .lineLimit(nil)
                }
                .padding([.horizontal])
            }
    }
    
    private func backgroundColor() -> Color {
        switch workOrder.state.id {
        case 1:
            return Color.red
        case 2:
            return Color.yellow
        case 3:
            return Color.green
        case 4:
            return Color.blue
        default:
            return Color.clear
        }
    }
}

What am I missing?

Replies

Sorry, I don't have much useful to say other than "me too".


I have a similar situation — a list of 5009 items, and a filter box. Filtering to 2752 items takes up to about 30s, and filtering the 2752 down to 17 takes nearly as long. Filtering 17 to 9 is instantaneous. Filtering back up again is equally slow.


Instruments shows the vast majority of the time is spent in

CollectionChanges.formChanges<A, B>(from:to:)
in SwiftUI, but that doesn't give me a lot of hints as to how to speed it up. I tried cutting down the collection passed to
List
down to just IDs, but that made no appreciable difference.

At least I am not the only one. What I tried as well was to filter in the view itself instead of the view model, but it didn't make any difference.

I take a guess here… as SwiftUI is trying to find out which elements of the collection have been changed, it compares the existing elements with the changed list. It looks like the scaling behavior is pretty exponential here, and this process should have a lot opportunity for optimization.


Until then, it might help to enforce a reload of the list instead of using a dynamic binding.

Still broken in Xcode 11b6. Looks like it'll ship this way, unable to make lists of more than a few tens of items!?


Kabe, I don't know what "enforce a reload" means in SwiftUI. Whether the list is passed by value to the component or is a field of an @ObjectBinding doesn't affect the performance. Either way, the resulting view tree gets diffed, including this horrifically slow implementation on the list.

unfortunately I'm facing the same problem with a list of 200 items.

I heard about the performance problems and after some research a colleague and me got to a solution.: Currently. If you have a big list array which you try to modify, the list completely blocks the UI until it’s finished, which can take many seconds. To prevent this and work with thousands of entries without blocking everything, you just have to empty the list array and then fill it with the new modified array.


So:

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.


With a simple List like this and an "listArray" with your items:

List(listArray){item in ... }


Filter your array as needed and store the result somewhere. Then empty your original array! This way, SwiftUI won't do any comparisons and checks like it does when you just modifiy the array. After the array is empty fill it again with your new list. You probably will need a delay between those steps. I choose 100 milliseconds but a much lower number might also work.

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


With this simple method, the list will basically get rendered in the same short amount of time it usually does, even with tousands of items. Only downside is that you will probably see that the list gets emptied and refilled. But for now this ist the simplest and most effective solution I have found, until Apple manages to find a fiy for the perofmance issues.

I am having this same issue and I believe this should be able to help me. Only issue is that I am not using an Array to populate my ForEach loop. I am using a UISearchBar Wrapper into my SwiftUI View, then I am using a dynamic FetchRequest to retreieve the data. I tried changing the search text to something that would clear the screen for a few milliseconds, but this did not help. Is there any way to tell SwiftUI to "clear the List" prior to having it repopulate with more data?


So in essence, my ForEach contains whatever is in:


var fetch: FetchRequest<MyData>

var theData: FetchedResults<MyData>{fetch.wrappedValue}


is listed in:


ForEach(theData, id: \.self) { fetchedData in


This fetch changes with a predicate that is bound to a <Bindable>String in my UISearchBar Wrapper..

I do not have anywhere in code to set this to nil in between changes..


Any ideas?


Thank you!

Does anyone know if this issue is resolved or made better with iOS 15? I'm having the same issue. Setting the array to [] and repopulating or adding a .id(UUID()) to the list both have some tradeoffs. Notably not saving scroll position or updating the view when using NavigationLink to navigate to other views.