Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Combine + SwiftUI Form + RunLoop causes table view to render unpredictably

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
}

like image 843
jnpdx Avatar asked Oct 27 '22 13:10

jnpdx


2 Answers

The problem in handling async processing... In your default case all operations are performed synchronously within one(!) animation block, so all works fine. But in second scenario (by introducing any scheduler in publishers chain) some operations are performed synchronously (like removing) that initiates animation, but operation from publisher comes asynchronously at the moment when animation is already in progress, and changing model breaks that running animation giving unpredictable result.

The possible approach to solve this is to separate initiating and resulting operations by different blocks and make publishers chan really async but processing in background and retrieving results in main queue.

Here is modified publishers chain. Tested with Xcode 12.4 / iOS 14.4

Note: also you can investigate possibility of wrapping all again in one animation block, but already in synk after retrieving results - this will require changing logic so it just for consideration

enter image description here

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
                }
        }
}
like image 91
Asperi Avatar answered Jan 02 '23 19:01

Asperi


@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
}
like image 21
jnpdx Avatar answered Jan 02 '23 20:01

jnpdx