SectionedFetchRequest with LazyVStack/ForEach Fails to Update Properly

573 views Asked by At

(Modified title and added Contact List example with code and behavior differences between Lists and LazyVStack/ForEach.)

Within a NavigationView/LazyVStack, mutually-exclusive ForEach loops group query results that come from a @FetchRequest. When an entity modification causes it to "switch" groups, Core Data is updated, the entity gets displayed in the appropriate group, but the entity has the previous data.

The code example creates two groups: 1) words that begin with a letter within A through M, and 2) words that begin with a letter within N through Z. Modifying a word from either group so that it should appear in the other group ... say change "A" to "Z" ... has the entity now appear in the other, proper group, but with the previous word.

enter image description here

Other key observations:

  • Selecting the just-changed word ... from "A" to "Z" in the mentioned example ... brings up the ModifyView (via NavigationLink) showing the previous data ... an "A" and not the "Z".
  • Changing LazyVStack to VStack produces the desired behavior: the change is shown correctly and in the proper section.
  • Closing the app (swipe up in iOS) and reopening produces the desired behavior: the change is shown correctly and in the proper section.
  • All updates to words that do not cause a group change work perfectly! For example, changing from B to C or from N to O works.
struct List: View {
   @FetchRequest(
      sortDescriptors: [SortDescriptor(\Entity.name_, order: .forward)]
   ) var allEntities: FetchedResults<Entity>
   @State var showAddView = false
   
   var body: some View {
      NavigationView {
         ScrollView {
            LazyVStack {   // Works as expected if VStack
               // First ForEach: first half of alphabet
               ForEach(allEntities.filter { fh in fh.firstHalfOfAlphabet() })
               { entity in
                  NavigationLink(destination: Modify(entity: entity)) {
                     Text(entity.name).font(.title).padding()
                  }
               }
               Text("A to M above ---- N to Z below")
               // Second ForEach: second half of alphabet
               ForEach(allEntities.filter { sh in !sh.firstHalfOfAlphabet() })
               { entity in
                  NavigationLink(destination: Modify(entity: entity)) {
                     Text(entity.name).font(.title).padding()
                  }
               }
            }
         }
         .navigationBarTitleDisplayMode(.inline)
         .sheet(isPresented: $showAddView) { New() }
         .toolbar {
            ToolbarItem() {
               Button(action: { showAddView = true },
                      label: { Image(systemName: "plus.circle") }
               )
            }
         }
      }
   }
}

Code to Modify an entity:

struct Modify: View {
   @ObservedObject var entity: Entity
   @Environment(\.managedObjectContext) var moc
   @State var theName: String // Local working value
   @Environment(\.dismiss) var dismiss
   
   init(entity: Entity) {
      self.entity = entity
      _theName = State<String>(initialValue: entity.name)
   }
   
   var body: some View {
      VStack {
         TextField("Name", text: $theName).padding()
         HStack {
            Button("Save") {
               entity.name = theName   // Update @ObservedObject
               try? moc.save()         // Save to database
               dismiss()
            }
         }
      }.font(.title)
   }
}

The Core Data database has the single entry name_ as a String. Helper code:

extension Entity {
   var name: String {
      get { return name_ ?? "Unknown" }
      set { name_ = newValue }
   }
}

extension Entity: Comparable {
   // Compares first letters of the names to determine "smallest" name
   public static func < (lhs: Entity, rhs: Entity) -> Bool {
      return lhs.name < rhs.name
   }

   func firstHalfOfAlphabet() -> Bool {
      if self.name.first!.uppercased() < "N" { return true }
      else { return false }
   }
}

NEW CONTENT. Implemented @SectionedFetchRequest as shown in the code below. However, the "Other Key Observations" noted above still apply. For example, using LazyVStack (rather than VStack) is problematic because updates are not reflected if a modification causes a switch from the first half to the second half of the alphabet. It's as if using LazyVStack provides the change "no reason to update".

struct List: View {
   @SectionedFetchRequest<Bool, Entity>(
      sectionIdentifier: \.firstHalfOfAlphabet,
      sortDescriptors: [SortDescriptor(\.name_, order: .forward)]
   ) private var sectionedEntities
   
   @State var showAddView = false
   
   var body: some View {
      NavigationView {
         ScrollView {
            LazyVStack {   // Works as expected if VStack
               ForEach(sectionedEntities) { section in
                  Section(header: HeaderView(sectionId: section.id)) {
                     ForEach(section) { entity in
                        NavigationLink(destination: Modify(entity: entity)) {
                           Text(entity.name).font(.title).padding()
                        }
                     }
                  }
               }
            }
            .navigationBarTitleDisplayMode(.inline)
            .sheet(isPresented: $showAddView) { New() }
            .toolbar {
               ToolbarItem() {
                  Button(action: { showAddView = true },
                         label: { Image(systemName: "plus.circle") }
                  )
               }
            }
         }
      }
   }
}

CONTACT LIST UPDATE. Added for relatability to show the issue. Contact List done with SectionedFetchRequest two ways: 1. Using a List (works), 2. Using LazyVStack and ForEach (fails for some updates).

  1. Contact List using a List
struct ContentView: View {
    @SectionedFetchRequest<String, Contact>(
        sectionIdentifier: \.lastNameInitial,
        sortDescriptors: [
            NSSortDescriptor(keyPath: \Contact.lastName, ascending: true),
            NSSortDescriptor(keyPath: \Contact.firstName, ascending: true),
        ]
    ) var sectionedContacts
    
    var body: some View {
        NavigationView {
            List(sectionedContacts) { section in
                Section(header: Text("Lastnames with '\(section.id)'")) {
                    ForEach(section) { contact in
                        NavigationLink(destination: ModifyContact(contact: contact)) {
                            ContactView(contact: contact)
                        }
                    }
                }
            }
        }
    }
}

Lastname modification behavior: Change Ommitt to Emmitt with success! Result: Modifying Ommitt to Emmitt updates name and properly goes to the 'E' section.

  1. Contact List using LazyVStack and ForEach
struct ContentView: View {
   @SectionedFetchRequest<String, Contact>(
      sectionIdentifier: \.lastNameInitial,
      sortDescriptors: [
         NSSortDescriptor(keyPath: \Contact.lastName, ascending: true),
         NSSortDescriptor(keyPath: \Contact.firstName, ascending: true),
      ]
   ) var sectionedContacts

   var body: some View {
      NavigationView {
         LazyVStack {
            ForEach(sectionedContacts) { section in
               Section(header: Text("Lastnames '\(section.id)'")) {
                  ForEach(section) { contact in
                     NavigationLink(destination: ModifyContact(contact: contact)) { ContactView(contact: contact)
                     }
                  }
               }
            }
         }
      }
   }
}

Lastname modification behavior: Contact List using LazyVStack and ForEach Result: Modifying Ommitt to Emmitt does NOT show name update but properly goes to the 'E' section.

Note: With a change to the first letter of the last name, no visual updates occur to last name, first name, or phone number even though the contact is sectioned properly as shown above. Without a change to the first letter of the last name, all fields display correctly.

1

There are 1 answers

0
SwiftAero On

Pairing a SectionedFetchRequest with LazyVStack fails to properly update when changes are made to the underlying data that cause a change in the item's section.

The alternative working examples above can be considered workarounds:

  • Use VStack rather than LazyVStack. A drawback is the VStack doesn't provide lazing fetching.
  • Use List rather than LazyVStack. Here you get lazy fetching, but perhaps not the desired "look".

Another workaround is this: for a given item's update that would lead to a switch in section, rather than Update, perform Delete then Create for that changed entity. This approach works for the SectionedFetchRequest/LazyVStack examples above and works in code for an app I'm developing.