How do I efficiently filter a long list in SwiftUI

2020-06-23 08:12发布

问题:

I've been writing my first SwiftUI application, which manages a book collection. It has a List of around 3,000 items, which loads and scrolls pretty efficiently. If use a toggle control to filter the list to show only the books I don't have the UI freezes for twenty to thirty seconds before updating, presumably because the UI thread is busy deciding whether to show each of the 3,000 cells or not.

Is there a good way to do handle updates to big lists like this in SwiftUI?

var body: some View {
        NavigationView {
            List {
                Toggle(isOn: $userData.showWantsOnly) {
                    Text("Show wants")
                }

                ForEach(userData.bookList) { book in
                    if !self.userData.showWantsOnly || !book.own {
                        NavigationLink(destination: BookDetail(book: book)) {
                            BookRow(book: book)
                        }
                    }
                }
            }
        }.navigationBarTitle(Text("Books"))
    }

回答1:

Have you tried passing a filtered array to the ForEach. Something like this:

ForEach(userData.bookList.filter {  return !$0.own }) { book in
    NavigationLink(destination: BookDetail(book: book)) { BookRow(book: book) }
}

Update

As it turns out, it is indeed an ugly, ugly bug:

Instead of filtering the array, I just remove the ForEach all together when the switch is flipped, and replace it by a simple Text("Nothing") view. The result is the same, it takes 30 secs to do so!

struct SwiftUIView: View {
    @EnvironmentObject var userData: UserData
    @State private var show = false

    var body: some View {
        NavigationView {

            List {
                Toggle(isOn: $userData.showWantsOnly) {
                    Text("Show wants")
                }

                if self.userData.showWantsOnly {
                   Text("Nothing")
                } else {
                    ForEach(userData.bookList) { book in
                        NavigationLink(destination: BookDetail(book: book)) {
                            BookRow(book: book)
                        }
                    }
                }
            }
        }.navigationBarTitle(Text("Books"))
    }
}

Workaround

I did find a workaround that works fast, but it requires some code refactoring. The "magic" happens by encapsulation. The workaround forces SwiftUI to discard the List completely, instead of removing one row at a time. It does so by using two separate lists in two separate encapsualted views: Filtered and NotFiltered. Below is a full demo with 3000 rows.

import SwiftUI

class UserData: ObservableObject {
    @Published var showWantsOnly = false
    @Published var bookList: [Book] = []

    init() {
        for _ in 0..<3001 {
            bookList.append(Book())
        }
    }
}

struct SwiftUIView: View {
    @EnvironmentObject var userData: UserData
    @State private var show = false

    var body: some View {
        NavigationView {

            VStack {
                Toggle(isOn: $userData.showWantsOnly) {
                    Text("Show wants")
                }

                if userData.showWantsOnly {
                    Filtered()
                } else {
                    NotFiltered()
                }
            }

        }.navigationBarTitle(Text("Books"))
    }
}

struct Filtered: View {
    @EnvironmentObject var userData: UserData

    var body: some View {
        List(userData.bookList.filter { $0.own }) { book in
            NavigationLink(destination: BookDetail(book: book)) {
                BookRow(book: book)
            }
        }
    }
}

struct NotFiltered: View {
    @EnvironmentObject var userData: UserData

    var body: some View {
        List(userData.bookList) { book in
            NavigationLink(destination: BookDetail(book: book)) {
                BookRow(book: book)
            }
        }
    }
}

struct Book: Identifiable {
    let id = UUID()
    let own = Bool.random()
}

struct BookRow: View {
    let book: Book

    var body: some View {
        Text("\(String(book.own)) \(book.id)")
    }
}

struct BookDetail: View {
    let book: Book

    var body: some View {
        Text("Detail for \(book.id)")
    }
}


回答2:

I think we have to wait until SwiftUI List performance improves in subsequent beta releases. I’ve experienced the same lag when lists are filtered from a very large array (500+) down to very small ones. I created a simple test app to time the layout for a simple array with integer IDs and strings with Buttons to simply change which array is being rendered - same lag.



回答3:

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
}


回答4:

Check this article https://www.hackingwithswift.com/articles/210/how-to-fix-slow-list-updates-in-swiftui

In short the solution proposed in this article is to add .id(UUID()) to the list:

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

"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."



回答5:

This code will work correctly provided that you initialize your class in the 'SceneDelegate' file as follows:

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

var window: UIWindow?
var userData = UserData()


func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
    // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
    // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

    // Create the SwiftUI view that provides the window contents.
    let contentView = ContentView()

    // Use a UIHostingController as window root view controller.
    if let windowScene = scene as? UIWindowScene {
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = UIHostingController(rootView:
            contentView
            .environmentObject(userData)
        )
        self.window = window
        window.makeKeyAndVisible()
    }
}