[Pic 1 AS IS] [Pic 2 TO BE]
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")
})
}
}
}
}
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")
}
}
}