I might be misunderstanding a couple of key concepts, but not seeing how to properly handle view bindings and retain proper MVVM structure with SwiftUI.
Let's take this example of two fields that affect the text above them:
struct ContentView: View {
#State var firstName = "John"
#State var lastName = "Smith"
var body: some View {
VStack {
Text("first name: \(firstName)")
Text("last name: \(lastName)")
ChangeMeView(firstName: $firstName, lastName: $lastName)
}
}
}
struct ChangeMeView: View {
#Binding var firstName: String
#Binding var lastName: String
var body: some View {
VStack {
TextField("first name", text: $firstName)
TextField("last name", text: $lastName)
}
}
}
Works as expected. However, if I wanted to follow MVVM, wouldn't I need to move (firstName, lastName) to a ViewModel object within that view?
That means that the view starts looking like this:
struct ContentView: View {
#State var firstName = "John"
#State var lastName = "Smith"
var body: some View {
VStack {
Text("first name: \(firstName)")
Text("last name: \(lastName)")
ChangeMeView(firstName: $firstName, lastName: $lastName)
}
}
}
struct ChangeMeView: View {
// #Binding var firstName: String
// #Binding var lastName: String
#StateObject var viewModel: ViewModel
init(firstName: Binding<String>, lastName: Binding<String>) {
//from https://stackoverflow.com/questions/62635914/initialize-stateobject-with-a-parameter-in-swiftui#62636048
_viewModel = StateObject(wrappedValue: ViewModel(firstName: firstName, lastName: lastName))
}
var body: some View {
VStack {
TextField("first name", text: viewModel.firstName)
TextField("last name", text: viewModel.lastName)
}
}
}
class ViewModel: ObservableObject {
var firstName: Binding<String>
var lastName: Binding<String>
init(firstName: Binding<String>, lastName: Binding<String>) {
self.firstName = firstName
self.lastName = lastName
}
}
This works but feels to me like it might be hacky. Is there another smarter way to pass data (like bindings) to a view while retaining MVVM?
Here's an example where I try using #Published. While it runs, the changes don't update the text:
struct ContentView: View {
var firstName = "John"
var lastName = "Smith"
var body: some View {
VStack {
Text("first name: \(firstName)")
Text("last name: \(lastName)")
ChangeMeView(viewModel: ViewModel(firstName: firstName, lastName: lastName))
}
}
}
struct ChangeMeView: View {
#ObservedObject var viewModel: ViewModel
init(viewModel: ViewModel) {
self.viewModel = viewModel
//apple approved strategy from https://stackoverflow.com/questions/62635914/initialize-stateobject-with-a-parameter-in-swiftui#62636048
// _viewModel = StateObject(wrappedValue: ViewModel(firstName: firstName, lastName: lastName))
}
var body: some View {
VStack {
TextField("first name", text: $viewModel.firstName)
TextField("last name", text: $viewModel.lastName)
}
}
}
class ViewModel: ObservableObject {
#Published var firstName: String
#Published var lastName: String
init(firstName: String, lastName: String) {
self.firstName = firstName
self.lastName = lastName
}
}
You are missing the model part of MVVM. In a simple example, as you have here, you probably don't need full MVVM - You can just share a view model between your two views.
However, here is how you can use a model and a view model. The model and view model classes first:
The model just declares two #Published properties and is an #ObservableObject
Model
class Model: ObservableObject {
#Published var firstName: String = ""
#Published var lastName: String = ""
}
The ContentViewModel is initialised with the Model instance and simply exposes the two properties of the model via computed properties.
ContentViewModel
class ContentViewModel {
let model: Model
var firstName:String {
return model.firstName
}
var lastName:String {
return model.lastName
}
init(model: Model) {
self.model = model
}
}
ChangeMeViewModel is a little more complex - It needs to both expose the current values from the Model but also update the values in the Model when the values are set in the ChangeMeViewModel. To make this happen we use a custom Binding. The get methods are much the same as the ContentViewModel - They just accesses the properties from the Model instance. The set method takes the new value that has been assigned to the Binding and updates the properties in the Model
ChangeMeViewModel
class ChangeMeViewModel {
let model: Model
var firstName: Binding<String>
var lastName: Binding<String>
init(model: Model) {
self.model = model
self.firstName = Binding(
get: {
return model.firstName
},
set: { newValue in
model.firstName = newValue
}
)
self.lastName = Binding(
get: {
return model.lastName
},
set: { newValue in
model.lastName = newValue
}
)
}
}
Finally we need to create the Model in the App file and use it with the view models in the view hierarchy:
App
#main
struct MVVMApp: App {
#StateObject var model = Model()
var body: some Scene {
WindowGroup {
ContentView(viewModel: ContentViewModel(model: model))
}
}
}
ContentView
struct ContentView: View {
let viewModel: ContentViewModel
var body: some View {
VStack {
Text("first name: \(self.viewModel.firstName)")
Text("last name: \(self.viewModel.lastName)")
ChangeMeView(viewModel:ChangeMeViewModel(model:self.viewModel.model))
}
}
}
ChangeMeView
struct ChangeMeView: View {
let viewModel: ChangeMeViewModel
var body: some View {
VStack {
TextField("first name", text: self.viewModel.firstName)
TextField("last name", text: self.viewModel.lastName)
}
}
}
Related
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
}
import SwiftUI
struct TestStudentView: View {
#StateObject var students = Students()
#State private var name = ""
#State private var numberOfSubjects = ""
#State private var subjects = [Subjects](repeating: Subjects(name: "", grade: ""), count: 10)
var body: some View {
NavigationView {
Group {
Form {
Section(header: Text("Student details")) {
TextField("Name", text: $name)
TextField("Number of subjects", text: $numberOfSubjects)
}
let count = Int(numberOfSubjects) ?? 0
Text("Count: \(count)")
Section(header: Text("Subject grades")) {
if count>0 && count<10 {
ForEach(0 ..< count, id: \.self) { number in
TextField("Subjects", text: $subjects[number].name)
TextField("Grade", text: $subjects[number].grade)
}
}
}
}
VStack {
ForEach(students.details) { student in
Text(student.name)
ForEach(student.subjects) { subject in //Does not work as expected
//ForEach(student.subjects, id:\.id) { subject in //Does not work as expected
//ForEach(student.subjects, id:\.self) { subject in //works fine with this
HStack {
Text("Subject: \(subject.name)")
Text("Grade: \(subject.grade)")
}
}
}
}
}
.navigationTitle("Student grades")
.navigationBarItems(trailing:
Button(action: {
let details = Details(name: name, subjects: subjects)
students.details.append(details)
}, label: {
Text("Save")
})
)
}
}
}
struct TestStudentView_Previews: PreviewProvider {
static var previews: some View {
TestStudentView()
}
}
class Students: ObservableObject {
#Published var details = [Details]()
}
struct Details: Identifiable {
let id = UUID()
var name: String
var subjects: [Subjects]
}
struct Subjects: Identifiable, Hashable {
let id = UUID()
var name: String
var grade: String
}
When I use - "ForEach(student.subjects, id:.id) { subject in" under normal circumstances it is supposed to work as id = UUID and the incorrect output is as follows:
then as the class conforms to Identifiable I tried - "ForEach(student.subjects) { subject in" it still does not work correctly. However, when I do - "ForEach(student.subjects, id:.self) { subject in" except I had to have the class conform to hashable and gives me the correct expected output. The correct output which is shown:
You need to use a map instead of repeating.
By using Array.init(repeating:) will invoke the Subjects to initialize only one time, and then insert that object into the array multiple times.
So all, in this case, all id is same.
You can check by just print all id in by this .onAppear() { print(subjects.map({ (sub) in print(sub.id) }))
struct TestStudentView: View {
#StateObject var students = Students()
#State private var name = ""
#State private var numberOfSubjects = ""
#State private var subjects: [Subjects] = (0...10).map { _ in
Subjects(name: "", grade: "")
} //<-- Here
I have problems updating my nested SwiftUI views with #Binding property.
I declared a DataModel with ObservableObject protocol:
class DataModel: ObservableObject {
#Published var subjects: [Subject] = []
...
}
I added it to my main app:
#main
struct LessonToTextApp: App {
#ObservedObject private var data = DataModel()
var body: some Scene {
WindowGroup {
NavigationView {
SubjectsView(subjects: $data.subjects) {
data.save()
}
}
.onAppear {
data.load()
}
}
}
}
I passed the Subjects array to the first view
struct SubjectsView: View {
#Binding var subjects: [Subject]
var body: some View {
List {
if subjects.isEmpty {
Text("subjects.empty")
} else {
ForEach(subjects) { subject in
NavigationLink(destination: DetailView(subject: binding(for: subject), saveAction: saveAction)) {
CardView(subject: subject)
}
.listRowBackground(subject.color)
.cornerRadius(10)
}
}
}
private func binding(for subject: Subject) -> Binding<Subject> {
guard let subIndex = subjects.firstIndex(where: { $0.id == subject.id }) else {
fatalError("Can't find subject in array")
}
return $subjects[subIndex]
}
And then i passed the single subject to the Second view using the function binding declared above:
struct DetailView: View {
#Binding var subject: Subject
var body: some View {
ForEach(subject.lessons) { lesson in
NavigationLink(destination: LessonView(lesson: lesson)) {
Text(lesson.date, style: .date)
}
}
.onDelete { indexSet in
self.subject.lessons.remove(atOffsets: indexSet)
}
}
In the DetailView, when i delete an item in ForEach the item still appear, the view doesn't update.
I'm using SwiftUI 2.0 on Xcode 12.3 (12C33)
EDIT
This is the Model:
struct Subject: Identifiable, Codable {
let id: UUID
var name: String
var teacher: String
var color: Color
var lessons: [Lesson]
}
struct Lesson: Identifiable, Codable {
let id: UUID
let date: Date
var lenghtInMinutes: Int
var transcript: String
}
Your SubjectsView should take in the entire DataModel as an #EnvironmentObject
struct LessonToTextApp: App {
#StateObject private var data = DataModel()
var body: some Scene {
WindowGroup {
NavigationView {
SubjectsView().environmentObject(data) {
data.save()
}
...
struct SubjectsView: View {
#EnvironmentObject var data = DataModel
var body: some View {
List {
if data.subjects.isEmpty {
Text("subjects.empty")
...
Also, struct are immutable
class Subject: Identifiable, Codable, ObservableObject {
let id: UUID
#Published var name: String
#Published var teacher: String
#Published var color: Color
...
}
struct DetailView: View {
#ObservedObject var subject: Subject
var body: some View {
...
That way you can get to the DetailView with
DetailView(subject: subject)
I have a simple use case of having a VStack of a dynamic number of Text with Toggle buttons coming from an array.
import SwiftUI
public struct Test: View {
#ObservedObject public var viewModel = TestViewModel()
public init() {
}
public var body: some View {
VStack {
ForEach(viewModel.models) { model in
ToggleView(title: <#T##Binding<String>#>, switchState: <#T##Binding<Bool>#>)
//how to add the above
}
}.padding(50)
}
}
struct ToggleView: View {
#Binding var title: String
#Binding var switchState: Bool
var body: some View {
VStack {
Toggle(isOn: $switchState) {
Text(title)
}
}
}
}
public class TestModel: Identifiable {
#Published var state: Bool {
didSet {
//do something
//publish to the view that the state has changed
}
}
#Published var title: String
init(state: Bool, title: String) {
self.state = state
self.title = title
}
}
public class TestViewModel: ObservableObject {
#Published var models: [TestModel] = [TestModel(state: false, title: "Title 1"), TestModel(state: true, title: "Title 2")]
}
The following questions arise:
In MVVM pattern, is it ok to have the binding variables in model class or should it be inside the view model?
How to send the message of state change from model class to view/scene when the toggle state is changed?
If using an array of binding variables in view model for each of the toggle states, how to know which particular element of array has changed? (see the following code snippet)
class ViewModel {
#Published var dataModel: [TestModel]
#Published var toggleStates = [Bool]() {
didSet {
//do something based on which element of the toggle states array has changed
}
}
}
Please help with the above questions.
here is one way you could achieve what you desire.
Has you will notice you have to use the binding power of #ObservedObject.
The trick is to use indexes to reach the array elements for you binding.
If you loop on the array elements model directly you loose the underlying binding properties.
struct Test: View {
#ObservedObject public var viewModel = TestViewModel()
var body: some View {
VStack {
ForEach(viewModel.models.indices) { index in
ToggleView(title: self.viewModel.models[index].title, switchState: self.$viewModel.models[index].state)
}
}.padding(50)
}
}
class TestViewModel: ObservableObject {
#Published var models: [TestModel] = [
TestModel(state: false, title: "Title 1"),
TestModel(state: true, title: "Title 2")]
}
struct ToggleView: View {
var title: String
#Binding var switchState: Bool
var body: some View {
VStack {
Toggle(isOn: $switchState) {
Text(title)
}
}
}
}
class TestModel: Identifiable {
var state: Bool
var title: String
init(state: Bool, title: String) {
self.title = title
self.state = state
}
}
Hope this does the trick for you.
Best
I'm making an app where the user can create groups, and fill these groups with people. Groups are showed in a list and link to GroupViews, where there is a list of people part of that particular group.
The list of and creation of groups work as expected; the user can create groups and clicking any group takes the user to that specific groups own view.
The list of and creation of people don't work; when the user attempts to append a person to the people list it falls between the AddPersonView and GroupView and does not show up in the list.
Below is my current attempt at a solution:
Models.swift
import Foundation
struct Group: Identifiable {
var id: UUID
var name: String
var people: [Person]
init(name: String) {
self.id = UUID()
self.name = name
self.people = [Person]()
}
}
struct Person: Identifiable {
var id: UUID
var firstName: String
var lastName: String
}
ModelView.swift
import Foundation
class GroupList: ObservableObject {
#Published var groups = [Group]()
}
ContentView.swift
import SwiftUI
struct ContentView: View {
#ObservedObject var groupList: GroupList
#State private var showingAddGroupView = false
var body: some View {
NavigationView {
List(groupList.groups) { group in
NavigationLink(destination: GroupView(group: group.people)) {
Text(group.name)
}
}
.navigationBarItems(trailing:
Button(action: {
self.showingAddGroupView.toggle()
}) {
Text("Add group")
})
}
.sheet(isPresented: $showingAddGroupView) {
AddGroupView(groupList: self.groupList)
}
}
}
AddGroupView.swift
import SwiftUI
struct AddGroupView: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var groupList: GroupList
#State private var name = ""
var body: some View {
VStack {
TextField("Name", text: self.$name)
Button(action: {
self.groupList.groups.append(Group(name: self.name))
self.presentationMode.wrappedValue.dismiss()
}) {
Text("OK")
}
}
}
}
GroupView.swift
import SwiftUI
struct GroupView: View {
var group: [Person]
#State private var showingAddPersonView = false
var body: some View {
NavigationView {
VStack {
List(group) { person in
NavigationLink(destination: Text(person.firstName)) {
Text("\(person.firstName) \(person.lastName)")
}
}
.sheet(isPresented: $showingAddPersonView) {
AddPersonView(group: self.group)
}
}
.navigationBarItems(trailing:
Button(action: {
self.showingAddPersonView.toggle()
}) {
Text("Add person")
})
}
}
}
AddPersonView.swift
import SwiftUI
struct AddPersonView: View {
#Environment(\.presentationMode) var presentationMode
#State var group: [Person]
#State private var firstName = ""
#State private var lastName = ""
var body: some View {
VStack {
TextField("First name", text: self.$firstName)
TextField("Last name", text: self.$lastName)
Button(action: {
self.group.append(Person(id: UUID(), firstName: self.firstName, lastName: self.lastName))
self.presentationMode.wrappedValue.dismiss()
}) {
Text("OK")
}
}
}
}
check this out:
the problem is exactly there what Lou said - structs will be copied. you must change and work on your observable object - not on copies.
import SwiftUI
import Foundation
struct Group: Identifiable {
var id: UUID
var name: String
var people: [Person]
init(name: String) {
self.id = UUID()
self.name = name
self.people = [Person]()
}
}
struct Person: Identifiable {
var id: UUID
var firstName: String
var lastName: String
}
class GroupList: ObservableObject {
#Published var groups = [Group]()
func getGroupBy(id: UUID) -> Group? {
let result = groups.filter { $0.id == id }
if result.count == 1 {
return result[0]
}
return nil
}
func getGroupIndex(id: UUID) -> Int? {
return groups.firstIndex { $0.id == id }
}
}
struct ContentView: View {
#EnvironmentObject var groupList: GroupList
#State private var showingAddGroupView = false
var body: some View {
NavigationView {
List(self.groupList.groups) { group in
NavigationLink(destination: GroupView(group: group).environmentObject(self.groupList)) {
Text(group.name)
}
}
.navigationBarItems(trailing:
Button(action: {
self.showingAddGroupView.toggle()
}) {
Text("Add group")
})
}
.sheet(isPresented: $showingAddGroupView) {
AddGroupView().environmentObject(self.groupList)
}
}
}
struct AddGroupView: View {
#Environment(\.presentationMode) var presentationMode
#EnvironmentObject var groupList: GroupList
#State private var name = ""
var body: some View {
VStack {
TextField("Name", text: self.$name)
Button(action: {
self.groupList.groups.append(Group(name: self.name))
self.presentationMode.wrappedValue.dismiss()
}) {
Text("OK")
}
}
}
}
struct GroupView: View {
#EnvironmentObject var groupList: GroupList
var group: Group
#State private var showingAddPersonView = false
var body: some View {
NavigationView {
VStack {
List(self.group.people) { person in
NavigationLink(destination: Text(person.firstName)) {
Text("\(person.firstName) \(person.lastName)")
}
}
.sheet(isPresented: $showingAddPersonView) {
AddPersonView(group: self.group).environmentObject(self.groupList)
}
}
.navigationBarItems(trailing:
Button(action: {
self.showingAddPersonView.toggle()
}) {
Text("Add person")
})
}
}
}
struct AddPersonView: View {
#Environment(\.presentationMode) var presentationMode
#EnvironmentObject var groupList : GroupList
#State var group: Group
#State private var firstName = ""
#State private var lastName = ""
var body: some View {
VStack {
TextField("First name", text: self.$firstName)
TextField("Last name", text: self.$lastName)
Button(action: {
if let index = self.groupList.getGroupIndex(id: self.group.id) {
self.groupList.groups[index].people.append(Person(id: UUID(), firstName: self.firstName, lastName: self.lastName))
self.group = self.groupList.groups[index]
}
self.presentationMode.wrappedValue.dismiss()
}) {
Text("OK")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(GroupList())
}
}
The issue is in GroupView, with this line
var group: [Person]
When you call AddPersonView, you are sending a copy of the array and the view appends a person to that copy and then it is lost when the view dismisses.
You must pass something that is shared. Probably this should all be in an ObservableObject and not local view variables.
Pass down the GroupList object or bindings to its internals