Problem with state management and Core Data - swiftui

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

Related

Default navigation link in Swiftui

I'm working on an app the uses traditional sidebar navigation with a detail view. I've synthesized the app to illustrate two issues.
when the app starts, the detail view is empty. How can I programmatically select an entry in the sidebar to show in the detail view?
The sidebar allows swipe to delete. If the selected row (the one showing in the detail view) is deleted, it still shows in the detail view. How can update the detail view with, for example, an empty view?
Here's the source code for the app illustrating the issues:
import SwiftUI
class Model: ObservableObject {
var items = [Item("")]
static var loadData: Model {
let model = Model()
model.items = [Item("Books"), Item("Videos"), Item("Pics"), Item("Cars")]
return model
}
}
class Item: Identifiable, Hashable {
static func == (lhs: Item, rhs: Item) -> Bool {
lhs.name == rhs.name
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
let id = UUID()
#Published var name: String
init(_ name: String) {
self.name = name
}
}
#main
struct IBTSimulatorApp: App {
#StateObject var model = Model.loadData
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
}
}
}
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
NavigationView {
List {
ForEach($model.items, id: \.self) { $item in
NavigationLink(item.name, destination: Text(item.name))
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
}
}
private func addItem() {
withAnimation {
model.items.append(Item("New item (\(model.items.count))"))
model.objectWillChange.send()
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
model.items.remove(atOffsets: offsets)
model.objectWillChange.send()
}
}
}
For 1. you can use the NavigationLink version with tag and selection, and save the active selection in a persisted AppStoragevar.
For 2. I expected you can set the selection to nil, but that does not work for some reason. But you can set it to the first item in the sidebar list.
As a general note you should make Item a struct instead of a class. Only the published Model should be a class.
class Model: ObservableObject {
var items: [Item] = []
static var loadData: Model {
let model = Model()
model.items = [Item("Books"), Item("Videos"), Item("Pics"), Item("Cars")]
return model
}
}
struct Item: Identifiable { // Change from class to struct!
let id = UUID()
var name: String
init(_ name: String) {
self.name = name
}
}
struct ContentView: View {
#StateObject var model = Model.loadData
#AppStorage("selectemItem") var selected: String? // bind to persisted var here
var body: some View {
NavigationView {
List {
ForEach(model.items) { item in //no .id needed as Item is identifiable
NavigationLink(tag: item.id.uuidString, selection: $selected) { // use link with selection here
Text(item.name)
} label: {
Text(item.name)
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Nothing selected")
}
}
private func addItem() {
withAnimation {
model.objectWillChange.send()
model.items.append(Item("New item (\(model.items.count))"))
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
// model.objectWillChange.send() // not necessary if Item is struct
self.selected = nil // for some reaseon this does not work
self.selected = model.items.first?.id.uuidString // selects first item
model.items.remove(atOffsets: offsets)
}
}
}

Why Is SwiftUI View Populated With Injected Object Only After Second Presentation of the View

I have a project which uses Core Data to store measurement values. The user can add new measurements to be persisted, and the user can edit persisted measurements.
The issue that I am experiencing is seen when attempting to edit a persisted measurement. After selecting a persisted measurement, the user is presented with the view to edit and save the measurement. The selected measurement is passed from the list to the presented view, where the value populates a TextField. Unfortunately, the value does not populate the TextField when the view is presented the first time within the app. Only after the second presentation does the measurement value populate the TextField.
The user can present the view to add a new measurement to be persisted, cancel and dismiss it, select an existing measurement, and that measurement's value will be displayed in the presented TextField. It seems that the initial presentation of the view used to add/edit a measurement does not contain the selected measurement on the first presentation. Only after the first presentation and dismissal will the value populate the TextField.
Below, you can see a 22sec GIF, which displays the current behavior.
In the GIF, you can see that a persisted measurement is selected, and the presented view's TextField is not populated with the measurement's value. Only on the second presentation is it populated. The last half of the GIF shows the process for persisting a new measurement and that the TextField is populated with that measurement's value on a subsequent presentation.
If you wish to reproduce the described behavior, then you can find the project's repository here, using the feature/edit-measurement branch, which the URL points to.
Steps to Reproduce
Launch application
Select any parameter from the list
Tap the trailing navigation button
Enter a value into the TextField
Tap the Save button
Return to the root view (list of parameters)
Select the parameter of which you saved the measurement value in step 5
Select the newly-persisted measurement in the list
Notice the unpopulated TextField
Tap the cancel button or dismiss the view by dragging downward
Select the same measurement that was selected in step 7
Notice the populated TextField
Below is the implementation of the view that displays persisted measurements:
import SwiftUI
struct ParameterMeasurementsLogView: View {
// MARK: Properties
let parameter: Parameter
#Environment(\.managedObjectContext) var managedObjectContext
#StateObject private var measurementStore = MeasurementStore()
#State private var displayMeasurementEntryView = false
#State private var selectedMeasurementIndex: Int?
private var measurementsRequest: FetchRequest<ParameterMeasurement>
private var measurements: FetchedResults<ParameterMeasurement> { measurementsRequest.wrappedValue }
private var measurementValues: [Double] { (measurements.map { $0.value }) }
private var measurementDeltas: [Double?] { measurementValues.deltasBetweenElements() }
private var measurementFormatter: MeasurementFormatter {
let formatter = MeasurementFormatter()
let numberFormatter = NumberFormatter()
numberFormatter.alwaysShowsDecimalSeparator = false
numberFormatter.maximumFractionDigits = 2
numberFormatter.numberStyle = .decimal
numberFormatter.usesGroupingSeparator = true
formatter.numberFormatter = numberFormatter
formatter.unitOptions = .providedUnit
formatter.unitStyle = .medium
return formatter
}
var body: some View {
List {
ForEach(measurements.indices, id: \.self) { index in
Button(action: {
selectedMeasurementIndex = index
displayMeasurementEntryView = true
}, label: {
HStack(content: {
VStack(alignment: .leading) {
Text(formattedMeasurement(at: index))
if index < measurements.count - 1 {
HStack(content: {
Image(systemName: deltaIconName(at: index))
Text(deltaString(for: index))
})
}
if let date = measurements[index].date {
FormattedDateTimeView(date: date)
}
}
})
})
}
.onDelete(perform: deleteMeasurements(at:))
}
.navigationTitle(parameter.name)
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing: Button(action: {
displayMeasurementEntryView = true
}, label: {
Image(systemName: Icon.plusCircleFill)
}))
.sheet(isPresented: $displayMeasurementEntryView, onDismiss: {
selectedMeasurementIndex = nil
}) {
ParameterMeasurementEntryView(parameter: parameter, entryMode: entryMode())
.environment(\.managedObjectContext, managedObjectContext)
}
}
// MARK: Initialization
init(parameter: Parameter) {
self.parameter = parameter
let entity = ParameterMeasurement.entity()
let sortDescriptors = [NSSortDescriptor(key: #keyPath(ParameterMeasurement.date), ascending: false)]
let predicateFormat = "%K =[c] %#"
let predicateArguments = [#keyPath(ParameterMeasurement.parameterName), parameter.name]
let predicate = NSPredicate(format: predicateFormat, argumentArray: predicateArguments)
measurementsRequest = FetchRequest(entity: entity, sortDescriptors: sortDescriptors, predicate: predicate, animation: .none)
}
// MARK: Deletion
private func deleteMeasurements(at offsets: IndexSet) {
offsets.forEach { managedObjectContext.delete(measurements[$0]) }
PersistenceStack.saveContext()
}
// MARK: Helpers
private func formattedMeasurement(at index: Int) -> String {
let value = measurements[index].value
switch parameter.measurementUnit {
case .unitDispersion(units: _, defaultUnit: let unit):
let measurement = Measurement<Unit>(value: value, unit: unit)
return measurementFormatter.string(from: measurement)
}
}
private func deltaIconName(at index: Int) -> String {
guard let delta = measurementDeltas[index] else { fatalError("Expected delta") }
if delta == 0 { return Icon.arrowUpArrowDown }
return delta > 0 ? Icon.arrowUp : Icon.arrowDown
}
private func deltaString(for index: Int) -> String {
guard let delta = measurementDeltas[index] else { return "" }
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 0
let absolute = abs(delta)
guard let formatted = formatter.string(from: absolute as NSNumber) else { fatalError("Expected formatted delta") }
return formatted
}
private func deltaBetweenMeasurement(at firstIndex: Int, and secondIndex: Int) -> Double {
measurementValues[firstIndex] - measurementValues[secondIndex]
}
private func entryMode() -> MeasurementEntryMode {
if let index = selectedMeasurementIndex {
return .edit(measurement: measurements[index])
}
return .add
}
}
Below, you can see the implementation of the view used to add/edit a measurement and persist it.
import SwiftUI
struct ParameterMeasurementEntryView: View {
// MARK: Properties
let parameter: Parameter
let entryMode: MeasurementEntryMode
#Environment(\.presentationMode) var presentationMode
#Environment(\.managedObjectContext) var managedObjectContext
#State private var measurementValueString = ""
private var measurementValue: Double? { Double(measurementValueString) }
private var cancelButton: some View {
Button(action: {
dismiss()
}, label: {
Text("Cancel")
})
}
private var saveButton: some View {
Button(action: {
saveNewMeasurement()
dismiss()
}, label: {
Text("Save")
})
.disabled(disableSaveButton())
}
var body: some View {
NavigationView(content: {
Form(content: {
Section(header: Text("Measurement")) {
HStack {
TextField("Value", text: $measurementValueString)
Text(defaultUnitSymbol())
}
}
})
.navigationTitle(parameter.name)
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: cancelButton, trailing: saveButton)
.onAppear(perform: setMeasurmentTextIfEditingMeasurement)
})
}
// MARK: Initialization
init(parameter: Parameter, entryMode: MeasurementEntryMode) {
self.parameter = parameter
self.entryMode = entryMode
}
// MARK: Helpers
private func dismiss() {
presentationMode.wrappedValue.dismiss()
}
private func disableSaveButton() -> Bool {
let measurementIsInvalid = measurementValue == nil
if case let .edit(measurement) = entryMode {
let enteredValueEqualsCurrentValue = Double(measurementValueString) == measurement.value
return measurementIsInvalid || enteredValueEqualsCurrentValue
}
return measurementIsInvalid
}
private func saveNewMeasurement() {
guard let value = measurementValue else { return assertionFailure("Expected measurement value") }
let measurement = ParameterMeasurement(entity: ParameterMeasurement.entity(), insertInto: managedObjectContext)
measurement.value = value
measurement.date = Date()
measurement.parameterName = parameter.name
PersistenceStack.saveContext()
}
private func defaultUnitSymbol() -> String {
switch parameter.measurementUnit {
case .unitDispersion(_, defaultUnit: let defaultUnit): return defaultUnit.symbol
}
}
private func setMeasurmentTextIfEditingMeasurement() {
if case let .edit(measurment) = entryMode { measurementValueString = String(measurment.value) }
}
}
MeasurementEntryMode is a simple enum that allows the list to tell the add/entry view if it's adding a new measurement or editing an existing one.
import Foundation
enum MeasurementEntryMode {
// MARK: Cases
case add, edit(measurement: ParameterMeasurement)
}
What is causing the persisted measurement's value to not be displayed in the TextField on the first presentation of the add/edit view but be displayed on the second presentation?
Even the following trivial example yields the same results:
struct PrimaryView: View {
#State private var selectedIndex: Int?
#State private var showDetail = false
var body: some View {
NavigationView {
List {
ForEach(Array(0...50).indices, id: \.self) { index in
Button(action: {
selectedIndex = index
showDetail = true
}, label: {
Text("\(index)")
})
}
}
.sheet(isPresented: $showDetail, onDismiss: {
selectedIndex = nil
}) {
Text(String(describing: selectedIndex))
}
}
}
}
According to Justin Stanley's response to my question on Twitter, simply moving the code to set whether the detail view is shown from the Button's action to an View.onChange(of:perform:) modifier fixes the issue.

How to manipulate the ion-list item using an ActionSheet in SwiftUI?

I am trying to use an ActionSheet to manipulate items of a List. How can I call a function (in this example deleteItem) that is part of the data model, using an ActionSheet and manipulte the selected item, similar to what .onDelete does?
My view presents items from a model using the following code:
struct ItemManager: View {
#ObservedObject var model: ItemModel
var body: some View {
List {
ForEach(model.items) { item in
ItemCell(item: item)
}
.onDelete { self.model.deleteItem(at: $0) }
}
}
}
struct ItemCell: View {
var item: Item
#State private var isActionSheetVisible = false
private var actionSheet: ActionSheet {
let button1 = ActionSheet.Button.default(Text("Delete")){
self.isActionSheetVisible = false
}
let button2 = ActionSheet.Button.cancel(){
self.isActionSheetVisible = false
}
let buttons = [button1, button2]
return ActionSheet(title: Text("Actions"), buttons: buttons)
}
var body: some View {
VStack(alignment: .leading) {
Button(action: {
self.isActionSheetVisible = true
}) {
Text(item.title).font(.headline)
}.actionSheet(isPresented: self.$isActionSheetVisible) {
self.actionSheet
}
}
}
}
My model has some simple properties and a function that deletes items from the collection:
struct Item: Identifiable, Equatable {
let title: String
var id: String {
title
}
}
class ItemModel: ObservableObject {
#Published var items: [Item] = [Item(title: "temp.1"), Item(title: "temp.2")]
public func deleteItem(at indices: IndexSet) {
indices.forEach { items.remove(at: $0) }
}
}
extension Item {
static let previewItem = Item(title: "temp.3")
}
Update: Added Equatable in the Item declaration to comform.
You could try passing the ItemModel to the ForEach() like so:
ForEach(model.items) { item in
ItemCell(item: item, model: self.model)
}
Then in your ItemCell you can:
struct ItemCell: View {
var item: Item
var model: ItemModel // Add the model variable
#State private var isActionSheetVisible = false
private var actionSheet: ActionSheet {
let button1 = ActionSheet.Button.default(Text("Delete")) {
// Get the index
if let index = self.model.items.firstIndex(of: self.item) {
// Delete the item based on the index
self.model.items.remove(at: index)
// Dismiss the ActionSheet
self.isActionSheetVisible = false
} else {
print("Could not find item!")
print(self.item)
}
}
}
}

SwiftUI: Picker content not refreshed when adding Element

I have a Picker Element in a VStack, but when its content changes by adding a new Element, the Picker does not refresh.
After hiding and showing the Picker, the new Element is visible.
Does anybody have any idea how to refresh the content of a Picker, without needing to hide / show it?
You can reproduce this by creating a new SwiftUI project and copying the following code instead of the "ContentView" struct.
class ContentModel {
#Published var pickerData: [String] = ["1"]
func addPickerData() {
pickerData.append("\(pickerData.count + 1)")
}
}
struct ContentView: View {
let contentModel = ContentModel()
#State private var showPicker = false
#State private var selectedPickerValue = ""
var body: some View {
VStack(spacing: 8) {
Text("Adding a new Element to the Picker does not refresh its content :-(")
Button(action: {
self.contentModel.addPickerData()
}) {
Text("Add Picker Data")
}
Button(action: {
self.showPicker.toggle()
}) {
Text("Show / Hide 2nd Picker")
}
Picker("Select",selection: $selectedPickerValue) {
ForEach(contentModel.pickerData, id: \.self) { data in
Text(data)
}
}
if (showPicker) {
Picker("Select",selection: $selectedPickerValue) {
ForEach(contentModel.pickerData, id: \.self) { data in
Text(data)
}
}
}
Text("Selected Value: \(selectedPickerValue)")
}
}
}
Thanks in advance for any help!
Here is the trick of reactive and always use two copies of same thing when you need to refresh something.
class ContentModel{
#Published var pickerData: [String] = ["1"]
func addPickerData() {
pickerData.append("\(pickerData.count + 1)")
}
}
struct ContentSSView: View {
let contentModel = ContentModel()
#State private var showPicker = false
#State private var selectedPickerValue = ""
var body: some View {
VStack(spacing: 8) {
Text("Adding a new Element to the Picker does not refresh its content :-(")
Button(action: {
self.contentModel.addPickerData()
self.showPicker.toggle()
}) {
Text("Add Picker Data")
}
Button(action: {
self.showPicker.toggle()
}) {
Text("Show / Hide 2nd Picker")
}
if (showPicker) {
Picker("Select",selection: $selectedPickerValue) {
ForEach(contentModel.pickerData, id: \.self) { data in
Text(data)
}
}
}else{
Picker("Select",selection: $selectedPickerValue) {
ForEach(contentModel.pickerData, id: \.self) { data in
Text(data)
}
}
}
Text("Selected Value: \(selectedPickerValue)")
}
}
}
I have a GitHub repo with this issue. I don't think having two Pickers is a viable solution.
Picker Update Bug GitHub Repo

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