组合 + SwiftUI Form + RunLoop 导致表格视图呈现不可预测

jn_*_*pdx 5 swiftui combine

我有一个组合函数,用于搜索项目列表并返回匹配项。它不仅会跟踪向用户显示哪些与搜索词匹配的项目,还会跟踪哪些项目已被用户标记为“已选择”。

该功能运行良好,包括动画,直到我添加.debounce(for: .seconds(0.2), scheduler: RunLoop.main) 加入.receive(on: RunLoop.main)Combine 发布者链。在这一点上,结果的呈现View变得莫名其妙——项目标题开始显示为标题视图,项目重复等。

您可以在随附的 GIF 中看到结果。

GIF 版本使用.receive(on: RunLoop.main). 注意我什至不在这里使用搜索词,尽管它也会导致有趣的结果。这也可能是值得注意的是,一切运行正常问题是否系withAnimation { }被删除。

我希望能够使用,debounce因为列表最终可能会非常大,而且我不想在每次击键时过滤整个列表。

在这些情况下,如何让表格视图正确呈现?

示例代码(有关痛点和代码解释,请参阅内联注释。它应该按编写的方式运行良好,但如果两个相关行中的任何一行都未注释):


import SwiftUI
import Combine
import UIKit

class Completer : ObservableObject {
    @Published var items : [Item] = [] {
        didSet {
            setupPipeline()
        }
    }
    @Published var filteredItems : [Item] = []
    @Published var chosenItems: Set<Item> = []
    @Published var searchTerm = ""
    
    private var filterCancellable : AnyCancellable?
    
    private func setupPipeline() {
        filterCancellable =
            Publishers.CombineLatest($searchTerm,$chosenItems) //listen for changes of both the search term and chosen items
            .print()
            // ** Either of the following lines, if uncommented will cause chaotic rendering of the table **
            //.receive(on: RunLoop.main) //<----- HERE --------------------
            //.debounce(for: .seconds(0.2), scheduler: RunLoop.main) //<----- HERE --------------------
            .map { (term,chosen) -> (filtered: [Item],chosen: Set<Item>) in
                if term.isEmpty { //if the term is empty, return everything
                    return (filtered: self.items, chosen: chosen)
                } else { //if the term is not empty, return only items that contain the search term
                    return (filtered: self.items.filter { $0.name.localizedStandardContains(term) }, chosen: chosen)
                }
            }
            .map { (filtered,chosen) in
                (filtered: filtered.filter { !chosen.contains($0) }, chosen: chosen) //don't include any items in the chosen items list
            }
            .sink { [weak self] (filtered, chosen) in
                self?.filteredItems = filtered
            }
    }
    
    func toggleItemChosen(item: Item) {
        withAnimation {
            if chosenItems.contains(item) {
                chosenItems.remove(item)
            } else {
                searchTerm = ""
                chosenItems.insert(item)
            }
        }
    }
}

struct ContentView: View {
    @StateObject var completer = Completer()
    
    var body: some View {
        Form {
            Section {
                TextField("Term", text: $completer.searchTerm)
            }
            Section {
                ForEach(completer.filteredItems) { item in
                    Button(action: {
                        completer.toggleItemChosen(item: item)
                    }) {
                        Text(item.name)
                    }.foregroundColor(completer.chosenItems.contains(item) ? .red : .primary)
                }
            }
            if completer.chosenItems.count != 0 {
                Section(header: HStack {
                    Text("Chosen items")
                    Spacer()
                    Button(action: {
                        completer.chosenItems = []
                    }) {
                        Text("Clear")
                    }
                }) {
                    ForEach(Array(completer.chosenItems)) { item in
                        Button(action: {
                            completer.toggleItemChosen(item: item)
                        }) {
                            Text(item.name)
                        }
                    }
                }
            }
        }.onAppear {
            completer.items = ["Chris", "Greg", "Ross", "Damian", "George", "Darrell", "Michael"]
                .map { Item(name: $0) }
        }
    }
}

struct Item : Identifiable, Hashable {
    var id = UUID()
    var name : String
}

Run Code Online (Sandbox Code Playgroud)

jn_*_*pdx 1

@Asperi 的建议让我走上了正确的道路,思考withAnimation { }将调用多少个事件。在我最初的问题中,当使用or时, filteredItemsandchosenItems会在 RunLoop 的不同迭代中发生变化,这似乎是不可预测的布局行为的根本原因。receive(on:)debounce

通过将debounce时间更改为更长的值,这可以防止出现问题,因为一个动画将在另一个动画完成完成,但这是一个有问题的解决方案,因为它依赖于动画时间(如果没有明确的动画时间,则可能是幻数)已发送)。

我设计了一个有点俗气的解决方案,它使用PassThroughSubjectforchosenItems而不是@Published直接分配给属性。通过这样做,我可以将所有@Published值的分配移至 中sink,从而只发生一个动画块。

我对这个解决方案并不满意,因为它感觉像是一个不必要的黑客攻击,但它似乎确实解决了问题:


class Completer : ObservableObject {
    @Published var items : [Item] = [] {
        didSet {
            setupPipeline()
        }
    }
    @Published private(set) var filteredItems : [Item] = []
    @Published private(set) var chosenItems: Set<Item> = []
    @Published var searchTerm = ""
    
    private var chosenPassthrough : PassthroughSubject<Set<Item>,Never> = .init()
    private var filterCancellable : AnyCancellable?
    
    private func setupPipeline() {
        filterCancellable =
            Publishers.CombineLatest($searchTerm,chosenPassthrough)
            .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
            .map { (term,chosen) -> (filtered: [Item],chosen: Set<Item>) in
                if term.isEmpty {
                    return (filtered: self.items, chosen: chosen)
                } else {
                    return (filtered: self.items.filter { $0.name.localizedStandardContains(term) }, chosen: chosen)
                }
            }
            .map { (filtered,chosen) in
                (filtered: filtered.filter { !chosen.contains($0) }, chosen: chosen)
            }
            .sink { [weak self] (filtered, chosen) in
                withAnimation {
                    self?.filteredItems = filtered
                    self?.chosenItems = chosen
                }
            }
        chosenPassthrough.send([])
    }
    
    func toggleItemChosen(item: Item) {
        if chosenItems.contains(item) {
            var copy = chosenItems
            copy.remove(item)
            chosenPassthrough.send(copy)
        } else {
            var copy = chosenItems
            copy.insert(item)
            chosenPassthrough.send(copy)
        }
        searchTerm = ""
    }
    
    func clearChosen() {
        chosenPassthrough.send([])
    }
}

struct ContentView: View {
    @StateObject var completer = Completer()
    
    var body: some View {
        Form {
            Section {
                TextField("Term", text: $completer.searchTerm)
            }
            Section {
                ForEach(completer.filteredItems) { item in
                    Button(action: {
                        completer.toggleItemChosen(item: item)
                    }) {
                        Text(item.name)
                    }.foregroundColor(completer.chosenItems.contains(item) ? .red : .primary)
                }
            }
            if completer.chosenItems.count != 0 {
                Section(header: HStack {
                    Text("Chosen items")
                    Spacer()
                    Button(action: {
                        completer.clearChosen()
                    }) {
                        Text("Clear")
                    }
                }) {
                    ForEach(Array(completer.chosenItems)) { item in
                        Button(action: {
                            completer.toggleItemChosen(item: item)
                        }) {
                            Text(item.name)
                        }
                    }
                }
            }
        }.onAppear {
            completer.items = ["Chris", "Greg", "Ross", "Damian", "George", "Darrell", "Michael"]
                .map { Item(name: $0) }
        }
    }
}

struct Item : Identifiable, Hashable, Equatable {
    var id = UUID()
    var name : String
}
Run Code Online (Sandbox Code Playgroud)