How can I load state from en EnvironmentObject in SwiftUI? - ios

So I am working on a view where I want to load state from an EnvironmentObject which acts something like a database.
I would like to achieve something like this:
class MyDB: ObservableObject {
func getName(_ id: RecordID) -> String { ... }
func getChildren(_ id: RecordID) -> [RecordID] { ... }
var didUpdate: PassthroughSubject...
}
struct TreeView: View {
let id: RecordID
#EnvironmentObject var db: DB
#State var name: String
#State var children: [RecordID]
func loadState() {
self.name = db.getName(id)
self.children = db. getChildren(id)
}
var body: some View {
Text(self.name)
List(self.children) { child in
TreeView(id: child)
}
.onReceive(self.db.didUpdate) { _ in
self.loadState()
}
}
}
So basically I would like to just pass the id of the node in the tree view to the child view, and then load the state from this environment object with the loadState function before the view is displayed.
Is there any way to achieve this? For instance, is there some kind of lifecycle function I could implement which will be called after the environment is bound?
Or for example can I implement loadState inside a custom init?
What would be the idiomatic way to handle this?

I have provided an explanation here if you want to check it out.
You will need to pass your MyDB instance using .environmentObject(myDBInstance) on a parent view, so all children views can read from the environment through #EnvironmentObject.

Try using a different approach, such as the following code,
where children and name are published var of MyDB, and
the functions just load the data into those.
// for testing
struct RecordID: Identifiable {
let id = UUID().uuidString
var thing: String = ""
}
class MyDB: ObservableObject {
#Published var didUpdate: Bool = false
#Published var children: [RecordID] = []
#Published var name: String = ""
func getName(_ id: RecordID) {
// ...
name = "xxx" // whatever
}
func getChildren(_ id: RecordID) {
// ...
children = [RecordID(), RecordID()] // whatever
}
}
struct TreeView: View {
#EnvironmentObject var db: MyDB
#State var id: RecordID
var body: some View {
Text(db.name)
List(db.children) { child in
// TreeView(id: child) // <-- here recursive call, use OutlineGroup instead
Text("\(child.id)")
}
.onReceive(db.$didUpdate) { _ in
loadState()
}
}
func loadState() {
db.getName(id)
db.getChildren(id)
}
}
struct ContentView: View {
#StateObject var myDB = MyDB()
let recId = RecordID()
var body: some View {
TreeView(id: recId).environmentObject(myDB)
}
}

Related

SwiftUI: Issue with data binding for passing back the updated value to caller

I have 2 views where the
first view passes list of items and selected item in that to second view and
second view returns the updated selected item if user changes.
I am getting error 'Type of expression is ambiguous without more context' when i am sending the model property 'idx'.
//I cant make any changes to this model so cant confirm it with ObservableObject or put a bool property like 'isSelected'
class Model {
var idx: String?
....
}
class FirstViewModel: ObservableObject {
var list: [Model]
#Published var selectedModel: Model?
func getSecondViewModel() -> SecondViewModel {
let vm2 = SecondViewModel( //error >> Type of expression is ambiguous without more context
list: list,
selected: selectedModel?.idx // >> issue might be here but showing at above line
)
return vm2
}
}
struct FirstView: View {
#ObservableObject firstViewModel: FirstViewModel
var body: some View {
..
.sheet(isPresented: $showView2) {
NavigationView {
SecondView(viewModel: firstViewModel.getSecondViewModel())
}
}
..
}
}
class SecondViewModel: ObservableObject {
var list: [Model]
#Published var selected: String?
init(list: [Model], selected: Published<String?>) {
self.list = list
_selected = selected
}
func setSelected(idx: String) {
self.selected = idx
}
}
struct SecondView: View {
#ObservableObject secondViewModel: SecondViewModel
#Environment(\.presentationMode) var presentationMode
var body: some View {
...
.onTapGesture {
secondViewModel.setSelected(idx: selectedIndex)
presentationMode.wrappedValue.dismiss()
}
...
}
}
In case if I am sending 'Model' object directly to the SecondViewModel its working fine. I need to make changes the type and couple of other areas and instantiate the SecondViewModel as below
let vm2 = SecondViewModel(
list: list,
selected: _selectedModel
)
Since I need only idx I don't want to send entire model.
Also the reason for error might be but not sure the Model is #Published and the idx is not.
Any help is appreciated
Here is some code, in keeping with your original code that allows you to
use the secondViewModel as a nested model.
It passes firstViewModel to the SecondView, because
secondViewModel is contained in the firstViewModel. It also uses
firstViewModel.objectWillChange.send() to tell the model to update.
My comment is still valid, you need to create only one SecondViewModel that you use. Currently, your func getSecondViewModel() returns a new SecondViewModel every time you use it.
Re-structure your code so that you do not need to have nested ObservableObjects.
struct Model {
var idx = ""
}
struct ContentView: View {
#StateObject var firstMdl = FirstViewModel()
var body: some View {
VStack (spacing: 55){
FirstView(firstViewModel: firstMdl)
Text(firstMdl.secondViewModel.selected ?? "secondViewModel NO selected data")
}
}
}
class FirstViewModel: ObservableObject {
var list: [Model]
#Published var selectedModel: Model?
let secondViewModel: SecondViewModel // <-- here only one source of truth
// -- here
init() {
self.list = []
self.selectedModel = nil
self.secondViewModel = SecondViewModel(list: list, selected: nil)
}
// -- here
func getSecondViewModel() -> SecondViewModel {
secondViewModel.selected = selectedModel?.idx
return secondViewModel
}
}
class SecondViewModel: ObservableObject {
var list: [Model]
#Published var selected: String?
init(list: [Model], selected: String?) { // <-- here
self.list = list
self.selected = selected // <-- here
}
func setSelected(idx: String) {
selected = idx
}
}
struct FirstView: View {
#ObservedObject var firstViewModel: FirstViewModel // <-- here
#State var showView2 = false
var body: some View {
Button("click me", action: {showView2 = true}).padding(20).border(.green)
.sheet(isPresented: $showView2) {
SecondView(firstViewModel: firstViewModel)
}
}
}
struct SecondView: View {
#ObservedObject var firstViewModel: FirstViewModel // <-- here
#Environment(\.dismiss) var dismiss
#State var selectedIndex = "---> have some data now"
var body: some View {
Text("SecondView tap here to dismiss").padding(20).border(.red)
.onTapGesture {
firstViewModel.objectWillChange.send() // <-- here
firstViewModel.getSecondViewModel().setSelected(idx: selectedIndex) // <-- here
// alternatively
// firstViewModel.secondViewModel.selected = selectedIndex
dismiss()
}
}
}

SwiftUI Bind to #ObservableObject in array

How do I pass a bindable object into a view inside a ForEach loop?
Minimum reproducible code below.
class Person: Identifiable, ObservableObject {
let id: UUID = UUID()
#Published var healthy: Bool = true
}
class GroupOfPeople {
let people: [Person] = [Person(), Person(), Person()]
}
public struct GroupListView: View {
//MARK: Environment and StateObject properties
//MARK: State and Binding properties
//MARK: Other properties
let group: GroupOfPeople = GroupOfPeople()
//MARK: Body
public var body: some View {
ForEach(group.people) { person in
//ERROR: Cannot find '$person' in scope
PersonView(person: $person)
}
}
//MARK: Init
}
public struct PersonView: View {
//MARK: Environment and StateObject properties
//MARK: State and Binding properties
#Binding var person: Person
//MARK: Other properties
//MARK: Body
public var body: some View {
switch person.healthy {
case true:
Text("Healthy")
case false:
Text("Not Healthy")
}
}
//MARK: Init
init(person: Binding<Person>) {
self._person = person
}
}
The error I get is Cannot find '$person' in scope. I understand that the #Binding part of the variable is not in scope while the ForEach loop is executing. I'm looking for advice on a different pattern to accomplish #Binding objects to views in a List in SwiftUI.
The SwiftUI way would be something like this:
// struct instead of class
struct Person: Identifiable {
let id: UUID = UUID()
var healthy: Bool = true
}
// class publishing an array of Person
class GroupOfPeople: ObservableObject {
#Published var people: [Person] = [
Person(), Person(), Person()
]
}
struct GroupListView: View {
// instantiating the class
#StateObject var group: GroupOfPeople = GroupOfPeople()
var body: some View {
List {
// now you can use the $ init of ForEach
ForEach($group.people) { $person in
PersonView(person: $person)
}
}
}
}
struct PersonView: View {
#Binding var person: Person
var body: some View {
HStack {
// ternary instead of switch
Text(person.healthy ? "Healthy" : "Not Healthy")
Spacer()
// Button to change, so Binding makes some sense :)
Button("change") {
person.healthy.toggle()
}
}
}
}
You don't need Binding. You need ObservedObject.
for anyone still wondering... it looks like this has been added
.onContinuousHover(perform: { phase in
switch phase {
case .active(let location):
print(location.x)
case .ended:
print("ended")
}
})

SwiftUI - Should you use `#State var` or `let` in child view when using ForEach

I think I've a gap in understanding what exactly #State means, especially when it comes to displaying contents from a ForEach loop.
My scenario: I've created minimum reproducible example. Below is a parent view with a ForEach loop. Each child view has aNavigationLink.
// Parent code which passes a Course instance down to the child view - i.e. CourseView
struct ContentView: View {
#StateObject private var viewModel: ViewModel = .init()
var body: some View {
NavigationView {
VStack {
ForEach(viewModel.courses) { course in
NavigationLink(course.name + " by " + course.instructor) {
CourseView(course: course, viewModel: viewModel)
}
}
}
}
}
}
class ViewModel: ObservableObject {
#Published var courses: [Course] = [
Course(name: "CS101", instructor: "John"),
Course(name: "NS404", instructor: "Daisy")
]
}
struct Course: Identifiable {
var id: String = UUID().uuidString
var name: String
var instructor: String
}
Actual Dilemma: I've tried two variations for the CourseView, one with let constant and another with a #State var for the course field. Additional comments in the code below.
The one with the let constant successfully updates the child view when the navigation link is open. However, the one with #State var doesn't update the view.
struct CourseView: View {
// Case 1: Using let constant (works as expected)
let course: Course
// Case 2: Using #State var (doesn't update the UI)
// #State var course: Course
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
Text("\(course.name) by \(course.instructor)")
Button("Edit Instructor", action: editInstructor)
}
}
// Case 1: It works and UI gets updated
// Case 2: Doesn't work as is.
// I've to directly update the #State var instead of updating the clone -
// which sometimes doesn't update the var in my actual project
// (that I'm trying to reproduce). It definitely works here though.
private func editInstructor() {
let instructor = course.instructor == "Bob" ? "John" : "Bob"
var course = course
course.instructor = instructor
save(course)
}
// Simulating a database save, akin to something like GRDB
// Here, I'm just updating the array to see if ForEach picks up the changes
private func save(_ courseToSave: Course) {
guard let index = viewModel.courses.firstIndex(where: { $0.id == course.id }) else {
return
}
viewModel.courses[index] = courseToSave
}
}
What I'm looking for is the best practice for a scenario where looping through an array of models is required and the model is updated in DB from within the child view.
Here is a right way for you, do not forget that we do not need put logic in View! the view should be dummy as possible!
struct ContentView: View {
#StateObject private var viewModel: ViewModel = ViewModel.shared
var body: some View {
NavigationView {
VStack {
ForEach(viewModel.courses) { course in
NavigationLink(course.name + " by " + course.instructor, destination: CourseView(course: course, viewModel: viewModel))
}
}
}
}
}
struct CourseView: View {
let course: Course
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
Text("\(course.name) by \(course.instructor)")
Button("Update Instructor", action: { viewModel.update(course) })
}
}
}
class ViewModel: ObservableObject {
static let shared: ViewModel = ViewModel()
#Published var courses: [Course] = [
Course(name: "CS101", instructor: "John"),
Course(name: "NS404", instructor: "Daisy")
]
func update(_ course: Course) {
guard let index = courses.firstIndex(where: { $0.id == course.id }) else {
return
}
courses[index] = Course(name: course.name, instructor: (course.instructor == "Bob") ? "John" : "Bob")
}
}
struct Course: Identifiable {
let id: String = UUID().uuidString
var name: String
var instructor: String
}

Why published Array does not change after I change him in SwiftUI?

So, this is just a sample code from my project. After I call SecondView, I want to change names in the array to "LoL", and display them. Why init() does not change my array? Since it does not display new names
struct Person: Identifiable {
let id = UUID()
var name: String
var index: Int
}
class User: ObservableObject {
#Published var array = [Person(name: "Nick", index: 0),
Person(name: "John", index: 1)
]
}
struct ContentView: View {
#ObservedObject var user = User()
var body: some View {
VStack {
ForEach (user.array) { row in
SecondView(name: row.name, index: row.index)
}
}
}
}
struct SecondView: View {
#ObservedObject var user = User()
var name = ""
var index = 0
init() {
user.array[index].name = "LoL"
}
init(name: String, index: Int) {
self.name = name
self.index = index
}
var body: some View {
VStack {
Text(name)
}
}
}
It is because you are not calling init() method in SecondView but you are calling init(name:, index:). Notice how you use SecondView initializer inside your FirstView's iteration (ie. ForEach) loop.
Also your second view displays the name that is passed along the initializer init(name:index:), not the one from your user array. So, if you want to change name to something, do that before this init(name:index:) is called, and pass the name from user array.
You can do it inside your first view.
struct ContentView: View {
#ObservedObject var user = User()
init() {
user.array[0].name = "LoL"
}
var body: some View {
VStack {
ForEach (user.array) { row in
SecondView(name: row.name, index: row.index)
}
}
}
}
Notice that it will now change the first name to Lol because we change it inside ContentView's initializer which then uses the same modified name.

How to use BindableObjects (EnviromentObject)?

Im using the new SwiftUI. I have a UserUpdate class which is a Bindable Object and I want to modify these variables and automatically Update the UI.
I update these Values successfully but the views in my UI struct isn't updating when I change the variable in the UserUpdate class.
It only changes when I modify the #EnviromentObject variable in the UI struct itself.
That's my Bindable Object Class:
final class UserUpdate: BindableObject {
let didChange = PassthroughSubject<Any, Never>()
var allUsers: [User] = [] {
didSet {
print(allUsers)
didChange.send(allUsers)
}
}
var firstName: String = "" {
didSet {
didChange.send(firstName)
}
}
var lastName: String = "" {
didSet {
didChange.send(lastName)
}
}
}
That's my User class:
struct User: Identifiable {
let id: Int
let firstName, lastName: String
}
Here's how I configure my UI:
struct ContentView : View {
#EnvironmentObject var bindableUser: UserUpdate
var body: some View {
NavigationView {
VStack(alignment: .leading) {
Text("All Users:").bold().padding(.leading, 10)
List {
ForEach(bindableUser.allUsers) { user in
Text("\(user.firstName) \(user.lastName)")
}
}
}
}
}
}
Here I modify the variables in UserUpdate:
class TestBind {
static let instance = TestBind()
let userUpdate = UserUpdate()
func bind() {
let user = User(id: userUpdate.allUsers.count, firstName: "Heyy", lastName: "worked")
userUpdate.allUsers.append(user)
}
}
I found out that I had to call the method from my UI to get it working so its on the same stream.
For example by this:
struct ContentView : View {
#EnvironmentObject var networkManager: NetworkManager
var body: some View {
VStack {
Button(action: {
self.networkManager.getAllCourses()
}, label: {
Text("Get All Courses")
})
List(networkManager.courses.identified(by: \.name)) {
Text($0.name)
}
}
}
}
If I'm not wrong, you should inject the UserUpdate instance in your ContentView, probably in the SceneDelegate, using ContentView().environmentObject(UserUpdate()).
In that case, you have 2 different instance of the UserUpdate class, the first one created in the SceneDelegate, and the second created in the TestBind class.
The problem is that you have one instance that is bond to view (and will trigger view reload on update), and the one that you actually modify (in TestBind class) which is totally unrelated to the view.
You should find a way to use the same instance in the view and in the TestBind class (for example by using ContentView().environmentObject(TestBind.instance.userUpdate)

Resources