List with Sections: Odd Behaviour when item in list changes

I have a simple Demo App were I present Items in a List with two sections. The first sections shows the favourite items the second all other.

The odd behaviour occurs if I change the isFav state.

on iPhoneOS:

  • When I select an item the DetailView will appear.
  • if I change the isFav state (toggle) the DetailView will disappear
  • Video: /Dx_t88YvEVw (usual platform)

on iPadOS:

  • When I select an item the DetailView will appear.
  • if I change the isFav state (toggle) the DetailView will not disappear but in the side bar the selection [disappears]
  • Video: /qq4jQNrqlBg (usual platform)
//
//  ContentView.swift
//  Shared
//
//  Created by Christian on 06.06.21.
//

import SwiftUI

//MARK: - Data Model

struct Item: Identifiable, Equatable, Hashable {
   var id = UUID().uuidString
   var isFav = false
   var text: String
}

struct ItemScoped: Identifiable, Equatable, Hashable {
   var id: String {
      return item.id
   }
   var item: Item
   var index: Int
}

//MARK: Store

class ItemStore: ObservableObject {
   @Published var items = [Item(text: "Item 1"),
                           Item(text: "Item 2"),
                           Item(isFav: true, text: "Item 3"),
                           Item(text: "Item 4")]
   
   func scopedItems(isFav: Bool) -> [ItemScoped] {
      let sItems: [ItemScoped]  = items.compactMap {
         guard let idx = items.firstIndex(of: $0) else { return nil }
         //find(items, $0)
         return ItemScoped(item: $0, index: idx)
      }
      return sItems.filter { $0.item.isFav == isFav }
   }
}

//MARK: - Views

struct ContentView: View {
   
   // usally this is @EnvironmetObject, due to simplicity I put it here
   @StateObject var store: ItemStore = ItemStore()
   
   var body: some View {
      NavigationView {
         List {
            Section(header: Text("Favorites")) {
               ForEach(store.scopedItems(isFav: true)) { scopedItems in
                  NavigationLink(
                     destination: DetailView(item: $store.items[scopedItems.index]),
                     label: {
                        RowView(item: $store.items[scopedItems.index])
                     })
               }
            }
            Section(header: Text("Other")) {
               ForEach(store.scopedItems(isFav: false)) { scopedItems in
                  NavigationLink(
                     destination: DetailView(item: $store.items[scopedItems.index]),
                     label: {
                        RowView(item: $store.items[scopedItems.index])
                     })
               }
            }
         }
         .navigationTitle("Items")
      }
   }
}

// MARK: Row View

/// RowView for item, tapping the text toggle the `isFav` state
struct RowView: View {
   
   @Binding var item: Item
   
   var body: some View {
      Label(
         title: { Text(item.text) },
         icon: { item.isFav ? Image(systemName: "star.fill") : Image(systemName: "star")}
      )
   }
}


// MARK: Detail View

/// DetailView to change item `text` and toggle `isFav` state
struct DetailView: View {
   
   @Binding var item: Item
   
   var body: some View {
      VStack {
         Spacer()
            .frame(height: 20.0)
         TextField("Title", text: $item.text)
            .background(Color.gray.opacity(0.2))
            .padding(10)
         Toggle("is Fav", isOn: $item.isFav.animation())
            .padding()
         Spacer()
      }
      .padding()
   }
}

// MARK: - Preview

struct ContentView_Previews: PreviewProvider {
   static var previews: some View {
      ContentView()
   }
}





Replies

I found a solution using the tag property of the NavigationLink and @SceneStorage property wrapper.

  1. Create a @SceneStorage (persistent state per scene)

     @State private var sceneItemID: String?  
    

    or

     @SceneStorage private var sceneItemID: String?  
    
  2. Add a tag with the unique id of the item to every NavigationLink

    NavigationLink(destination:  DetailView(item: $item),
                   tag: item.id,
                   selection: $sceneItemID,
                   label: {
                        RowView(item: $item)
                   })
    

    Every time the Navigation Link is used the sceneItemID is updated with the tag (item.id in this case).

  3. In the DetailView update the sceneItemID in the .onAppear() modifier.

This is necessary due to the behaviour during state change of isFav.

Now it is working only on iPad the Sidebar does not correctly display the selection. On macOS and iPhone this works.


//
//  ContentView.swift
//  Shared
//
//  Created by Christian on 06.06.21.
//

import SwiftUI

//MARK: - Data Model

struct Item: Identifiable, Equatable, Hashable {
   var id = UUID().uuidString
   var isFav = false
   var text: String
}

struct ItemScoped: Identifiable, Equatable, Hashable {
   var id: String {
      return item.id
   }
   var item: Item
   var index: Int
}

//MARK: Store

class ItemStore: ObservableObject {
   @Published var items = [Item(id: "uuid01", text: "Item 1"),
                           Item(id: "uuid02", text: "Item 2"),
                           Item(id: "uuid03", isFav: true, text: "Item 3"),
                           Item(id: "uuid04", text: "Item 4")]
   
   /// scope item to sections and keep knowledge of origin index
   func scopedItems(isFav: Bool) -> [ItemScoped] {
      let sItems: [ItemScoped]  = items.compactMap {
         guard let idx = items.firstIndex(of: $0) else { return nil }
         //find(items, $0)
         return ItemScoped(item: $0, index: idx)
      }
      return sItems.filter { $0.item.isFav == isFav }
   }
}


//MARK: - Views

struct ContentView: View {
   
   // usally this is @EnvironmetObject, due to simplicity I put it here
   @StateObject var store: ItemStore = ItemStore()
   
   @SceneStorage("SceneItemSelectionID") private var sceneItemID: String?
   
   var body: some View {
      NavigationView {
         
         List {
            Section(header: Text("Favorites")) {
               ForEach(store.scopedItems(isFav: true)) { scopedItems in
                  NavigationLink(
                     destination: DetailView(item: $store.items[scopedItems.index]),
                     //MARK: !! IMPORTANT: use unique indetifier as tag
                     tag: store.items[scopedItems.index].id,
                     selection: $sceneItemID,
                     label: {
                        RowView(item: $store.items[scopedItems.index])
                     })
               }
            }
            Section(header: Text("Others")) {
               ForEach(store.scopedItems(isFav: false)) { scopedItems in
                  NavigationLink(
                     destination: DetailView(item: $store.items[scopedItems.index]),
                     //MARK: !! IMPORTANT: use unique indetifier as tag
                     tag: store.items[scopedItems.index].id,
                     selection: $sceneItemID,
                     label: {
                        RowView(item: $store.items[scopedItems.index])
                     })
                  
               }
            }
         }
         .listStyle(SidebarListStyle())
         .navigationTitle("Items")
      }
   }
}

// MARK: Row View

/// RowView for item, tapping the text toggle the `isFav` state
struct RowView: View {
   
   @Binding var item: Item
   
   var body: some View {
      Label(
         title: { Text(item.text) },
         icon: { item.isFav ? Image(systemName: "star.fill") : Image(systemName: "star")}
      )
   }
}


// MARK: Detail View

/// DetailView to change item `text` and toggle `isFav` state
struct DetailView: View {
   
   @Binding var item: Item
   
   @SceneStorage("SceneItemSelectionID") private var sceneItemID: String?
   
   var body: some View {
      VStack {
         Spacer()
            .frame(height: 20.0)
         TextField("Title", text: $item.text)
            .background(Color.gray.opacity(0.2))
            .padding(10)
         Toggle("is Fav:", isOn: $item.isFav.animation())
            .padding()
         Spacer()
      }
      .padding()
      .onAppear() {
         //MARK: !! IMPORTANT set scene selction id again
         sceneItemID = item.id
      }
   }
}

// MARK: - Preview

struct ContentView_Previews: PreviewProvider {
   static var previews: some View {
      ContentView()
   }
}



Add a Comment