16

I'm trying to learn SwiftUI, and how bindings work.

I have this code that works, that shows a list of projects. When one project is tapped, a binding to that project is passed to the child view:

struct ProjectsView: View {
  @ObjectBinding var state: AppState
  @State var projectName: String = ""

  var body: some View {
    NavigationView {
      List {
        ForEach(0..<state.projects.count) { index in
          NavigationLink(destination: ProjectView(project: self.$state.projects[index])) {
            Text(self.state.projects[index].title)
          }
        }
      }
      .navigationBarTitle("Projects")
    }
  }
}

The child view, where I'm mutating the project using a binding:

struct ProjectView: View {
  @Binding var project: Project
  @State var projectName: String = ""

  var body: some View {
    VStack {
      Text(project.title)
      TextField(
        $projectName,
        placeholder: Text("Change project name"),
        onCommit: {
          self.project.title = self.projectName
          self.projectName = ""
      })
      .padding()
    }
  }
}

However, I would rather iterate over the projects array without using indexes (beacuse I want to learn, and its easier to read), but not sure how I then can pass the binding to a single project. I tried it like this, but then I can't get access to project.title, since it's a binding, and not a String.

ForEach($state.projects) { project in
  NavigationLink(destination: ProjectView(project: project)) {
    Text(project.title)
  }
}

How can I achieve this?

3
  • I solved it by using CurrentValueSubject passed to the Child instead of Binding but maybe it is overly complicated... Commented Sep 3, 2019 at 15:05
  • 1
    Is Project identifiable? Can you provide declaration of AppState, or at least projects property? Commented Nov 5, 2019 at 5:50
  • Project is Identifiable, yes. Commented Nov 5, 2019 at 9:31

4 Answers 4

3

Note: I use Xcode 11.2, @ObjectBinding is obsoleted in it (so you need to update to verify below code).

I asked about model, because it might matter for approach. For similar functionality I preferred Combine's ObservableObject, so model is reference not value types.

Here is the approach, which I tune for your scenario. It is not exactly as you requested, because ForEach requires some sequence, but you try to feed it with unsupported type.

Anyway you may consider below just as alternate (and it is w/o indexes). It is complete module so you can paste it in Xcode 11.2 and test in preview. Hope it would be helpful somehow.

Preview:

simple iteration preview

Solution:

import SwiftUI
import Combine

class Project: ObservableObject, Identifiable {
    var id: String = UUID().uuidString
    @Published var title: String = ""

    init (title: String) {
        self.title = title
    }
}

class AppState: ObservableObject {
    @Published var projects: [Project] = []
    init(_ projects: [Project]) {
        self.projects = projects
    }
}

struct ProjectView: View {
  @ObservedObject var project: Project
  @State var projectName: String = ""

  var body: some View {
    VStack {
      Text(project.title)
      TextField("Change project name",
        text: $projectName,
        onCommit: {
          self.project.title = self.projectName
          self.projectName = ""
      })
      .padding()
    }
  }
}

struct ContentView: View {
    @ObservedObject var state: AppState = AppState([Project(title: "1"), Project(title: "2")])
    @State private var refreshed = false

    var body: some View {
        NavigationView {
            List {
                ForEach(state.projects) { project in
                  NavigationLink(destination: ProjectView(project: project)) {
                    // !!!  existance of .refreshed state property somewhere in ViewBuilder
                    //      is important to inavidate view, so below is just a demo
                    Text("Named: \(self.refreshed ? project.title : project.title)")
                  }
                  .onReceive(project.$title) { _ in
                        self.refreshed.toggle()
                    }
                }
            }
            .navigationBarTitle("Projects")
            .navigationBarItems(trailing: Button(action: {
                self.state.projects.append(Project(title: "Unknown"))
            }) {
                Text("New")
            })
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Sign up to request clarification or add additional context in comments.

Comments

2

I'm sort of stuck on the same issue you are, and I found a partial solution. But first I should point out that iterating over the index with ForEach(0..<state.projects.count) { index in ... } is not a good idea because index is an Int, which does not conform to Identifiable. Because of that, the UI will not update when your array changes, and you'll see a warning in the console.

My solution directly accesses the state.projects array when creating ProjectView and using firstIndex(of:) to get a bindable form of the project element. It's kind of icky but it's as far as I could get to making it more SwiftUI-y.

ForEach(state.projects) { project in
  NavigationLink(destination: ProjectView(project: self.$state.projects[self.state.projects.firstIndex(of: project)!]))) {
    Text(project.title)
  }
}

Comments

1

I've found this works: In your AppState, when you add a project, observe its changes:

import Combine

class AppState: ObservableObject {
  @Published var projects: [Project]
  var futures = Set<AnyCancellable>()

  func addProject(project: Project) {
    project.objectWillChange
      .sink {_ in
        self.objectWillChange.send()
      }
     .store(in: &futures)
  }

  ...
}

If you ever need to create a binding for a project var in your outer view, do it like this:

func titleBinding(forProject project: Project) -> Binding<String> {
    Binding {
        project.title
    } set: { newValue in
        project.title = newValue
    }
}

You shouldn't need it if you are passing the project into another view, though.

Comments

0

Unfortunately this doesn't seem to be possible at this time. The only way to achieve this is like the following:

ForEach(state.projects.indices) { index in
    NavigationLink(destination: ProjectView(project: state.projects[index])) {
        Text(state.projects[index].title)
    }
} 

NOTE: I didn't compile this code. This is just to give you the gesture for how to go about it. i.e. use and index of type Index and not Int.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.