0

[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")
            })
                  
            }
        }
    }
}
lorem ipsum
  • 21,175
  • 5
  • 24
  • 48
Christina
  • 3
  • 2
  • Look into `DisclosureGroup` or `List` with `children`. Hard to help more specifically without a minimal reproducible example to troubleshoot – lorem ipsum Nov 28 '22 at 15:22
  • @loremipsum: I copied my code above - sorry, its a bit long with four different Views, but I hope that clarifies. It is not DisclosureGroup or List with children what I mean – Christina Nov 29 '22 at 10:12
  • A minimal reproducible example should only contain the code necessary to reproduce the issue everything else should be removed – lorem ipsum Nov 29 '22 at 10:28
  • @loremipsum, I tried to remove a bit more code, but the rest is really what it needs. Thanks. – Christina Dec 01 '22 at 11:15
  • What do you mean by individual? Why is Disclosure Group not what you are looking for? Just trying to get a picture of what you are trying to acheive – lorem ipsum Dec 01 '22 at 12:11
  • @loremipsum, I added two pictures: "Pic 1 AS IS" and "Pic 2 TO BE". In the first pic you see that the fruits link contains a list of peach, banana and apple, but the vegetables link as well contains the fruits. BUT I want the vegetables to have the tomatoes etc. That's what I mean with "individual". Do you know how to change the code so that it will be as in the pic 2? – Christina Dec 02 '22 at 10:11
  • The 2 photos seem identical to me. Just a List with Navigation Links that have lists in the detail – lorem ipsum Dec 02 '22 at 12:34
  • @loremipsum, the two pictures are not identical. Right now (AS IS), when I type in all the fruits, they appear in the fruits list and in the vegetables list as well. I do not know how to make the fruits list showing fruits and the vegetable list showing vegetables. – Christina Dec 02 '22 at 14:34
  • I see it now that was my blindness, it is because your `DetailView` is only displaying values from `@StateObject private var pvm = ProductlistViewModel()` you need to pass the values from the list. It would be better illustrated if you had some mock items in the arrays – lorem ipsum Dec 02 '22 at 14:42
  • @loremipsum I do not really understand your suggestion. How would your suggestion look like in the code? – Christina Dec 02 '22 at 16:56
  • See below it is looong – lorem ipsum Dec 02 '22 at 17:00
  • @loremipsum OK I will have a chance to look at it next week! – Christina Dec 02 '22 at 17:26
  • No problem, don't forget to accept (green checkmark) when you have time to look at it. – lorem ipsum Dec 02 '22 at 17:28
  • @loremipsum I finally got the time to look at your solution. This is AWESOME and exactly answers what I was looking for - could you please program the whole app I am planning to do ;))) One thing I had to edit: "@Published var items: [ListItemModel] = [ListItemModel(..." I needed to change into "@Published var items: [ListProductModel] = [ListProductModel(..." because it showed an error. THANK YOU SO MUCH! – Christina Dec 10 '22 at 13:25
  • Not sure what error you were getting but “items” is for the ItemModel code/models and “products” is for the ProductModel code/models. – lorem ipsum Dec 10 '22 at 13:47
  • @loremipsum Cool! On my journey programming an app I for sure will come across more issues - simply because it's the first time I am programming ;) You have your own website? Or is there a site where one can "book" a programmer for an app or for certain projects? – Christina Dec 12 '22 at 09:07
  • @loremipsum It did not take ListItemModel – Christina Dec 12 '22 at 09:09

1 Answers1

0

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")
        }
    }
}
lorem ipsum
  • 21,175
  • 5
  • 24
  • 48