Unexpected (automatic) navigation with SwiftUI on watchOS - swiftui

I have a simple watchOS 6.2.8 SwiftUI application in which I present a list of messages to the user.
These messages are modelled as classes and have a title, body and category name. I also have a Category object which is a view on these messages that only shows a specific category name.
I specifically mention watchOS 6.2.8 because it seems SwiftUI behaves a bit different there than on other platforms.
class Message: Identifiable {
let identifier: String
let date: Date
let title: String
let body: String
let category: String
var id: String {
identifier
}
init(identifier: String, date: Date, title: String, body: String, category: String) {
self.identifier = identifier
self.date = date
self.title = title
self.body = body
self.category = category
}
}
class Category: ObservableObject, Identifiable {
let name: String
#Published var messages: [Message] = []
var id: String {
name
}
init(name: String, messages: [Message] = []) {
self.name = name
self.messages = messages
}
}
Category itself is an #ObservableObject and publishes messages, so that when a category gets updated (like in the background), the view that is displaying the category message list will also update. (This works great)
To store these messages I have a simple MessageStore, which is an #ObservableObject that looks like this:
class MessageStore: ObservableObject {
#Published var messages: [Message] = []
#Published var categories: [Category] = []
static let sharedInstance = MessageStore()
func insert(message: Message) throws { ... mutage messages and categories ... }
func delete(message: Message) throws { ... mutage messages and categories ... }
}
(For simplicity I use a singleton, because there are problems with environment objects not being passed properly on watchOS)
The story keeps messages and categories in sync. When a new Message is added that has a category name set, it will also create or update a Category object in the categories list.
In my main view I present two things:
An All Messages NavigationLink that goes to a view to display all messages
For each Category I display a NavigationLink that goes to a view to display messages just in that specific category.
This all works, amazingly. But there is one really odd thing happening that I do not understand. (First SwiftUI project)
When I go into the All Messages list and delete all messages containing to a specific category, something unexpected happen when I navigate back to the main view.
First I observe that the category is properly removed from the list.
But then, the main view automatically quickly navigates to the All Messages list and then back.
The last part is driving me .. crazy .. I don't understand why this is happening. From a data perspective everyting looks good - the messages have been deleted and the category too. The final UI state, after the automagical navigation, also looks good - the message count for All Messages is correct and the category with now zero messages is not shown in the list anymore.
Here is the code for the main ContentView and also for the AllMessagesView. If helpful I can post the complete code here of course.
struct AllMessagesView: View {
#ObservedObject var messageStore = MessageStore.sharedInstance
#ViewBuilder
var body: some View {
if messageStore.messages.count == 0 {
Text("No messages").multilineTextAlignment(.center)
.navigationBarTitle("All Messages")
} else {
List {
ForEach(messageStore.messages) { message in
MessageCellView(message: message)
}.onDelete(perform: deleteMessages)
}
.navigationBarTitle("All Messages")
}
}
func deleteMessages(at offsets: IndexSet) {
for index in offsets {
do {
try messageStore.delete(message: messageStore.messages[index])
} catch {
NSLog("Failed to delete message: \(error.localizedDescription)")
}
}
}
}
//
struct CategoryMessagesView: View {
#ObservedObject var messageStore = MessageStore.sharedInstance
#ObservedObject var category: Category
var body: some View {
Group {
if category.messages.count == 0 {
Text("No messages in category “\(category.name)”").multilineTextAlignment(.center)
} else {
List {
ForEach(category.messages) { message in
MessageCellView(message: message)
}.onDelete(perform: deleteMessages)
}
}
}.navigationBarTitle(category.name)
}
func deleteMessages(at offsets: IndexSet) {
for index in offsets {
do {
try messageStore.delete(message: category.messages[index])
} catch {
NSLog("Cannot delete message: \(error.localizedDescription)")
}
}
}
}
struct ContentView: View {
#ObservedObject var messageStore = MessageStore.sharedInstance
var body: some View {
List {
Section {
NavigationLink(destination: AllMessagesView()) {
HStack {
Image(systemName: "tray.2")
Text("All Messages")
Spacer()
Text("\(messageStore.messages.count)")
.font(messageCountFont())
.bold()
.layoutPriority(1)
.foregroundColor(.green)
}
}
}
Section {
Group {
if messageStore.categories.count > 0 {
Section {
ForEach(messageStore.categories) { category in
NavigationLink(destination: CategoryMessagesView(category: category)) {
HStack {
Image(systemName: "tray") // .foregroundColor(.green)
Text("\(category.name)").lineLimit(1).truncationMode(.tail)
Spacer()
Text("\(category.messages.count)")
.font(self.messageCountFont())
.bold()
.layoutPriority(1)
.foregroundColor(.green)
}
}
}
}
} else {
EmptyView()
}
}
}
}
}
// TODO This is pretty inefficient
func messageCountFont() -> Font {
let font = UIFont.preferredFont(forTextStyle: .caption1)
return Font(font.withSize(font.pointSize * 0.75))
}
}
Apologies, I know this is a lot of code, but I feel I need to give enough context and visibility to show what is going on here.
Full project at https://github.com/st3fan/LearningSwiftUI/tree/master/MasterDetail - I don't think more code is relevant, but if it is, let me know and I'll move it into the question here.

The problem is in updated ForEach which result in recreating List and thus breaks link. This looks like SwiftUI defect, so worth submitting feedback to Apple.
The tested workaround is to move All Messages navigation link out of list (looks a bit different but might be appropriate). Tested with Xcode 12 / watchOS 7.0
struct ContentView: View {
#ObservedObject var messageStore = MessageStore.sharedInstance
var body: some View {
VStack {
NavigationLink(destination: AllMessagesView()) {
HStack {
Image(systemName: "tray.2")
Text("All Messages")
Spacer()
Text("\(messageStore.messages.count)")
.font(messageCountFont())
.bold()
.layoutPriority(1)
.foregroundColor(.green)
}
}
List {
ForEach(messageStore.categories) { category in
NavigationLink(destination: CategoryMessagesView(category: category)) {
HStack {
Image(systemName: "tray") // .foregroundColor(.green)
Text("\(category.name)").lineLimit(1).truncationMode(.tail)
Spacer()
Text("\(category.messages.count)")
.font(self.messageCountFont())
.bold()
.layoutPriority(1)
.foregroundColor(.green)
}
}
}
}
}
}
// ... other code

Related

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 picker didn't update

I tried to build a form that contained multiple picker. The first picker will show the existing data. The second picker will get the data from API according to the selection from first picker. However, when I open the form, the first picker work but the second picker display nothing. Even I select other selection from first picker, I can get the data but the second picker didn't update as my expectation. So the main question is how to make the second picker get data since there is a default selection in first picker. And what is the problem that cause the second picker no update after I make a selection in first picker
struct addBooking : View {
#State var shops = ShopJSON.shared.shops //existing data
#State var selectedShopIndex = 0
#State var selectedOutletIndex = 0
#ObservedObject var bookingOutletJSON = BookingOutletJSON()
var body: some View {
NavigationView{
Form {
Picker(selection: $selectedShopIndex, label: Text("Select Shop")) {
ForEach(0 ..< shops.count) {
Text(self.shops[$0].name!)
}
}
.onChange(of: selectedShopIndex, perform: { (value) in
print("Shop changed")
bookingOutletJSON.getOutlet(selectedShopId: shops[value].shopID!)
})
Picker(selection: $selectedOutletIndex, label: Text("Select Outlet")){
ForEach(0 ..< bookingOutletJSON.outlets.count) {
Text(self.bookingOutletJSON.outlets[$0].location!)
}
}
if bookingOutletJSON.outlets.count != 0 {
Text("\(bookingOutletJSON.outlets[0].location!)")
}
class BookingOutletJSON : ObservableObject{
#Published var outlets = [BookingOutlets]()
init() {
}
func getOutlet(selectedShopId : Int){
//API get data
let task = URLSession.shared.dataTask(with: request) { data, response, error in
guard let data = data else {
print(String(describing: error))
return
}
if let decodedOutlet = try? JSONDecoder().decode(OutletModal.self, from: data) {
DispatchQueue.main.async {
self.outlets = decodedOutlet.outlets!
print(self.outlets)
print("Outlet updated")
}
} else {
print("Invalid response from server")
}
}
task.resume()
}
}
Try with another ForEach constructor
Picker(selection: $selectedOutletIndex, label: Text("Select Outlet")){
ForEach(bookingOutletJSON.outlets.indices, id: \.self) {
Text(self.bookingOutletJSON.outlets[$0].location!)
}
}

SwiftUI: UI not updated with object transiting through several views

I have an issue with a OSX app developed with SwiftUI.
Here is my model.
class Chapter: Identifiable, Hashable, ObservableObject {
var content: [ChapterContent] = []
var id: Int
// Other Equatable/Hashable related code...
}
// A chapter content contains either a message or an event,
// both can't be nil at the same time
struct ChapterContent: Identifiable, Hashable {
var event: Event?
var message: Message?
var id: Int {
return self.message?.id ?? self.event?.id ?? .min
}
init(_ event: Event) {
self.event = event
self.message = nil
}
init(_ message: Message) {
self.event = nil
self.message = message
}
var representingView: AnyView {
// A view that represents the event or the message
}
// Other Equatable/Hashable related code...
}
The app starts with a ContentView() containing an array of chapters, represented in a list.
The view also contains a button to add a new chapter (with a hardcoded id so far just to test this).
struct ContentView: View {
var chapters: [Chapter]
var body: some View {
NavigationView {
VStack {
List {
ForEach(chapters, id: \.self) { chapter in
Text("Chapter C\(chapter.id)")
}
 }
Spacer()
NavigationLink(
destination: ChapterView(chapter: Chapter(id: 1)),
label: {
Text("ADD CHAPTER")
})
}
}
}
}
In this ChapterView, I display all the content of the chapter and also two buttons to add either a new event or a new message into this chapter. To simplify it I'll just show the ADD EVENT button.
struct ChapterView: View {
#State var chapter: Chapter
var body: some View {
NavigationView {
VStack(spacing: 0) {
VStack(spacing: 0) {
ForEach(chapter.content, id: \.self) { content in
content.representingView
}
.padding(0)
}
HStack(spacing: 0) {
NavigationLink(
destination: EventFormView(with: $chapter),
label: {
Text("ADD EVENT")
})
}
}
}
}
}
And in the EventFormView:
struct EventFormView: View {
#Binding var chapter: Chapter
init(with chapter: Binding<Chapter>) {
self._chapter = chapter
}
}
Somewhere in EventFormView I add the newly created event with:
chapter.content.append(newEventChapterContent)
After checking while debugging, I ensured that the value is actually added into the content array of the chapter. However, this doesn't trigger any UI update in the list (which is contained in ChapterView).
I guess it is related to the way I use the State/Binding protocols. I should even maybe use ObservedObject but I don't know how to do so and correctly transmit data from view to view.
Thank you for your help
At first make content published so it can be observed by views
class Chapter: Identifiable, Hashable, ObservableObject {
#Published var content: [ChapterContent] = []
now as it is observable, wrap it in observed in views
struct ChapterView: View {
#ObservedObject var chapter: Chapter
struct EventFormView: View {
#ObservedObject var chapter: Chapter
// previous init not needed
and create it as usual, because observable object is a reference, ie
EventFormView(chapter: chapter)

SwiftUI Picker onChange or equivalent?

I want to change another unrelated #State variable when a Picker gets changed, but there is no onChanged and it's not possible to put a didSet on the pickers #State. Is there another way to solve this?
Deployment target of iOS 14 or newer
Apple has provided a built in onChange extension to View, which can be used like this:
struct MyPicker: View {
#State private var favoriteColor = 0
var body: some View {
Picker(selection: $favoriteColor, label: Text("Color")) {
Text("Red").tag(0)
Text("Green").tag(1)
}
.onChange(of: favoriteColor) { tag in print("Color tag: \(tag)") }
}
}
Deployment target of iOS 13 or older
struct MyPicker: View {
#State private var favoriteColor = 0
var body: some View {
Picker(selection: $favoriteColor.onChange(colorChange), label: Text("Color")) {
Text("Red").tag(0)
Text("Green").tag(1)
}
}
func colorChange(_ tag: Int) {
print("Color tag: \(tag)")
}
}
Using this helper
extension Binding {
func onChange(_ handler: #escaping (Value) -> Void) -> Binding<Value> {
return Binding(
get: { self.wrappedValue },
set: { selection in
self.wrappedValue = selection
handler(selection)
})
}
}
First of all, full credit to ccwasden for the best answer. I had to modify it slightly to make it work for me, so I'm answering this question hoping someone else will find it useful as well.
Here's what I ended up with (tested on iOS 14 GM with Xcode 12 GM)
struct SwiftUIView: View {
#State private var selection = 0
var body: some View {
Picker(selection: $selection, label: Text("Some Label")) {
ForEach(0 ..< 5) {
Text("Number \($0)") }
}.onChange(of: selection) { _ in
print(selection)
}
}
}
The inclusion of the "_ in" was what I needed. Without it, I got the error "Cannot convert value of type 'Int' to expected argument type '()'"
I think this is simpler solution:
#State private var pickerIndex = 0
var yourData = ["Item 1", "Item 2", "Item 3"]
// USE this if needed to notify parent
#Binding var notifyParentOnChangeIndex: Int
var body: some View {
let pi = Binding<Int>(get: {
return self.pickerIndex
}, set: {
self.pickerIndex = $0
// TODO: DO YOUR STUFF HERE
// TODO: DO YOUR STUFF HERE
// TODO: DO YOUR STUFF HERE
// USE this if needed to notify parent
self.notifyParentOnChangeIndex = $0
})
return VStack{
Picker(selection: pi, label: Text("Yolo")) {
ForEach(self.yourData.indices) {
Text(self.yourData[$0])
}
}
.pickerStyle(WheelPickerStyle())
.padding()
}
}
I know this is a year old post, but I thought this solution might help others that stop by for a visit in need of a solution. Hope it helps someone else.
import Foundation
import SwiftUI
struct MeasurementUnitView: View {
#State var selectedIndex = unitTypes.firstIndex(of: UserDefaults.standard.string(forKey: "Unit")!)!
var userSettings: UserSettings
var body: some View {
VStack {
Spacer(minLength: 15)
Form {
Section {
Picker(selection: self.$selectedIndex, label: Text("Current UnitType")) {
ForEach(0..<unitTypes.count, id: \.self) {
Text(unitTypes[$0])
}
}.onReceive([self.selectedIndex].publisher.first()) { (value) in
self.savePick()
}
.navigationBarTitle("Change Unit Type", displayMode: .inline)
}
}
}
}
func savePick() {
if (userSettings.unit != unitTypes[selectedIndex]) {
userSettings.unit = unitTypes[selectedIndex]
}
}
}
I use a segmented picker and had a similar requirement. After trying a few things I just used an object that had both an ObservableObjectPublisher and a PassthroughSubject publisher as the selection. That let me satisfy SwiftUI and with an onReceive() I could do other stuff as well.
// Selector for the base and radix
Picker("Radix", selection: $base.value) {
Text("Dec").tag(10)
Text("Hex").tag(16)
Text("Oct").tag(8)
}
.pickerStyle(SegmentedPickerStyle())
// receiver for changes in base
.onReceive(base.publisher, perform: { self.setRadices(base: $0) })
base has both an objectWillChange and a PassthroughSubject<Int, Never> publisher imaginatively called publisher.
class Observable<T>: ObservableObject, Identifiable {
let id = UUID()
let objectWillChange = ObservableObjectPublisher()
let publisher = PassthroughSubject<T, Never>()
var value: T {
willSet { objectWillChange.send() }
didSet { publisher.send(value) }
}
init(_ initValue: T) { self.value = initValue }
}
typealias ObservableInt = Observable<Int>
Defining objectWillChange isn't strictly necessary but when I wrote that I liked to remind myself that it was there.
For people that have to support both iOS 13 and 14, I added an extension which works for both. Don't forget to import Combine.
Extension View {
#ViewBuilder func onChangeBackwardsCompatible<T: Equatable>(of value: T, perform completion: #escaping (T) -> Void) -> some View {
if #available(iOS 14.0, *) {
self.onChange(of: value, perform: completion)
} else {
self.onReceive([value].publisher.first()) { (value) in
completion(value)
}
}
}
}
Usage:
Picker(selection: $selectedIndex, label: Text("Color")) {
Text("Red").tag(0)
Text("Blue").tag(1)
}.onChangeBackwardsCompatible(of: selectedIndex) { (newIndex) in
print("Do something with \(newIndex)")
}
Important note: If you are changing a published property inside an observed object within your completion block, this solution will cause an infinite loop in iOS 13. However, it is easily fixed by adding a check, something like this:
.onChangeBackwardsCompatible(of: showSheet, perform: { (shouldShowSheet) in
if shouldShowSheet {
self.router.currentSheet = .chosenSheet
showSheet = false
}
})
SwiftUI 1 & 2
Use onReceive and Just:
import Combine
import SwiftUI
struct ContentView: View {
#State private var selection = 0
var body: some View {
Picker("Some Label", selection: $selection) {
ForEach(0 ..< 5, id: \.self) {
Text("Number \($0)")
}
}
.onReceive(Just(selection)) {
print("Selected: \($0)")
}
}
}
iOS 14 and CoreData entities with relationships
I ran into this issue while trying to bind to a CoreData entity and found that the following works:
Picker("Level", selection: $contact.level) {
ForEach(levels) { (level: Level?) in
HStack {
Circle().fill(Color.green)
.frame(width: 8, height: 8)
Text("\(level?.name ?? "Unassigned")")
}
.tag(level)
}
}
.onChange(of: contact.level) { _ in savecontact() }
Where "contact" is an entity with a relationship to "level".
The Contact class is an #ObservedObject var contact: Contact
saveContact is a do-catch function to try viewContext.save()...
The very important issue : we must pass something to "tag" modifier of Picker item view (inside ForEach) to let it "identify" items and trigger selection change event. And the value we passed will return to Binding variable with "selection" of Picker.
For example :
Picker(selection: $selected, label: Text("")){
ForEach(data){item in //data's item type must conform Identifiable
HStack{
//item view
}
.tag(item.property)
}
}
.onChange(of: selected, perform: { value in
//handle value of selected here (selected = item.property when user change selection)
})

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