0

In SwiftUI I have a list of menu items that each hold a name, price etc. There are a bunch of categories and under each are a list of items.

struct ItemList: Identifiable, Codable {
    var id: Int
    var name: String
    var picture: String
    var list: [Item]
    
    @State var newItemName: String
}

I was looking for a way to create a TextField inside each category that would add to its array of items.

enter image description here

Making the TextFields through a ForEach loop was simple enough, but I got stuck trying to add a new Item using the entered text to the right category.

ForEach(menu.indices) { i in
    Section(header: Text(menu[i].name)) {
        ForEach(menu[i].list) { item in
            Text(item.name)
        }
        TextField("New Type:", text: /*some kind of bindable here?*/) {
            menu[i].list.append(Item(name: /*the text entered above*/))
        }
    }
}

I considered using @Published and Observable Object like this other question, but I need the ItemList to be a Codable struct so I couldn't figure out how to fit the answers there to my case.

TextField("New Type:", text: menu[i].$newItemName)

Anyway any ideas would be appreciated, thanks!

2 Answers2

0

You just have to focus your View.

import SwiftUI

struct ExpandingMenuView: View {
    @State var menu: [ItemList] = [
        ItemList(name: "Milk Tea", picture: "", list: [ItemModel(name: "Classic Milk Tea"), ItemModel(name: "Taro milk tea")]),
        ItemList(name: "Tea", picture: "", list: [ItemModel(name: "Black Tea"), ItemModel(name: "Green tea")]),
        ItemList(name: "Coffee", picture: "", list: [])
        
    ]
    var body: some View {
        List{
            //This particular setup is for iOS15+
            ForEach($menu) { $itemList in
                ItemListView(itemList: $itemList)
            }
        }
    }
}

struct ItemListView: View {
    @Binding var itemList: ItemList
    @State var newItemName: String = ""
    var body: some View {
        Section(header: Text(itemList.name)) {
            ForEach(itemList.list) { item in
                Text(item.name)
            }
            TextField("New Type:", text: $newItemName, onCommit: {
                //When the user commits add to array and clear the new item variable
                itemList.list.append(ItemModel(name: newItemName))
                newItemName = ""
            })
        }
    }
}
struct ItemList: Identifiable, Codable {
    var id: UUID = UUID()
    var name: String
    var picture: String
    var list: [ItemModel]
    //@State is ONLY for SwiftUI Views
    //@State var newItemName: String
}
struct ItemModel: Identifiable, Codable {
    var id: UUID = UUID()
    var name: String
    
}
struct ExpandingMenuView_Previews: PreviewProvider {
    static var previews: some View {
        ExpandingMenuView()
    }
}

If you aren't using Xcode 13 and iOS 15+ there are many solutions in SO for Binding with array elements. Below is just one of them

ForEach(menu) { itemList in
    let proxy = Binding(get: {itemList}, set: { new in
        let idx = menu.firstIndex(where: {
            $0.id == itemList.id
        })!
        menu[idx] = new
    })
    ItemListView(itemList: proxy)
}

Also note that using indices is considered unsafe. You can watch Demystifying SwiftUI from WWDC2021 for more details.

lorem ipsum
  • 21,175
  • 5
  • 24
  • 48
  • The `ForEach($menu) { $itemList in` syntax isn't iOS 15 only, it's a Swift 5.5 feature. You only need iOS 15 if using `enumerated()` on a `Binding`, for example. – George Sep 11 '21 at 16:37
  • Yeah that worked perfectly thanks! Had a bit of a tough time upgrading to swift 5.5, but the Binding() method worked great, really appreciate it – Brayden Rudisill Sep 11 '21 at 21:21
-1

You can have an ObservableObject to be your data model, storing categories which then store the items.

You can then bind to these items, using Swift 5.5 syntax. This means we can write List($menu.categories) { $category in /* ... */ }. Then, when we write $category.newItem, we have a Binding<String> to the newItem property in Category.

Example:

struct ContentView: View {
    @StateObject private var menu = Menu(categories: [
        Category(name: "Milk Tea", items: [
            Item(name: "Classic Milk Tea"),
            Item(name: "Taro Milk Tea")
        ]),
        Category(name: "Tea", items: [
            Item(name: "Black Tea"),
            Item(name: "Green Tea")
        ]),
        Category(name: "Coffee", items: [
            Item(name: "Black Coffee")
        ])
    ])

    var body: some View {
        List($menu.categories) { $category in
            Section(header: Text(category.name)) {
                ForEach(category.items) { item in
                    Text(item.name)
                }

                TextField("New item", text: $category.newItem, onCommit: {
                    guard !category.newItem.isEmpty else { return }
                    category.items.append(Item(name: category.newItem))
                    category.newItem = ""
                })
            }
        }
    }
}
class Menu: ObservableObject {
    @Published var categories: [Category]

    init(categories: [Category]) {
        self.categories = categories
    }
}

struct Category: Identifiable {
    let id = UUID()
    let name: String
    var items: [Item]
    var newItem = ""
}

struct Item: Identifiable {
    let id = UUID()
    let name: String
}

Result:

Result

George
  • 25,988
  • 10
  • 79
  • 133
  • You may want to add the Combine import line before somebody gets confused. – El Tomato Sep 11 '21 at 01:45
  • @ElTomato But it's using SwiftUI, so Combine is already imported by SwiftUI – George Sep 11 '21 at 01:48
  • @ElTomato Why not? Give it a try yourself. Compiles perfectly fine. Do be sure you are using Swift 5.5, though. Importing Combine is redundant here. – George Sep 11 '21 at 01:56
  • Okay. You are right about Combine. I'm sorry about that. – El Tomato Sep 11 '21 at 01:57
  • @ElTomato nw! If you `command + click` on `import SwiftUI` then see definition, the 2nd import from SwiftUI is Combine. – George Sep 11 '21 at 01:58
  • Does this code need Swift 5.5? I see two errors if I don't run with Swift 5.5. – El Tomato Sep 11 '21 at 01:59
  • @ElTomato Yep - see paragraph 2 in my answer. It's the new element binding syntax. See [here](https://www.swiftbysundell.com/articles/bindable-swiftui-list-elements/#xcode-s-new-element-binding-syntax). Also, it's a Swift feature so backwards compatible for iOS versions. – George Sep 11 '21 at 02:02
  • Note SwiftUI does import Combine, but it does not export it, so there are (many) instances when you also need to import Combine as well to have access to its types. – workingdog support Ukraine Sep 11 '21 at 02:14
  • @workingdog Ah that's true because they are both Swift frameworks. Nonetheless, this example works without `import Combine` for me :p – George Sep 11 '21 at 02:17
  • @George "But it's using SwiftUI, so Combine is already imported by SwiftUI" that's what I originally thought, but sometimes you still need to manually import it yourself. [See here](https://stackoverflow.com/q/67353167/14351818) – aheze Sep 13 '21 at 03:48