Search code examples
swiftlistforeachitemsidentifiable

Create list with NavigationLink items in SwiftUI where each NavigationLink again contains a list of items which is individual


[Pic 1 AS IS]1 [Pic 2 TO BE]2

Hi there, I am just starting to learn Swift an I would like my app users to build their own list of items (first level) where each item again contains a list of items (second level). Important is that each of the individually created lists in the second level is like no other of the individually created lists. (see picture)

Is anyone aware of which approach I need to take to solve this?

I am myself able to build the list within the list within the NavigationView, but how can I make each list individual?

Here is my code:

    struct ItemModel: Hashable {
        let name: String
    }

struct ProductModel: Hashable {
    let productname: String
}

class ListViewModel: ObservableObject {
    @Published var items: [ItemModel] = []
    }

class ProductlistViewModel: ObservableObject {
    @Published var products: [ProductModel] = []
    }




struct ContentView: View {
        
        @StateObject private var vm = ListViewModel()
        @StateObject private var pvm = ProductlistViewModel()
        @State var firstPlusButtonPressed: Bool = false
        @State var secondPlusButtonPressed: Bool = false
        
        var body: some View {
            NavigationView {
               List {
                  ForEach(vm.items, id: \.self) { item in
                     NavigationLink {
                         DetailView() //The DetailView below
                             .navigationTitle(item.name)
                             .navigationBarItems(
                                  trailing:
                                      Button(action: {  
                               secondPlusButtonPressed.toggle()
   
                                        }, label: {                                                              
                                      NavigationLink {                                                    
                               AddProduct() //AddProduct below                                    
                               } label: {
                         Image(systemName: "plus")
                                                            }
        
                                            })
                                            )
                                
                            } label: {
                                Text(item.name)
                            }
                        }
                    }
                .navigationBarItems(        
                      trailing:
                          Button(action: {        firstPlusButtonPressed.toggle()
                             }, label: {
           NavigationLink {
                      AddItem() //AddItem below
                             } label: {                        Image(systemName: "plus")
             }
                                      })
                                      )
            }
            .environmentObject(vm)
            .environmentObject(pvm)
        }
    }


struct AddItem: View {
    
    @State var textFieldText: String = ""
    @Environment(\.presentationMode) var presentationMode
    @EnvironmentObject var vm: ListViewModel

    var body: some View {
        
        NavigationView {
            
        VStack {
            
            TextField("Add an item...", text: $textFieldText)
            
            Button(action: {
                vm.addItem(text: textFieldText)
                presentationMode.wrappedValue.dismiss()
                
            }, label: {
                Text("SAVE")
            })                
            }
        }
    }
}


struct DetailView: View {
    
    @StateObject private var pvm = ProductlistViewModel()
    @Environment(\.editMode) var editMode
    
    var body: some View {
        
        NavigationView {
            List {
                ForEach(pvm.products, id: \.self) { product in
                    Text(product.productname)
                }
            }
        }
        .environmentObject(pvm)
    }
}
struct AddProduct: View {
    
    @State var textFieldText: String = ""
    @Environment(\.presentationMode) var presentationMode
    @EnvironmentObject var pvm: ProductlistViewModel

    var body: some View {
        
        NavigationView {
            
            VStack {
            
            TextField("Add a product", text: $textFieldText)
            
            Button(action: {
                pvm.addProduct(text: textFieldText)
                presentationMode.wrappedValue.dismiss()
                
            }, label: {
                Text("SAVE")
            })
                  
            }
        }
    }
}

Solution

  • This is going to be long but here it goes. The issue is the whole ViewModel setup. You detail view now is only using the product view model, you need to rethink your approach.

    But what makes the whole thing "complicated" is the 2 different types, Item and Product which you seem to want to combine into one list and use the same subviews for them both.

    In swift you have protocol that allows this, protocols require struct and class "conformance".

    //Protocols are needed so you can use reuse views
    protocol ObjectModelProtocol: Hashable, Identifiable{
        var id: UUID {get}
        var name: String {get set}
        init(name: String)
    }
    //Protocols are needed so you can use reuse views
    protocol ListModelProtocol: Hashable, Identifiable{
        associatedtype O : ObjectModelProtocol
        var id: UUID {get}
        var name: String {get set}
        //Keep the individual items with the list 
        var items: [O] {get set}
        init(name: String, items: [O])
    }
    extension ListModelProtocol{
        mutating func addItem(name: String) {
            items.append(O(name: name))
        }
    }
    

    Then your models start looking something like this. Notice the conformance to the protocols.

    //Replaces the ListViewModel
    struct ListItemModel: ListModelProtocol{
        let id: UUID
        var name: String
        var items: [ItemModel]
        
        init(name: String, items: [ItemModel]){
            self.id = .init()
            self.name = name
            self.items = items
        }
    }
    //Replaces the ProductlistViewModel
    struct ListProductModel: ListModelProtocol{
        let id: UUID
        var name: String
        var items: [ProductModel]
        init(name: String, items: [ProductModel]){
            self.id = .init()
            self.name = name
            self.items = items
        }
    }
    //Uniform objects, can be specialized but start uniform
    struct ItemModel: ObjectModelProtocol {
        let id: UUID
        var name: String
        init(name: String){
            self.id = .init()
            self.name = name
        }
    }
    //Uniform objects, can be specialized but start uniform
    struct ProductModel: ObjectModelProtocol {
        let id: UUID
        var name: String
        init(name: String){
            self.id = .init()
            self.name = name
        }
    }
    class ModelStore: ObservableObject{
        @Published var items: [ListItemModel] = [ListItemModel(name: "fruits", items: [.init(name: "peach"), .init(name: "banana"), .init(name: "apple")])]
        @Published var products: [ListProductModel] = [ListProductModel(name: "vegetable", items: [.init(name: "tomatoes"), .init(name: "paprika"), .init(name: "zucchini")])]
        
    }
    

    Now your views can look something like this

    struct ComboView: View {
        @StateObject private var store = ModelStore()
        @State var firstPlusButtonPressed: Bool = false
        @State var secondPlusButtonPressed: Bool = false
    
        var body: some View {
            NavigationView {
                List {
                    //The next part will address this
                    ItemLoop(list: $store.items)
                    ItemLoop(list: $store.products)
                    
                }
                .toolbar(content: {
                    ToolbarItem {
                        AddList(store: store)
                    }
                })
            }
        }
    }
    
    struct ItemLoop<LM: ListModelProtocol>: View {
        @Binding var list: [LM]
        var body: some View{
            ForEach($list, id: \.id) { $item in
                NavigationLink {
                    DetailView<LM>(itemList: $item)
                        .navigationTitle(item.name)
                        .toolbar {
                            NavigationLink {
                                AddItem<LM>( item: $item)
                            } label: {
                                Image(systemName: "plus")
                            }
                        }
                } label: {
                    Text(item.name)
                }
            }
        }
    }
    
    struct AddList: View {
        @Environment(\.presentationMode) var presentationMode
        @ObservedObject var store: ModelStore
        var body: some View {
            Menu {
                Button("add item"){
                    store.items.append(ListItemModel(name: "new item", items: []))
                }
                Button("add product"){
                    store.products.append(ListProductModel(name: "new product", items: []))
                }
            } label: {
                Image(systemName: "plus")
            }
            
        }
    }
    struct AddItem<LM>: View where LM : ListModelProtocol {
        @State var textFieldText: String = ""
        @Environment(\.presentationMode) var presentationMode
        @Binding var item: LM
    
        var body: some View {
            VStack {
                TextField("Add an item...", text: $textFieldText)
                Button(action: {
                    item.addItem(name: textFieldText)
                    presentationMode.wrappedValue.dismiss()
    
                }, label: {
                    Text("SAVE")
                })
            }
    
        }
    }
    
    struct DetailView<LM>: View where LM : ListModelProtocol{
        @Environment(\.editMode) var editMode
        @Binding var itemList: LM
        var body: some View {
            VStack{
                TextField("name", text: $itemList.name)
                    .textFieldStyle(.roundedBorder)
                List (itemList.items, id:\.id) { item in
                    Text(item.name)
                }
            }
            .navigationTitle(itemList.name)
            .toolbar {
                NavigationLink {
                    AddItem(item: $itemList)
                } label: {
                    Image(systemName: "plus")
                }
            }
        }
    }
    

    If you notice the List in the ComboView you will notice that the items and products are separated into 2 loop. That is because SwiftUI requires concrete types for most views, view modifiers and wrappers.

    You can have a list of [any ListModelProtocol] but at some point you will have to convert from an existential to a concrete type. In your case the ForEach in de DetailView requires a concrete type.

    class ModelStore: ObservableObject{
        @Published var both: [any ListModelProtocol] = [
            ListProductModel(name: "vegetable", items: [.init(name: "tomatoes"), .init(name: "paprika"), .init(name: "zucchini")]),
            ListItemModel(name: "fruits", items: [.init(name: "peach"), .init(name: "banana"), .init(name: "apple")])
        ]
    }
    struct ComboView: View {
        
        @StateObject private var store = ModelStore()
        @State var firstPlusButtonPressed: Bool = false
        @State var secondPlusButtonPressed: Bool = false
    
        var body: some View {
            NavigationView {
                List {
                    ConcreteItemLoop(list: $store.both)
                }
                .toolbar(content: {
                    ToolbarItem {
                        AddList(store: store)
                    }
                })
            }
        }
    }
    struct ConcreteItemLoop: View {
        @Binding var list: [any ListModelProtocol]
        var body: some View{
            ForEach($list, id: \.id) { $item in
                NavigationLink {
                    if let concrete: Binding<ListItemModel> = getConcrete(existential: $item){
                        DetailView(itemList: concrete)
                    } else if let concrete: Binding<ListProductModel> = getConcrete(existential: $item){
                        DetailView(itemList: concrete)
                    }else{
                        Text("unknown type")
                    }
                } label: {
                    Text(item.name)
                }
            }
        }
        func getConcrete<T>(existential: Binding<any ListModelProtocol>) -> Binding<T>? where T : ListModelProtocol{
            if existential.wrappedValue is T{
                return Binding {
                    existential.wrappedValue as! T
                } set: { newValue in
                    existential.wrappedValue = newValue
                }
    
            }else{
                return nil
            }
        }
    }
    
    struct AddList: View {
        @Environment(\.presentationMode) var presentationMode
        @ObservedObject var store: ModelStore
        var body: some View {
            Menu {
                Button("add item"){
                    store.both.append(ListItemModel(name: "new item", items: []))
                }
                Button("add product"){
                    store.both.append(ListProductModel(name: "new product", items: []))
                }
            } label: {
                Image(systemName: "plus")
            }
            
        }
    }
    

    I know its long but this all compiles so you should be able to put it in a project and disect it.

    Also, at the end of all of this you can create specific views for the model type.

    struct DetailView<LM>: View where LM : ListModelProtocol{
        @Environment(\.editMode) var editMode
        @Binding var itemList: LM
        var body: some View {
            VStack{
                TextField("name", text: $itemList.name)
                    .textFieldStyle(.roundedBorder)
                List (itemList.items, id:\.id) { item in
                    VStack{
                        switch item{
                        case let i as ItemModel:
                            ItemModelView(item: i)
                        case let p as ProductModel:
                            Text("\(p.name) is product")
                        default:
                            Text("\(item.name) is unknown")
                        }
                    }
                }
            }
            .navigationTitle(itemList.name)
            .toolbar {
                NavigationLink {
                    AddItem(item: $itemList)
                } label: {
                    Image(systemName: "plus")
                }
            }
        }
    }
    struct ItemModelView: View{
        let item: ItemModel
        var body: some View{
            VStack{
                Text("\(item.name) is item")
                Image(systemName: "person")
            }
        }
    }