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

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
}

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

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

Related

How can I apply individual transitions to children views during insertion and removal in SwiftUI?

I have a container view that contains multiple child views. These child views have different transitions that should be applied when the container view is inserted or removed.
Currently, when I add or remove this container view, the only transition that works is the one applied directly to the container view.
I have tried applying the transitions to each child view, but it doesn't work as expected. Here is a simplified version of my code:
struct Container: View, Identifiable {
let id = UUID()
var body: some View {
HStack {
Text("First")
.transition(.move(edge: .leading)) // this transition is ignored
Text("Second")
.transition(.move(edge: .trailing)) // this transition is ignored
}
.transition(.opacity) // this transition is applied
}
}
struct Example: View {
#State var views: [AnyView] = []
func pushView(_ view: some View) {
withAnimation(.easeInOut(duration: 1)) {
views.append(AnyView(view))
}
}
func popView() {
guard views.count > 0 else { return }
withAnimation(.easeInOut(duration: 1)) {
_ = views.removeLast()
}
}
var body: some View {
VStack(spacing: 30) {
Button("Add") {
pushView(Container()) // any type of view can be pushed
}
VStack {
ForEach(views.indices, id: \.self) { index in
views[index]
}
}
Button("Remove") {
popView()
}
}
}
}
And here's a GIF that shows the default incorrect behaviour:
If I remove the container's HStack and make the children tuple views, then the individual transitions will work, but I will essentially lose the container — which in this scenario was keeping the children aligned next to each other.
e.g
So this isn't a useful solution.
Note: I want to emphasise that the removal transitions are equally important to me
The .transition is applied to the View that appears (or disappears), and as you've found any .transition on a subview is ignored.
You can work around this by adding your Container without animation, and then animating in each of the Text.
struct Pair: Identifiable {
let id = UUID()
let first = "first"
let second = "second"
}
struct Container: View {
#State private var showFirst = false
#State private var showSecond = false
let pair: Pair
var body: some View {
HStack {
if showFirst {
Text(pair.first)
.transition(.move(edge: .leading))
}
if showSecond {
Text(pair.second)
.transition(.move(edge: .trailing))
}
}
.onAppear {
withAnimation {
showFirst = true
showSecond = true
}
}
}
}
struct ContentView: View {
#State var pairs: [Pair] = []
var animation: Animation = .easeInOut(duration: 1)
var body: some View {
VStack(spacing: 30) {
Button("Add") {
pairs.append(Pair())
}
VStack {
ForEach(pairs) { pair in
Container(pair: pair)
}
}
Button("Remove") {
if pairs.isEmpty { return }
withAnimation(animation) {
_ = pairs.removeLast()
}
}
}
}
}
Also note, your ForEach should be over an array of objects rather than Views (not that it makes a difference in this case).
Update
You can reverse the process by using a Binding to a Bool that contains the show state for each View. In this case I've created a struct PairState that holds a Set of all the views currently shown:
struct Container: View {
let pair: Pair
#Binding var show: Bool
var body: some View {
HStack {
if show {
Text(pair.first)
.transition(.move(edge: .leading))
Text(pair.second)
.transition(.move(edge: .trailing))
}
}
.onAppear {
withAnimation {
show = true
}
}
}
}
struct PairState {
var shownIds: Set<Pair.ID> = []
subscript(pairID: Pair.ID) -> Bool {
get {
shownIds.contains(pairID)
}
set {
shownIds.insert(pairID)
}
}
mutating func remove(_ pair: Pair) {
shownIds.remove(pair.id)
}
}
struct ContentView: View {
#State var pairs: [Pair] = []
#State var pairState = PairState()
var body: some View {
VStack(spacing: 30) {
Button("Add") {
pairs.append(Pair())
}
VStack {
ForEach(pairs) { pair in
Container(pair: pair, show: $pairState[pair.id])
}
}
Button("Remove") {
guard let pair = pairs.last else { return }
Task {
withAnimation {
pairState.remove(pair)
}
try? await Task.sleep(for: .seconds(0.5)) // 😢
_ = pairs.removeLast()
}
}
}
}
}
This has a delay in there to wait for the animation to complete before removing from the array. I'm not happy with that, but it works in this example.

Problem with state management and Core Data

I'm fairly new to Swift and Core Data. I’m having a problem resolving a state issue in a new project of mine.
I have a parent view (CategoryView)that includes a context menu item to allow editing of certain category properties (EditCategoryView). When the EditCategoryView sheet is presented and an edit to a category property is made, the CategoriesView updates correctly when the sheet is dismissed. Works fine.
There is a navigation link off of CategoriesView (ItemsView) that also includes a context menu to allow editing of certain item properties (EditItemView). Unlike the prior example, when the EditItemView sheet is presented and an edit is made to an item property, the ItemsView does not update when the sheet is dismissed. The old item property still displays. If I navigate back to CategoriesView and then return to ItemsView, the updated item property displays correctly.
I’m stumped and clearly don’t understand how state is managed in a CoreData environment. My code for the 2 views seems to be similar, yet they are behaving distinctly different. I wonder if the problem relates to the difference in the structures used in the 2 ForEach lines. That is, in CategoriesView I'm looping on the results of a Fetch and in EventsView I'm looping on the results of a computed value.
Any suggestions? thanks in advance for any guidance.
I created a simple example project that demonstrates the problem. To reproduce:
tap on Load Sample Data
choose a Category
tap and hold an Item to bring up context menu
choose Edit and change the name of the item
you’ll note when sheet dismisses the updated name is not reflected
return to Category list and then select the item again to see the updated name
https://github.com/jayelevy/CoreDataState
edit to include the code for the minimal example referenced in the repo
xcdatamodeld
2 Entities
Category
Attribute: name: String
Relationships: items, destination: Item (many-to-one)
Item
Attribute: name: String
Relationships: category, destination: Category (to one)
#main
struct CoreDataStateApp: App {
#StateObject var dataController: DataController
init() {
let dataController = DataController()
_dataController = StateObject(wrappedValue: dataController)
}
var body: some Scene {
WindowGroup {
CategoriesView()
.environment(\.managedObjectContext, dataController.container.viewContext)
.environmentObject(dataController)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification), perform: save)
}
}
func save(_ note: Notification) {
dataController.save()
}
}
struct CategoriesView: View {
#EnvironmentObject var dataController: DataController
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(sortDescriptors: [SortDescriptor(\.name)])
var categories: FetchedResults<Category>
var body: some View {
NavigationView {
VStack {
List {
ForEach(categories) { category in
NavigationLink {
ItemsView(category: category)
} label : {
Text(category.categoryName)
}
}
}
}
.navigationTitle("My Categories")
.toolbar {
ToolbarItem(placement: .automatic) {
Button {
dataController.deleteAll()
try? dataController.createSampleData()
} label: {
Text("Load Sample Data")
}
}
}
}
}
}
problem occurs with the following view. When an item is edited in EditItemView, the updated property (name) does not display when returning to ItemsView from the sheet.
If you return to CategoryView and then return to ItemsView, the correct property name is displayed.
struct ItemsView: View {
#ObservedObject var category: Category
#State private var isEditingItem = false
var body: some View {
VStack {
List {
ForEach(category.categoryItems) { item in
NavigationLink {
//
} label: {
Text(item.itemName)
}
.contextMenu {
Button {
isEditingItem.toggle()
} label: {
Label("Edit Item", systemImage: "pencil")
}
}
.sheet(isPresented: $isEditingItem) {
EditItemView(item: item)
}
}
}
}
.navigationTitle(category.categoryName)
}
}
struct EditItemView: View {
var item: Item
#EnvironmentObject var dataController: DataController
#Environment(\.managedObjectContext) var managedObjectContext
#Environment(\.dismiss) private var dismiss
#State private var itemName: String
init(item: Item) {
// _item = ObservedObject(initialValue: item)
self.item = item
_itemName = State(initialValue: item.itemName)
}
var body: some View {
NavigationView {
VStack {
Form {
Section {
TextField("Item Name", text: $itemName)
}
}
}
.navigationTitle("Edit Item")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
// add any needed cancel logic
Button("Cancel") {
dismiss()
}
}
ToolbarItem {
Button {
saveItem()
dismiss()
} label: {
Text("Update")
}
.disabled(itemName.isEmpty)
}
}
}
}
func saveItem() {
item.name = itemName
dataController.save()
}
}
extension Category {
var categoryName: String {
name ?? "New Category"
}
var categoryItems: [Item] {
items?.allObjects as? [Item] ?? []
}
extension Item {
var itemName: String {
name ?? "New Item"
}
}
extension Binding {
func onChange(_ handler: #escaping () -> Void) -> Binding<Value> {
Binding(
get: { self.wrappedValue },
set: { newValue in
self.wrappedValue = newValue
handler()
}
)
}
}
class DataController: ObservableObject {
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "Model")
if inMemory {
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores { _, error in
if let error = error {
fatalError("Fatal error loading store: \(error.localizedDescription)")
}
}
}
static var preview: DataController = {
let dataController = DataController(inMemory: true)
let viewContext = dataController.container.viewContext
do {
try dataController.createSampleData()
} catch {
fatalError("Fatal error creating preview: \(error.localizedDescription)")
}
return dataController
}()
func createSampleData() throws {
let viewContext = container.viewContext
for i in 1...4 {
let category = Category(context: viewContext)
category.name = "Category \(i)"
category.items = []
for j in 1...5 {
let item = Item(context: viewContext)
item.name = "Item \(j)"
item.category = category
}
}
try viewContext.save()
}
func save() {
if container.viewContext.hasChanges {
try? container.viewContext.save()
}
}
func delete(_ object: NSManagedObject) {
container.viewContext.delete(object)
}
func deleteAll() {
let fetchRequest1: NSFetchRequest<NSFetchRequestResult> = Item.fetchRequest()
let batchDeleteRequest1 = NSBatchDeleteRequest(fetchRequest: fetchRequest1)
_ = try? container.viewContext.execute(batchDeleteRequest1)
let fetchRequest2: NSFetchRequest<NSFetchRequestResult> = Category.fetchRequest()
let batchDeleteRequest2 = NSBatchDeleteRequest(fetchRequest: fetchRequest2)
_ = try? container.viewContext.execute(batchDeleteRequest2)
}
func count<T>(for fetchRequest: NSFetchRequest<T>) -> Int {
(try? container.viewContext.count(for: fetchRequest)) ?? 0
}
}
ItemsView needs its own #FetchRequest for CategoryItem with a predicate where category = %#.
Also, instead of passing your DataController object around just put your helper methods in an extension of NSManagedObjectContext. Then you can change DataController back to the struct it should be.
I imagine there are other opportunities to improve my code (obviously, still learning), per other posts. However, the resolution was quite simple.
Modified saveItem in EditItemView to include objectWillChange.send()
func saveItem() {
item.name = itemName
item.category = itemCategory
item.category?.objectWillChange.send()
dataController.save()
}

SwiftUI scroll to item after async load

I do async load of items from network .onAppear, after that my ViewModel force to redraw swiftUI view. After that i need scroll list to particular item. I do it .onRecieve, problem is that view redrawing and scrolling perform almost same time, but indeed scrolling goes first as i understand, so i need add ugly hack - delay, than it works fine.
Question is there are some another approach how to postpone scrolling after view refresh.
struct ItemsListView: View {
#Binding var selectedItem: Item?
#ObservedObject var viewModel: ViewModel
var body: some View {
ScrollViewReader { proxy in
List(viewModel.state.items) { item in
let selected = item.id == selectedItem?.id
self.createCell(item: item, selected: selected)
.onTapGesture {
selectedItem = item
}
}
.listStyle(SidebarListStyle())
.navigationBarTitleDisplayMode(.inline)
.onAppear {
self.viewModel.trigger(.getItems) // triggers async load of items
}
.onReceive(viewModel.$state) { state in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { // hack to give a time redraw ItemsListView
withAnimation {
proxy.scrollTo(selectedItem?.id, anchor: .center)
}
}
}
}
}
View Model code, it's real but a bit simplified for this topic
class ViewModel: ObservableObject {
struct State {
var items = [Item]()
}
enum Event {
case getItems
}
func trigger(_ event: Event) {
switch event {
case .getItems: self.getItems()
}
}
#Published var state = State()
init(dataFetcher: RemoteDataProvider) {
self.dataFetcher = dataFetcher
super.init()
}
private func getItems() {
dataFetcher.getItems()
.sink { result in
if case .failure(let err) = result {
print("Retrieving car items list failed:\(err)")
}
} receiveValue: {[weak self] items in
self?.state.items = items
}
.store(in: &subscriptions)
}
}

SwiftUI: Real device shows strange behavior with asynchronous flow, while Simulator runs perfectly

*** EDIT 23.20.20 ***
Due to the strange behavior discovered after my original post, I need to completely rephrase my question. I meanwhile re-wrote large parts of my code as well.
The issue:
I run an asynchronous HTTP GET search query, which returns me an Array searchResults, which I store in an ObservedObject FoodDatabaseResults.
struct FoodItemEditor: View {
//...
#ObservedObject var foodDatabaseResults = FoodDatabaseResults()
#State private var activeSheet: FoodItemEditorSheets.State?
//...
var body: some View {
NavigationView {
VStack {
Form {
Section {
HStack {
// Name
TextField(titleKey: "Name", text: $draftFoodItem.name)
// Search and Scan buttons
Button(action: {
if draftFoodItem.name.isEmpty {
self.errorMessage = NSLocalizedString("Search term must not be empty", comment: "")
self.showingAlert = true
} else {
performSearch()
}
}) {
Image(systemName: "magnifyingglass").imageScale(.large)
}.buttonStyle(BorderlessButtonStyle())
//...
}
//...
}
//...
}
}
//...
}
.sheet(item: $activeSheet) {
sheetContent($0)
}
}
private func performSearch() {
UserSettings.shared.foodDatabase.search(for: draftFoodItem.name) { result in
switch result {
case .success(let networkSearchResults):
guard let searchResults = networkSearchResults else {
return
}
DispatchQueue.main.async {
self.foodDatabaseResults.searchResults = searchResults
self.activeSheet = .search
}
case .failure(let error):
debugPrint(error)
}
}
}
#ViewBuilder
private func sheetContent(_ state: FoodItemEditorSheets.State) -> some View {
switch state {
case .search:
FoodSearch(foodDatabaseResults: foodDatabaseResults, draftFoodItem: self.draftFoodItem) // <-- I set a breakpoint here
//...
}
}
}
class FoodDatabaseResults: ObservableObject {
#Published var selectedEntry: FoodDatabaseEntry?
#Published var searchResults: [FoodDatabaseEntry]?
}
I get valid search results in my performSearch function. The DispatchQueue.main.async closure makes sure to perform the update of my #Published var searchResults in the main thread.
I then open a sheet, displaying these search results:
struct FoodSearch: View {
#ObservedObject var foodDatabaseResults: FoodDatabaseResults
#Environment(\.presentationMode) var presentation
//...
var body: some View {
NavigationView {
List {
if foodDatabaseResults.searchResults == nil {
Text("No search results (yet)")
} else {
ForEach(foodDatabaseResults.searchResults!) { searchResult in
FoodSearchResultPreview(product: searchResult, isSelected: self.selectedResult == searchResult)
}
}
}
.navigationBarTitle("Food Database Search")
.navigationBarItems(leading: Button(action: {
// Remove search results and close sheet
foodDatabaseResults.searchResults = nil
presentation.wrappedValue.dismiss()
}) {
Text("Cancel")
}, trailing: Button(action: {
if selectedResult == nil {
//...
} else {
//... Do something with the result
// Remove search results and close sheet
foodDatabaseResults.searchResults = nil
presentation.wrappedValue.dismiss()
}
}) {
Text("Select")
})
}
}
}
When I run this on the Simulator, everything works as it should, see https://wolke.rueth.info/index.php/s/KbqETcDtSe4278d
When I run it on a real device with the same iOS version (14.0.1), the FoodSearch view first correctly displays the search result, but is then immediately called a second time with empty (nil) search results. You need to look very closely at the screen cast here and you'll see it displaying the search results for a very short moment before they disappear: https://wolke.rueth.info/index.php/s/9n2DZ88qSB9RWo4
When setting a breakpoint in the marked line in my sheetContent function, the FoodSearch sheet is indeed called twice on the real device, while it's only called once in the Simulator.
I have no idea what is going on here. Hope someone can help. Thanks!
*** ORIGINAL POST ***
I run an HTTP request, which updates a #Published variable searchResults in a DispatchQueue.main.async closure:
class OpenFoodFacts: ObservableObject {
#Published var searchResults = [OpenFoodFactsProduct]()
// ...
func search(for term: String) {
let urlString = "https://\(countrycode)-\(languagecode).openfoodfacts.org/cgi/search.pl?action=process&search_terms=\(term)&sort_by=unique_scans_n&json=true"
let request = prepareRequest(urlString)
let session = URLSession.shared
session.dataTask(with: request, completionHandler: { (data: Data?, response: URLResponse?, error: Error?) in
guard error == nil else {
debugPrint(error!.localizedDescription)
return
}
if let data = data {
do {
let openFoodFactsSearchResult = try JSONDecoder().decode(OpenFoodFactsSearchResult.self, from: data)
guard let products = openFoodFactsSearchResult.products else {
throw FoodDatabaseError.noSearchResults
}
DispatchQueue.main.async {
self.searchResults = products
self.objectWillChange.send()
}
} catch {
debugPrint(error.localizedDescription)
}
}
}).resume()
}
struct OpenFoodFactsSearchResult: Decodable {
var products: [OpenFoodFactsProduct]?
enum CodingKeys: String, CodingKey {
case products
}
}
struct OpenFoodFactsProduct: Decodable, Hashable, Identifiable {
var id = UUID()
// ...
enum CodingKeys: String, CodingKey, CaseIterable {
// ...
}
// ...
}
I call the search function from my view:
struct FoodSearch: View {
#ObservedObject var foodDatabase: OpenFoodFacts
// ...
var body: some View {
NavigationView {
List {
ForEach(foodDatabase.searchResults) { searchResult in
FoodSearchResultPreview(product: searchResult, isSelected: self.selectedResult == searchResult)
}
}
// ...
}
.onAppear(perform: search)
}
private func search() {
foodDatabase.search(for: draftFoodItem.name)
}
}
My ForEach list will never update, although I have a valid searchResult set in my OpenFoodFacts observable object and also sent an objectWillChange signal. Any idea what I'm missing?
Funny enough: On the simulator it works as expected:
https://wolke.rueth.info/index.php/s/oy4Xf6C5cgrEZdK
On a real device not:
https://wolke.rueth.info/index.php/s/TQz8HnFyjLKtN74

How do I efficiently filter a long list in SwiftUI?

I've been writing my first SwiftUI application, which manages a book collection. It has a List of around 3,000 items, which loads and scrolls pretty efficiently. If use a toggle control to filter the list to show only the books I don't have the UI freezes for twenty to thirty seconds before updating, presumably because the UI thread is busy deciding whether to show each of the 3,000 cells or not.
Is there a good way to do handle updates to big lists like this in SwiftUI?
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
ForEach(userData.bookList) { book in
if !self.userData.showWantsOnly || !book.own {
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
}.navigationBarTitle(Text("Books"))
}
Have you tried passing a filtered array to the ForEach. Something like this:
ForEach(userData.bookList.filter { return !$0.own }) { book in
NavigationLink(destination: BookDetail(book: book)) { BookRow(book: book) }
}
Update
As it turns out, it is indeed an ugly, ugly bug:
Instead of filtering the array, I just remove the ForEach all together when the switch is flipped, and replace it by a simple Text("Nothing") view. The result is the same, it takes 30 secs to do so!
struct SwiftUIView: View {
#EnvironmentObject var userData: UserData
#State private var show = false
var body: some View {
NavigationView {
List {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
if self.userData.showWantsOnly {
Text("Nothing")
} else {
ForEach(userData.bookList) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
}.navigationBarTitle(Text("Books"))
}
}
Workaround
I did find a workaround that works fast, but it requires some code refactoring. The "magic" happens by encapsulation. The workaround forces SwiftUI to discard the List completely, instead of removing one row at a time. It does so by using two separate lists in two separate encapsualted views: Filtered and NotFiltered. Below is a full demo with 3000 rows.
import SwiftUI
class UserData: ObservableObject {
#Published var showWantsOnly = false
#Published var bookList: [Book] = []
init() {
for _ in 0..<3001 {
bookList.append(Book())
}
}
}
struct SwiftUIView: View {
#EnvironmentObject var userData: UserData
#State private var show = false
var body: some View {
NavigationView {
VStack {
Toggle(isOn: $userData.showWantsOnly) {
Text("Show wants")
}
if userData.showWantsOnly {
Filtered()
} else {
NotFiltered()
}
}
}.navigationBarTitle(Text("Books"))
}
}
struct Filtered: View {
#EnvironmentObject var userData: UserData
var body: some View {
List(userData.bookList.filter { $0.own }) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
struct NotFiltered: View {
#EnvironmentObject var userData: UserData
var body: some View {
List(userData.bookList) { book in
NavigationLink(destination: BookDetail(book: book)) {
BookRow(book: book)
}
}
}
}
struct Book: Identifiable {
let id = UUID()
let own = Bool.random()
}
struct BookRow: View {
let book: Book
var body: some View {
Text("\(String(book.own)) \(book.id)")
}
}
struct BookDetail: View {
let book: Book
var body: some View {
Text("Detail for \(book.id)")
}
}
Check this article https://www.hackingwithswift.com/articles/210/how-to-fix-slow-list-updates-in-swiftui
In short the solution proposed in this article is to add .id(UUID()) to the list:
List(items, id: \.self) {
Text("Item \($0)")
}
.id(UUID())
"Now, there is a downside to using id() like this: you won't get your update animated. Remember, we're effectively telling SwiftUI the old list has gone away and there's a new list now, which means it won't try to move rows around in an animated way."
I think we have to wait until SwiftUI List performance improves in subsequent beta releases. I’ve experienced the same lag when lists are filtered from a very large array (500+) down to very small ones. I created a simple test app to time the layout for a simple array with integer IDs and strings with Buttons to simply change which array is being rendered - same lag.
Instead of a complicated workaround, just empty the List array and then set the new filters array. It may be necessary to introduce a delay so that emptying the listArray won't be omitted by the followed write.
List(listArray){item in
...
}
self.listArray = []
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
self.listArray = newList
}
Looking for how to adapt Seitenwerk's response to my solution, I found a Binding extension that helped me a lot. Here is the code:
struct ContactsView: View {
#State var stext : String = ""
#State var users : [MockUser] = []
#State var filtered : [MockUser] = []
var body: some View {
Form{
SearchBar(text: $stext.didSet(execute: { (response) in
if response != "" {
self.filtered = []
self.filtered = self.users.filter{$0.name.lowercased().hasPrefix(response.lowercased()) || response == ""}
}
else {
self.filtered = self.users
}
}), placeholder: "Buscar Contactos")
List{
ForEach(filtered, id: \.id){ user in
NavigationLink(destination: LazyView( DetailView(user: user) )) {
ContactCell(user: user)
}
}
}
}
.onAppear {
self.users = LoadUserData()
self.filtered = self.users
}
}
}
This is the Binding extension:
extension Binding {
/// Execute block when value is changed.
///
/// Example:
///
/// Slider(value: $amount.didSet { print($0) }, in: 0...10)
func didSet(execute: #escaping (Value) ->Void) -> Binding {
return Binding(
get: {
return self.wrappedValue
},
set: {
execute($0)
self.wrappedValue = $0
}
)
}
}
The LazyView is optional, but I took the trouble to show it, as it helps a lot in the performance of the list, and prevents swiftUI from creating the NavigationLink target content of the whole list.
struct LazyView<Content: View>: View {
let build: () -> Content
init(_ build: #autoclosure #escaping () -> Content) {
self.build = build
}
var body: Content {
build()
}
}
This code will work correctly provided that you initialize your class in the 'SceneDelegate' file as follows:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var userData = UserData()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView:
contentView
.environmentObject(userData)
)
self.window = window
window.makeKeyAndVisible()
}
}