Home > Enterprise >  SwiftUI ForEach Very Slow Over Large JSON
SwiftUI ForEach Very Slow Over Large JSON

Time:06-20

I have a field where the user begins typing the name of a prescription drug and after 3 characters it searches through a 4.5mb JSON file containing over 36k entries. There are only two fields in the struct holding the decoded JSON (name and id) and I am doing a simple ForEach. However it is extremely slow and I need to make it real-time. This is a local JSON file in the app (i.e. I am not calling to an external URL).

There are a few issues I need to resolve:

  1. I need to make this as responsive as possible
  2. I need to return the results sorted by the name, but including numbers (i.e. Tylenol 50MG should come before Tylenol 100MG).

For issue #2 I tried using compare, which works but slowed things down even further, as follows:

var allDrugsSorted: [Mod_Prescription_DrugsList] {
    return Mod_Prescription_DrugsList.allDrugs.sorted { first, second in
        first.name.compare(second.name, options: .numeric) == .orderedAscending
    } // End Return
}

This is my ForEach:

if isFocused == .prescription_name &&
viewModel.prescriptionName.count >= 3 {
    ForEach(
        viewModel.allDrugsSorted.filter {
            $0.name
                .lowercased()
                .hasPrefix(
                    viewModel.prescriptionName.lowercased()
                )
        }.prefix(5), id: \.self) { prescription in
            Button {
                isFocused = nil
            } label: {
                VStack(alignment: .leading) {
                    Text(LocalizedStringKey(prescription.name))
                }
                .frame(maxWidth: .infinity, alignment: .leading)
            } // End Label
            .contentShape(Rectangle())
            .padding()
    } // End ForEach
} // End If

I already tried the .id(UUID()) trick and it did nothing to help.

This is the struct in which I am loading the JSON:

struct Mod_Prescription_DrugsList: Codable, Hashable {
    let rxcui: String
    let name: String

    static let allDrugs = Bundle.main.decode([Mod_Prescription_DrugsList].self, from: "Drugs.json") 
    static let example = allDrugs[0]
}

This is the decode extension on Bundle:

extension Bundle {
    func decode<T: Decodable>(
        _ type: T.Type,
        from file: String,
        dateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .deferredToDate,
        keyDecodingStrategy: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys
    ) -> T {
        guard let url = self.url(
            forResource: file,
            withExtension: nil
        ) else {
            fatalError("Failed to locate \(file) in bundle.")
        }

        guard let data = try? Data(
            contentsOf: url
        ) else {
            fatalError("Failed to load \(file) from bundle.")
        }

        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = dateDecodingStrategy
        decoder.keyDecodingStrategy = keyDecodingStrategy

        do {
            return try decoder.decode(T.self, from: data)
        } catch DecodingError.keyNotFound(let key, let context) {
            fatalError("Failed to decode \(file) from bundle due to missing key '\(key.stringValue)' - \(context.debugDescription)")
        } catch DecodingError.typeMismatch(_, let context) {
            fatalError("Failed to decode \(file) from bundle due to type mismatch - \(context.debugDescription)")
        } catch DecodingError.valueNotFound(let type, let context) {
            fatalError("Failed to decode \(file) from bundle due to missing \(type) value - \(context.debugDescription)")
        } catch DecodingError.dataCorrupted(_) {
            fatalError("Failed to decode \(file) from bundle because it appears to be invalid JSON")
        } catch {
            fatalError("Failed to decode \(file) from bundle - \(error.localizedDescription)")
        }
    }
}

I also tried creating a decoded array in the view model on init and though it speeds things up a little, it's still not great, and it's unusable because the view now takes almost a minute to load. self.allDrugsArray is an @Published var in the view model. Here's the function I wrote:

func getAllDrugs() {
    let sortedDrugs = Mod_Prescription_DrugsList.allDrugs.sorted { first, second in
        first.name.compare(second.name, options: .numeric) == .orderedAscending
    }
    sortedDrugs.forEach { drug in
        self.allDrugsArray.append(drug)
    }
}

CodePudding user response:

You really need to restructure this. You load your List, sort it and filter it every time your View needs to update. You should keep this list in a sorted state in Memory and filter it as you need.

Following architecture should work:

import Combine

class Viewmodel: ObservableObject{
    //used to show the results
    @Published var sortedAndFiltered: [Mod_Prescription_DrugsList] = []
    //used to store the list
    @Published var sorted: [Mod_Prescription_DrugsList] = []
    //filter string
    @Published var prescriptionName: String = ""
    //Load, sort and assign the items here
    
    init(){
        $prescriptionName
            .debounce(for: 0.4, scheduler: RunLoop.main) //Wait for user to stop typing
            .receive(on: DispatchQueue.global()) // perform filter on background
            .map{[weak self] filterString in
                guard filterString.count > 3, let self = self else{
                    return []
                }
                //n apply the filter
                return self.sorted.filter {
                    $0.name
                        .lowercased()
                        .hasPrefix(
                            filterString.lowercased()
                        )
                }
            }
            .receive(on: RunLoop.main) // switch back to uithread
            .assign(to: &$sortedAndFiltered)
    }
    
    
    func load(){
        sorted = Mod_Prescription_DrugsList.allDrugs.sorted { first, second in
            first.name.compare(second.name, options: .numeric) == .orderedAscending
        }
    }
    
}

struct ContentView: View{
    
    @FocusState var isFocused: Bool
    @StateObject private var viewmodel: Viewmodel = Viewmodel()
    
    var body: some View{
        VStack{
            if isFocused &&
            viewmodel.sortedAndFiltered.count >= 3 {
                ForEach(viewmodel.sortedAndFiltered, id: \.self) { prescription in
                        Button {
                            isFocused = false
                        } label: {
                            VStack(alignment: .leading) {
                                Text(LocalizedStringKey(prescription.name))
                            }
                            .frame(maxWidth: .infinity, alignment: .leading)
                        } // End Label
                        .contentShape(Rectangle())
                        .padding()
                } // End ForEach
            } // End If
        }.onAppear{ // I had to add a VStack to be able to do this just add it to the element surrounding your ForEach
            // If list is empty load it in the background, so your app stays responsive
            if viewmodel.sorted.count == 0{
                Task{
                    viewmodel.load()
                }
            }
        }

    }
}

This is a more generalized approach so you will have to integrate this solution in your work. I have commented as much as I thought would be necessary. If you have questions don´t hessitate to ask. I didn´t have the chance to test this, so there may be some minor things to adress.

  • Related