I 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
}
