1

I'm sure it's something very silly but how should one reset the state value of a child view when another state has changed?

For example, the code below shows 2 folders, which respectively have 2 and 3 items., which can be edited.

If you select the second folder (Work) and its 3rd item (Peter) and then select the first folder (Home), the app crashes since selectedItemIndex is out of bounds.

I tried to "reset" the state value when the view gets initialized but it seems like changing the state like such triggers out a "runtime: SwiftUI: Modifying state during view update, this will cause undefined behavior." warning.

init(items: Binding<[Item]>) {  
    self._items = items  
    self._selectedItemIndex = State(wrappedValue: 0)  
}  

What is the proper way to do this? Thanks!

Here's the code:

AppDelegate.swift

import Cocoa
import SwiftUI

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {

    var window: NSWindow!


    func applicationDidFinishLaunching(_ aNotification: Notification) {
        // Create the SwiftUI view that provides the window contents.
        let store = ItemStore()
        let contentView = ContentView(store: store)

        // Create the window and set the content view. 
        window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
            styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
            backing: .buffered, defer: false)
        window.center()
        window.setFrameAutosaveName("Main Window")
        window.contentView = NSHostingView(rootView: contentView)
        window.makeKeyAndOrderFront(nil)
    }

    func applicationWillTerminate(_ aNotification: Notification) {
        // Insert code here to tear down your application
    }
}

ContentView.swift

import SwiftUI

final class ItemStore: ObservableObject {
    @Published var data: [Folder] = [Folder(name: "Home",
                                            items: [Item(name: "Mark"), Item(name: "Vincent")]),
                                     Folder(name: "Work",
                                            items:[Item(name: "Joseph"), Item(name: "Phil"), Item(name: "Peter")])]
}

struct Folder: Identifiable {
    var id = UUID()
    var name: String
    var items: [Item]
}

struct Item: Identifiable {
    static func == (lhs: Item, rhs: Item) -> Bool {
        return true
    }
    
    var id = UUID()
    var name: String
    var content = Date().description
    
    init(name: String) {
        self.name = name
    }
}

struct ContentView: View {
    @ObservedObject var store: ItemStore
    
    @State var selectedFolderIndex: Int?
    
    var body: some View {
        HSplitView {
            // FOLDERS
            List(selection: $selectedFolderIndex) {
                Section(header: Text("Groups")) {
                    ForEach(store.data.indexed(), id: \.1.id) { index, folder in
                        Text(folder.name).tag(index)
                    }
                }.collapsible(false)
            }
            .listStyle(SidebarListStyle())
            
            // ITEMS
            if selectedFolderIndex != nil {
                ItemsView(items: $store.data[selectedFolderIndex!].items)
            }
        }
        .frame(minWidth: 800, maxWidth: .infinity, maxHeight: .infinity)
    }
}


struct ItemsView: View {
    @Binding var items: [Item]
    @State var selectedItemIndex: Int?
    
    var body: some View {
        HSplitView {
            List(selection: $selectedItemIndex) {
                ForEach(items.indexed(), id: \.1.id) { index, item in
                    Text(item.name).tag(index)
                }
            }
            .frame(width: 300)
            
            if selectedItemIndex != nil {
                DetailView(item: $items[selectedItemIndex!])
                .padding()
                .frame(minWidth: 200, maxHeight: .infinity)
            }
        }
    }
    
    init(items: Binding<[Item]>) {
        self._items = items
        self._selectedItemIndex = State(wrappedValue: 0)
    }
}


struct DetailView: View {
    @Binding var item: Item
    
    var body: some View {
        VStack {
            TextField("", text: $item.name)
        }
    }
}

// Credit: https://swiftwithmajid.com/2019/07/03/managing-data-flow-in-swiftui/

struct IndexedCollection<Base: RandomAccessCollection>: RandomAccessCollection {
    typealias Index = Base.Index
    typealias Element = (index: Index, element: Base.Element)

    let base: Base

    var startIndex: Index { base.startIndex }

    var endIndex: Index { base.endIndex }

    func index(after i: Index) -> Index {
        base.index(after: i)
    }

    func index(before i: Index) -> Index {
        base.index(before: i)
    }

    func index(_ i: Index, offsetBy distance: Int) -> Index {
        base.index(i, offsetBy: distance)
    }

    subscript(position: Index) -> Element {
        (index: position, element: base[position])
    }
}

extension RandomAccessCollection {
    func indexed() -> IndexedCollection<Self> {
        IndexedCollection(base: self)
    }
}
aheze
  • 24,434
  • 8
  • 68
  • 125
GSD
  • 149
  • 6
  • Does this answer your question? [Handling derived state in SwiftUI](https://stackoverflow.com/questions/59888328/handling-derived-state-in-swiftui) – arsenius Apr 09 '20 at 01:12
  • I tried pasting your code into Xcode 11.4 and there quite a few compiler errors, so it's hard to experiment with it. Is there supposed to be a type parameter on IndexedCollection? Something may not have made it into what you pasted into your question. I see `Binding<[Item]>` became `Binding` for example. – Rob N Apr 10 '20 at 00:03

2 Answers2

2

Thanks to @jordanpittman for suggesting a fix:

ItemsView(items: $store.data[selectedFolderIndex!].items).id(selectedRowIndex)

Source: https://swiftui-lab.com/swiftui-id

GSD
  • 149
  • 6
1

Fully playable sample draft for ContentView.swift. Play with it in both edit modes (inactive/active row selection) and adopt to your needs.

import SwiftUI

struct ItemStore {
    var data: [Folder] = [Folder(name: "Home", items: [Item(name: "Mark"), Item(name: "Vincent")]),
                          Folder(name: "Work", items:[Item(name: "Joseph"), Item(name: "Phil"), Item(name: "Peter")])]
}

struct Folder: Identifiable {
    var id = UUID()
    var name: String
    var items: [Item]
}

struct Item: Identifiable {

    var id = UUID()
    var name: String
    var content = Date().description

}

struct ContentView: View {
    @State var store: ItemStore

    @State var selectedFolderIndex: Int? = 0
    @State private var editMode = EditMode.inactive
    var body: some View {
        NavigationView {
            VStack {
                // FOLDERS

                List(selection: $selectedFolderIndex) {
                    Section(header: Text("Groups")) {
                        ForEach(store.data.indexed(), id: \.1.id) { index, folder in
                            HStack {
                                Text(folder.name).tag(index)
                                Spacer()
                            }
                                .background(Color.white) //make the whole row tapable, not just the text
                            .frame(maxWidth: .infinity)
                            .multilineTextAlignment(.leading)
                            .onTapGesture {
                                self.selectedFolderIndex = index
                            }
                        }.onDelete(perform: delete)
                    }
                }
                .listStyle(GroupedListStyle())
                .id(selectedFolderIndex)
                // ITEMS
                if selectedFolderIndex != nil && (($store.data.wrappedValue.startIndex..<$store.data.wrappedValue.endIndex).contains(selectedFolderIndex!) ){
                    ItemsView(items: $store.data[selectedFolderIndex!].items)
                }
            }
            .navigationBarTitle("Title")
            .navigationBarItems(trailing: EditButton())
            .environment(\.editMode, $editMode)
        }
    }

    func delete(at offsets: IndexSet) {
        $store.wrappedValue.data.remove(atOffsets: offsets) // Note projected value! `store.data.remove() will not modify SwiftUI on changes and it will crash because of invalid index.
    }
}


struct ItemsView: View {
    @Binding var items: [Item]
    @State var selectedDetailIndex: Int?
    var body: some View {
        HStack {
            List(selection: $selectedDetailIndex) {
                ForEach(items.indexed(), id: \.1.id) { index, item in
                    Text(item.name).tag(index)
                    .onTapGesture {
                        self.selectedDetailIndex = index
                    }
                }

            }

            if selectedDetailIndex != nil && (($items.wrappedValue.startIndex..<$items.wrappedValue.endIndex).contains(selectedDetailIndex!) )    {
                DetailView(item: $items[selectedDetailIndex!])
                    .padding()
            }
        }

    }

}


struct DetailView: View {
    @Binding var item: Item

    var body: some View {
        VStack {
            TextField("", text: $item.name)
        }
    }
}

// Credit: https://swiftwithmajid.com/2019/07/03/managing-data-flow-in-swiftui/

struct IndexedCollection<Base: RandomAccessCollection>: RandomAccessCollection {
    typealias Index = Base.Index
    typealias Element = (index: Index, element: Base.Element)

    let base: Base

    var startIndex: Index { base.startIndex }

    var endIndex: Index { base.endIndex }

    func index(after i: Index) -> Index {
        base.index(after: i)
    }

    func index(before i: Index) -> Index {
        base.index(before: i)
    }

    func index(_ i: Index, offsetBy distance: Int) -> Index {
        base.index(i, offsetBy: distance)
    }

    subscript(position: Index) -> Element {
        (index: position, element: base[position])
    }
}

extension RandomAccessCollection {
    func indexed() -> IndexedCollection<Self> {
        IndexedCollection(base: self)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(store: ItemStore())
    }
}
Paul B
  • 3,989
  • 33
  • 46