问题描述
我有一个组合函数,用于搜索项目列表并返回匹配项.它不仅跟踪向用户显示与搜索词匹配的哪些项目,而且跟踪哪些项目已被标记为已选择".由用户.
该函数效果很好,包括动画,直到我添加 .debounce(for: .seconds(0.2), scheduler: RunLoop.main)
或 .receive(on: RunLoop.main)
在 Combine 发布者链中.那时,View
中的结果呈现变得莫名其妙——项目标题开始显示为标题视图、项目重复等.
您可以在随附的 GIF 中看到结果.
GIF 版本使用 .receive(on: RunLoop.main)
.注意这里我什至不使用搜索词,尽管它也会导致有趣的结果.还可能值得注意的是,如果 withAnimation { }
被删除,一切正常with问题行.
我希望能够使用 debounce
,因为列表最终可能会非常大,而且我不想在每次击键时过滤整个列表.
在这些情况下,我怎样才能让表格视图正确呈现?
示例代码(有关痛点和代码解释,请参阅内联注释.它应该可以按编写的方式运行,但如果两个相关行中的任何一行都未注释):
导入 SwiftUI进口结合导入 UIKit类完成者:ObservableObject {@Published var items : [Item] = [] {已设置{设置管道()}}@Published varfilteredItems : [Item] = []@Published var selectedItems: Set= []@Published var searchTerm = "";private var filterCancellable : AnyCancellable?私有函数 setupPipeline() {过滤器可取消 =Publishers.CombineLatest($searchTerm,$chosenItems)//监听搜索词和选择项的变化.打印()//** 以下任一行,如果不注释会导致表格渲染混乱**//.receive(on: RunLoop.main)//<----- 这里 -----//.debounce(for: .seconds(0.2), scheduler: RunLoop.main)//<----- HERE --------------------.map { (term,chosen) ->(过滤:[Item],选择:Set)在if term.isEmpty {//如果词条为空,则返回所有内容返回(过滤:self.items,选择:选择)} else {//如果词条不为空,则只返回包含搜索词条的项目返回(过滤:self.items.filter { $0.name.localizedStandardContains(term)},选择:选择)}}.map {(过滤,选择)在(filtered:filtered.filter { !chosen.contains($0) }, selected: selected)//不包括选择项列表中的任何项}.sink { [weak self] (filtered, selected) inself?.filteredItems = 过滤}}功能切换项目选择(项目:项目){带动画{如果 selectedItems.contains(item) {selectedItems.remove(item)} 别的 {searchTerm = "";selectedItems.insert(项目)}}}}结构内容视图:查看{@StateObject var completer = Completer()var主体:一些视图{形式 {部分 {TextField("Term", 文本:$completer.searchTerm)}部分 {ForEach(completer.filteredItems) { item in按钮(动作:{completer.toggleItemChosen(item: item)}) {文本(项目名称)}.foregroundColor(completer.chosenItems.contains(item) ? .red : .primary)}}如果 completer.chosenItems.count != 0 {部分(标题:HStack {文本(选择的项目")垫片()按钮(动作:{completer.chosenItems = []}) {文本(清除")}}) {ForEach(Array(completer.chosenItems)) { item in按钮(动作:{completer.toggleItemChosen(item: item)}) {文本(项目名称)}}}}}.onAppear {completer.items = [克里斯"、格雷格"、罗斯"、达米安"、乔治"、达雷尔"、迈克尔"].map { 项目(名称:$0)}}}}结构项目:可识别,可散列{var id = UUID()变量名称:字符串}@Asperi 的建议让我走上了正确的轨道,思考将调用多少 withAnimation { }
事件.在我最初的问题中,当 receive(on:)
或 debouncefilteredItems
和 chosenItems
将在 RunLoop 的不同迭代中改变/code> 被使用,这似乎是不可预测的布局行为的根本原因.
通过将 debounce
时间更改为更长的值,这可以防止该问题,因为一个动画将在 另一个动画完成后完成,但这是一个有问题的解决方案因为它依赖于动画时间(如果没有发送明确的动画时间,则可能是幻数).
我设计了一个有点俗气的解决方案,它为 chosenItems
使用 PassThroughSubject
而不是直接分配给 @Published
属性.通过这样做,我可以将 @Published
值的所有分配移动到 sink
中,从而只发生 一个 动画块.
我对这个解决方案并不感到兴奋,因为它感觉像是一个不必要的黑客攻击,但它似乎确实解决了问题:
类完成者:ObservableObject {@Published var items : [Item] = [] {已设置{设置管道()}}@Published private(set) varfilteredItems : [Item] = []@Published private(set) var selectedItems: Set= []@Published var searchTerm = "";private var selectedPassthrough : PassthroughSubjectI have a Combine function that I use to search through a list of items and return matches. It keeps track of not only what items to show the user that match the search term, but also what items have been marked as "chosen" by the user.
The function works great, including animations, until I add either .debounce(for: .seconds(0.2), scheduler: RunLoop.main)
or .receive(on: RunLoop.main)
in the Combine publisher chain. At that point, the rendering of the results in the View
get inexplicably strange -- item titles start showing up as header views, items are repeated, etc.
You can see the result in the accompanying GIF.
The GIF version is using .receive(on: RunLoop.main)
. Note I don't even use the search term here, although it also leads to funny results. It also may be worth noting that everything works correctly with the problem lines if withAnimation { }
is removed.
I'd like to be able to use debounce
as the list may eventually be pretty large and I don't want to filter the whole list on every keystroke.
How can I get the table view to render correctly under these circumstances?
Example code (see inline comments for the pain points and explanation of the code. It should run well as written, but if either of the two relevant lines is uncommented) :
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
}
@Asperi's suggestion got me on the right track thinking about how many withAnimation { }
events would get called. In my original question, filteredItems
and chosenItems
would be changed in different iterations of the RunLoop when receive(on:)
or debounce
was used, which seemed to be the root cause of the unpredictable layout behavior.
By changing the debounce
time to a longer value, this would prevent the issue, because one animation would be done after the other was finished, but was a problematic solution because it relied on the animation times (and potentially magic numbers if explicit animation times weren't sent).
I've engineered a somewhat tacky solution that uses a PassThroughSubject
for chosenItems
instead of assigning to the @Published
property directly. By doing this, I can move all assignment of the @Published
values into the sink
, resulting in just one animation block happening.
I'm not thrilled with the solution, as it feels like an unnecessary hack, but it does seem to solve the issue:
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
}
这篇关于组合 + SwiftUI Form + RunLoop 导致表格视图呈现不可预测的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持!