List with Bindings from ObservedObject

Hi,


I'm trying to build a really simple Todo-List App to get started with SwiftUI.
I decided on a store object that acts as my single source of truth for Todo entries, it's really the most basic example one can imagine.


struct Todo: Identifiable {
     var id = UUID()
     var title: String
     var isDone = false
}

class TodoStore: ObservableObject {
     @Published var todos: [Todo] = []
}


I want to display the Todo-Entries in a List, but in seperate ListRow views that each have a Binding on a Todo value.


struct ListRow: View {
     @Binding var todo: Todo
     var body: some View {
          Button(action: {
               self.todo.isDone.toggle()
          }) {
               Text(todo.title)
          }
     }
}

So now, when I try to setup the actual List, I get an error:


Cannot convert value of type '(Binding<Todo>) -> ListRow' to expected argument type '(_) -> _'



struct ContentView: View {
     @ObservedObject var todoStore = TodoStore()

     var body: some View {
          List(todoStore.todos, id: \.self) { todo in
               ListRow(todo: todo)
          }
     }
}


What am I missing here?

Replies

Your ListRow view expects a Binding, but the closure for List(todoStore.todos, ...) gives you a value. You have to somehow get a binding out of the todoStore instead.



Here's an example using the .indexed() extension on RandomAccessCollection (the extension was provided in the Xcode 11 Beta release notes, but has since been removed. In older Xcode 11 betas, you could just use ForEach()/List() to iterate and get Bindings out): https://github.com/rudedogg/swiftui-toggle-crash/blob/master/SwitchToggleCrash/ContentView.swift



I don't recommend doing it that way. It seems like you can get an index out of bounds error in some cases depending on how the View updates. If you look at https://developer.apple.com/tutorials/swiftui/handling-user-input#create-a-favorite-button-for-each-landmark

there is a slightly different pattern used, which seems safer (it fixes the crash I created that Github repo to demonstrate).



This still seems ugly to me, and I feel like there is a better way. Maybe someone can offer up a better pattern for this. I've seen a lot of people stuck on this issue, and it's a really common and basic use case. No blogs have covered it, so I don't know if that's because it is hard - or because it's easy and I'm missing some basic solution to the problem.



Hopefully this helps, it'll get your code running but I don't think it's a good answer. This is the biggest problem I'm having currently with SwiftUI, so I'd like a more elegant solution that doesn't feel like such a hack. I'd prefer my View not know about my @EnvironmentObject. It breaks all the modularity. But it's currently the only "safe" way I've found to get a binding out of a @Published array on an ObservableObject.



Sidenote to anyone at Apple: If the bug in that Github repo is actually a bug, let me know and I'll report it through feedback assistant. After thinking about it I decided it wasn't the SwitchToggleStyle()s fault it updates differently causing my index to go out of bounds - so I never reported it.

Apples Scrumdinger sample uses a function to generate a binding. In your case it would be like this:

Code Block
struct Todo: Identifiable {
     let id = UUID()
     var title: String
     var isDone = false
}
class TodoStore: ObservableObject {
    @Published var todos: [Todo] = [.init(title:"Test")]
}
struct ListRow: View {
     @Binding var todo: Todo
     var body: some View {
          Button(action: {
               self.todo.isDone.toggle()
          }) {
            Text("\(todo.title) \(todo.isDone.description)")
          }
     }
}
struct ContentView: View {
     @StateObject var todoStore = TodoStore()
     var body: some View {
          List(todoStore.todos) { todo in
               ListRow(todo: binding(for: todo))
          }
     }
// from Scrumdinger sample app
    private func binding(for todo: Todo) -> Binding<Todo> {
        guard let scrumIndex = todoStore.todos.firstIndex(where: { $0.id == todo.id }) else {
            fatalError("Can't find scrum in array")
        }
        return $todoStore.todos[scrumIndex]
    }
}

Post not yet marked as solved Up vote reply of malc Down vote reply of malc

Another way of doing it.

import SwiftUI
import Combine

struct ContentView: View {
    @ObservedObject var api = TodoAPI()

    var body: some View {
        VStack {
            List{
                Button(action: {
                    api.fetchTodos()
                }, label: {
                    Text("Get todos")
                })

                ForEach(0..<api.todos.count) { idx in
                    HStack {
                        Image(systemName: (api.todos[idx].isDone ? "checkmark.circle.fill" : "checkmark.circle"))
                        ToDoRowView(todo: $api.todos[idx])
                    }
                }
            }
        }
    }
}



struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}



struct ToDoRowView: View {
    @Binding var todo:Todo

    var body: some View {
        HStack {
            Text(todo.title)
                .font(.title3)
                .padding()

            Spacer()

            Button(action: {
                self.todo.isDone.toggle()
            }, label: {
                Text("DONE").padding(5).background(Color( colorLiteral(red: 0.721568644, green: 0.8862745166, blue: 0.5921568871, alpha: 1)))
            })
        }
    }
}

struct Todo: Identifiable {
    var id = UUID()
    var title: String
    var isDone = false
}

class TodoAPI: ObservableObject {
    @Published var todos:[Todo] = []

    init() {
        fetchTodos()
    }

    //fetch data from API
    func fetchTodos(){
        todos = [
            Todo(title: "Feed the dog"),
            Todo(title: "Drink 5 glass of water"),
            Todo(title: "Do the homework"),
            Todo(title: "Call grandma"),
            Todo(title: "Task - \(Int.random(in: 1...100))")
        ]
    }
}