Swiftui 联合收割机&x2B;快捷表单+;RunLoop导致表视图呈现不可预测的结果

Swiftui 联合收割机&x2B;快捷表单+;RunLoop导致表视图呈现不可预测的结果,swiftui,combine,Swiftui,Combine,我有一个Combine函数,用于搜索项目列表并返回匹配项。它不仅跟踪要向用户显示的与搜索词匹配的项目,还跟踪用户标记为“已选择”的项目 该函数非常有效,包括动画,直到我在联合发布器链中添加.debounce(for:.秒(0.2),scheduler:RunLoop.main)或.receive(on:RunLoop.main)。此时,视图中结果的呈现变得不可思议地奇怪——项目标题开始显示为标题视图,项目重复,等等 您可以在附带的GIF中看到结果 GIF版本正在使用.receive(on:Run

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

该函数非常有效,包括动画,直到我在联合发布器链中添加
.debounce(for:.秒(0.2),scheduler:RunLoop.main)
.receive(on:RunLoop.main)
。此时,
视图
中结果的呈现变得不可思议地奇怪——项目标题开始显示为标题视图,项目重复,等等

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

GIF版本正在使用
.receive(on:RunLoop.main)
。注意,我甚至没有在这里使用搜索词,尽管它也会导致有趣的结果。还值得注意的是,如果删除
withAnimation{}
,则所有问题行都可以正常工作

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

在这种情况下,如何使表视图正确呈现?

示例代码(请参阅内联注释以了解代码的难点和解释。它应该运行良好,但如果两行中的任何一行未注释):


导入快捷键
进口联合收割机
导入UIKit
类完成符:ObserveObject{
@已发布的变量项:[Item]=[]{
迪塞特{
setupPipeline()
}
}
@已发布的变量filteredItems:[项]=[]
@已发布的变量chosenItems:Set=[]
@已发布的var searchTerm=“”
private var filterCancellable:是否可以取消?
专用函数设置管道(){
过滤器可更换=
publisher.CombineLatest($searchTerm,$chosenItems)//侦听搜索词和所选项目的更改
.print()
//**以下任一行如果未注释,将导致表格呈现混乱**

//.receive(on:RunLoop.main)/处理异步处理的问题…在默认情况下,所有操作都在一个(!)动画块内同步执行,因此所有操作都可以正常工作。但在第二种情况下(通过在发布者链中引入任何调度程序),某些操作是同步执行的(如删除)这会启动动画,但publisher的操作会在动画已在进行时异步进行,更改模型会破坏正在运行的动画,从而产生不可预知的结果

解决这一问题的可能方法是通过不同的块来分离启动操作和结果操作,并使发布服务器真正异步,但在后台处理并在主队列中检索结果

这是修改后的发布链。使用Xcode 12.4/iOS 14.4进行测试

注意:您还可以研究在一个动画块中重新包装所有内容的可能性,但在检索结果后已在
synk
中-这将需要更改逻辑,以便考虑

private func setupPipeline(){
过滤器可更换=
Publishers.CombineTest($searchTerm,$chosenItems)
.debounce(用于:。秒(0.5),调度程序:DispatchQueue.main)//debounce输入
.subscribe(on:DispatchQueue.global(qos:.background))//准备在后台处理
.print()
.map{(术语,已选择)->(已筛选:[DItem],已选择:Set)中的
如果term.isEmpty{//如果该项为空,则返回所有内容
返回(已筛选:self.items,已选择:已选择)
}else{//如果术语不为空,则仅返回包含搜索术语的项目
返回(已筛选:self.items.filter{$0.name.localizedStandardContains(term)},已选择:已选择)
}
}
.map{(已筛选,已选择)在中
(filtered:filtered.filter{!Selected.contains($0)},Selected:Selected)//在Selected items列表中不包括任何项目
}

.receive(on:DispatchQueue.main)/@Asperi的建议让我正确地考虑了调用多少
withAnimation{}
事件。在我最初的问题中,
filteredItems
chosenItems
将在
receive(on:)时在运行循环的不同迭代中更改使用了
去盎司
,这似乎是不可预测的布局行为的根本原因

通过将
debounce
时间更改为更长的值,这将防止问题的发生,因为一个动画将在另一个动画完成后完成,但这是一个有问题的解决方案,因为它依赖于动画时间(如果没有发送显式动画时间,则可能是神奇的数字)

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

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


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
}

类完成符:ObserveObject{
@已发布的变量项:[Item]=[]{
迪塞特{
setupPipeline()
}
}
@已发布的专用(集)变量filteredItems:[项]=[]
@已发布的私有(集合)变量chosenItems:set=[]
@已发布的var searchTerm=“”
私有变量chosenPassthrough:PassthroughSubject=.init()
private var filterCancellable:是否可以取消?
专用函数设置管道(){
过滤器可更换=
出版商:联合测试($searchTerm,chosenPassth)
private func setupPipeline() {
    filterCancellable =
        Publishers.CombineLatest($searchTerm,$chosenItems)
        .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)   // debounce input
        .subscribe(on: DispatchQueue.global(qos: .background))         // prepare for processing in background
        .print()
        .map { (term,chosen) -> (filtered: [DItem],chosen: Set<DItem>) 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
        }
        .receive(on: DispatchQueue.main) // << receive processed items on main queue
        .sink { [weak self] (filtered, chosen) in
            withAnimation {
                self?.filteredItems = filtered      // animating this as well
                }
        }
}

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
}