SwiftUI Navigation View goes back in hierarchy - swift

I have a problem with Navigation View hierarchy.
All screens in my app use the same ViewModel.
When a screen inside navigation link updates the ViewModel (here it is called DataManager), the navigation view automatically goes back to the first screen, as if the "Back" button was pressed.
Here's what it looks like
I tried to shrink my code as much as I could
struct DataModel: Identifiable, Codable {
var name: String
var isPinned: Bool = false
var id: String = UUID().uuidString
}
class DataManager: ObservableObject {
#Published private(set) var allModules: [DataModel]
var pinnedModules: [DataModel] {
allModules.filter { $0.isPinned }
}
var otherModules: [DataModel] {
allModules.filter { !$0.isPinned }
}
func pinModule(id: String) {
if let moduleIndex = allModules.firstIndex(where: { $0.id == id }) {
allModules[moduleIndex].isPinned = true
}
}
func unpinModule(id: String) {
if let moduleIndex = allModules.firstIndex(where: { $0.id == id }) {
allModules[moduleIndex].isPinned = false
}
}
static let instance = DataManager()
fileprivate init() {
allModules =
[DataModel(name: "One"),
DataModel(name: "Two"),
DataModel(name: "Three"),
DataModel(name: "Four"),
DataModel(name: "Five")]
}
}
struct ModulesList: View {
#StateObject private var dataStorage = DataManager.instance
var body: some View {
NavigationView {
List {
Section("Pinned") {
ForEach(dataStorage.pinnedModules) { module in
ModulesListCell(module: module)
}
}
Section("Other") {
ForEach(dataStorage.otherModules) { module in
ModulesListCell(module: module)
}
}
}
}
}
fileprivate struct ModulesListCell: View {
let module: DataModel
var body: some View {
NavigationLink {
SingleModuleScreen(module: module)
} label: {
Text(module.name)
}
}
}
}
struct SingleModuleScreen: View {
#State var module: DataModel
#StateObject var dataStorage = DataManager.instance
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(module.name)
.font(.title)
Button {
dataStorage.pinModule(id: module.id)
} label: {
Text("Pin")
}
}
}
}
}

I can guess because when your dataStorage changed, the ModulesList will be redrawn, that cause all current ModulesListCell removed from memory.
Your cells are NavigationLink and when it destroyed, the navigation stack doesn't keep the screen that's being linked.
I would recommend to watch this wwdc https://developer.apple.com/videos/play/wwdc2021/10022/ and you will know how to manage your view identity properly when your data's changed.

When you tap Pin, your otherModules array is recreated and you do not have the view in Navigation Stack from where you navigated. Thus you are going back automatically, which is desired behaviour. So the solution is Don't destroy your array from where your NavigationLink is created. Make a temporary published array, load Other modules from that array and change the array onAppear like below:
As a workaround which is working in my end:
Add this line in DataManger:
#Published var tempOtherModules:[DataModel] = []
Change your ModulesList like below
struct ModulesList: View {
#StateObject private var dataStorage = DataManager.instance
var body: some View {
NavigationView {
List {
Section("Pinned") {
ForEach(dataStorage.pinnedModules) { module in
ModulesListCell(module: module)
}
}
Section("Other") {
ForEach(dataStorage.tempOtherModules) { module in
ModulesListCell(module: module)
}
}
}.onAppear {
dataStorage.tempOtherModules = dataStorage.otherModules
}
}
}
}

Related

SwiftUI: #Environment(\.presentationMode)'s dismiss not working in iOS14

I have a view that shows a sheet for filtering the items in a list. The view has this var:
struct JobsTab: View {
#State private var jobFilter: JobFilter = JobFilter()
var filter: some View {
Button {
self.showFilter = true
} label: {
Image(systemName: "line.horizontal.3.decrease.circle")
.renderingMode(.original)
}
.sheet(isPresented: $showFilter) {
FilterView($jobFilter, categoriesViewModel, jobsViewModel)
}
}
However, in the sheet, I'm trying the following and I can't make the view dismissed when clicking on the DONE button, only on the CANCEL button:
struct FilterView: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var categoriesViewModel: CategoriesViewModel
#ObservedObject var jobsViewModel: JobsViewModel
let filterViewModel: FilterViewModel
#Binding var jobFilter: JobFilter
#State private var draft: JobFilter
#State var searchText = ""
init(_ jobFilter: Binding<JobFilter>, _ categoriesViewModel: CategoriesViewModel, _ jobsViewModel: JobsViewModel) {
_jobFilter = jobFilter
_draft = State(wrappedValue: jobFilter.wrappedValue)
self.categoriesViewModel = categoriesViewModel
self.jobsViewModel = jobsViewModel
self.filterViewModel = FilterViewModel()
}
...
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("FilterView.Button.Cancel.Text".capitalizedLocalization) {
presentationMode.wrappedValue.dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("FilterView.Button.Done.Text".capitalizedLocalization) {
let request = Job.defaultRequest()
request.predicate = filterViewModel.buildPredicate(withJobFilterDraft: self.draft)
request.sortDescriptors = [NSSortDescriptor(key: #keyPath(Job.publicationDate), ascending: false)]
jobsViewModel.filteredJobsFetchRequest = request
self.jobFilter = self.draft
presentationMode.wrappedValue.dismiss()
}
}
}
I have also tried with a #Binding like Paul says here but there's no luck.
Is there any workaround, or am I doing something wrong?
Thanks in advance!
EDIT: I've posted the properties of both views, because I think the problem comes from the the line in FilterView self.jobFilter = self.draft.
What I'm trying to do here is create a filter view, and the aforementioned line will be executed when the user presses the DONE button: I want to assign my binding jobFilter in the JobsTab the value of the FilterView source of truth (which is a #State) and probably, since I'm updating the binding jobFilter the FilterView is being shown again even though the $showFilter is false? I don't know to be honest.
EDIT2: I have also tried
``
if #available(iOS 15.0, *) {
let _ = Self._printChanges()
} else {
// Fallback on earlier versions
}
in both `FilterView` and its called `JobTabs` and in both, I get the same result: unchanged
According to your code, I assumed your FilterView() is not a sub view, but an independent view by its own.
Therefore, to make sure "presentationMode.wrappedValue.dismiss()" works, you don't need to create #Binding or #State variables outside the FilerView() for passing the data back and forth between different views. Just create one variable inside your FilterView() to make it works.
I don't have your full code, but I created a similar situation to your problem as below code:
import SwiftUI
struct Main: View {
#State private var showFilter = false
var body: some View {
Button {
self.showFilter = true
} label: {
Image(systemName: "line.horizontal.3.decrease.circle")
.renderingMode(.original)
}
.sheet(isPresented: $showFilter) {
FilterView()
}
}
}
struct FilterView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
NavigationView {
VStack {
Text("Filter View")
}.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text("cancel")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text("okay")
}
}
}
}
}
}
the parameter onDismiss is missing:
.sheet(isPresented: $showFilter, onDismiss: { isPresented = false }) { ... }
View model objects are usually a source of bugs because we don't use those in SwiftUI instead we use structs for speed and consistency. I recommend making an #State struct containing a bool property isSheetPresented and also the data the sheet needs. Add a mutating func and call it on the struct from the button event. Pass a binding to the struct into the sheet's view where you can call another mutating function to set the bool to false. Something like this:
struct SheetConfig {
var isPresented = false
var data: [String] = []
mutating func show(initialData: [String] ) {
isPresented = true
data = initialData
}
mutating func hide() {
isPresented = false
}
}
struct ContentView: View {
#State var config = SheetConfig()
var body: some View {
Button {
config.show(intialData: ...)
} label: {
}
.sheet(isPresented: $config.isPresented) {
FilterView($config)
}
Here is what I did to handle this issue.
Create a new protocol to avoid repeatations.
public protocol UIViewControllerHostedView where Self: View {
/// A method which should be triggered whenever dismiss is needed.
/// - Note: Since `presentationMode & isPresented` are not working for presented UIHostingControllers on lower iOS versions than 15. You must call, this method whenever you want to dismiss the presented SwiftUI.
func dismissHostedView(presentationMode: Binding<PresentationMode>)
}
public extension UIViewControllerHostedView {
func dismissHostedView(presentationMode: Binding<PresentationMode>) {
// Note: presentationMode & isPresented are not working for presented UIHostingControllers on lower iOS versions than 15.
if #available(iOS 15, *) {
presentationMode.wrappedValue.dismiss()
} else {
self.topViewController?.dismisOrPopBack()
}
}
}
Extend UIWindow and UIApplication
import UIKit
public extension UIWindow {
// Credits: - https://gist.github.com/matteodanelli/b8dcdfef39e3417ec7116a2830ff67cf
func visibleViewController() -> UIViewController? {
if let rootViewController: UIViewController = self.rootViewController {
return UIWindow.getVisibleViewControllerFrom(vc: rootViewController)
}
return nil
}
class func getVisibleViewControllerFrom(vc:UIViewController) -> UIViewController {
switch(vc){
case is UINavigationController:
let navigationController = vc as! UINavigationController
return UIWindow.getVisibleViewControllerFrom( vc: navigationController.visibleViewController!)
case is UITabBarController:
let tabBarController = vc as! UITabBarController
return UIWindow.getVisibleViewControllerFrom(vc: tabBarController.selectedViewController!)
default:
if let presentedViewController = vc.presentedViewController {
if let presentedViewController2 = presentedViewController.presentedViewController {
return UIWindow.getVisibleViewControllerFrom(vc: presentedViewController2)
}
else{
return vc;
}
}
else{
return vc;
}
}
}
}
#objc public extension UIApplication {
/// LCUIComponents: Returns the current visible top most window of the app.
var topWindow: UIWindow? {
return windows.first(where: { $0.isKeyWindow })
}
var topViewController: UIViewController? {
return topWindow?.visibleViewController()
}
}
Extend View to handle UIHostingController presented View`
public extension View {
weak var topViewController: UIViewController? {
UIApplication.shared.topViewController
}
}
Finally, use the helper method in your SwiftUI view as follows: -
struct YourView: View, UIViewControllerHostedView {
// MARK: - Properties
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body some View {
Button {
// Note: this part trigger a method in a created protocol above.
dismissHostedView(presentationMode: presentationMode)
} label: {
Text("Tap to dismiss")
}
}
In the sheet try adding this instead.
#Environment(\.dismiss) var dismiss // EnvironmentValue
Button("Some text") {
// Code
dismiss()
}

View dismisses unintentionally in SwiftUI

My goal is to have an array of structs that when tapped on an individual item, a change is made and passed up to the parent view but doesn't automatically dismiss my child view. I'm really not sure why the child view is automatically dismissing and how to prevent it. Here is my code for the ContentView. Note that I have now updated the struct to Identifiable.
//
// ContentView.swift
// test
//
// Created by Kevin McQuown on 3/7/22.
//
import SwiftUI
struct Person: Identifiable {
var id = UUID()
var name: String = ""
}
struct ContentView: View {
#State private var patients: [Person]
init() {
var temp: [Person] = []
for index in 0 ..< 3 {
temp.append(Person(name: "\(index)"))
}
patients = temp
}
var body: some View {
NavigationView {
VStack(spacing: 40) {
ForEach($patients) { $patient in
NavigationLink("\(patient.name)") {
View2(patient: $patient)
}
}
}
}
}
}
struct View2: View {
#Binding var patient: Person
var body: some View {
NavigationLink("\(patient.name)") {
ChangeNameView(patient: $patient)
}
}
}
struct ChangeNameView: View {
#Binding var patient: Person
var body: some View {
Button {
patient.name = "New Name"
} label: {
Text("Set New Name")
}
}
}

How to inject a Model from the Environment into a ViewModel in SwiftUI

I am trying to MVVM my SwiftUI app, but am unable to find a working solution for injecting a shared Model from #EnvironmentObject into the app's various Views' ViewModels.
The simplified code below creates a Model object in the init() of an example View, but I feel like I am supposed to be creating the model at the top of the app so that it can be shared among multiple Views and will trigger redraws when Model changes.
My question is whether this is the correct strategy, if so how to do it right, and if not what do I have wrong and how do I do it instead. I haven't found any examples that demonstrate this realistically beginning to end, and I can't tell if I am just a couple of property wrappers off, or it I am approaching this completely wrong.
import SwiftUI
#main
struct DIApp: App {
// This is where it SEEMS I should be creating and sharing Model:
// #StateObject var dataModel = DataModel()
var body: some Scene {
WindowGroup {
ListView()
// .environmentObject(dataModel)
}
}
}
struct Item: Identifiable {
let id: Int
let title: String
}
class DataModel: ObservableObject {
#Published var items = [Item]()
init() {
items.append(Item(id: 1, title: "First Item"))
items.append(Item(id: 2, title: "Second Item"))
items.append(Item(id: 3, title: "Third Item"))
}
func addItem(_ item: Item) {
items.append(item)
print("DM adding \(item.title)")
}
}
struct ListView: View {
// Creating the StateObject here compiles, but it will not work
// in a realistic app with other views that need to share it.
// It should be an app-wide ObservableObject created elsewhere
// and accessible everywhere, right?
#StateObject private var vm: ViewModel
init() {
_vm = StateObject(wrappedValue: ViewModel(dataModel: DataModel()))
}
var body: some View {
NavigationView {
List {
ForEach(vm.items) { item in
Text(item.title)
}
}
.navigationTitle("List")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
Button(action: {
addItem()
}) {
Image(systemName: "plus.circle")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
func addItem() {
vm.addRandomItem()
}
}
extension ListView {
class ViewModel: ObservableObject {
#Published var items: [Item]
let dataModel: DataModel
init(dataModel: DataModel) {
self.dataModel = dataModel
items = dataModel.items
}
func addRandomItem() {
let newID = Int.random(in: 100..<999)
let newItem = Item(id: newID, title: "New Item \(newID)")
// The line below causes Model to be successfully updated --
// dataModel.addItem print statement happens -- but Model change
// is not reflected in View.
dataModel.addItem(newItem)
// The line below causes the View to redraw and reflect additions, but the fact
// that I need it means I am not doing doing this right. It seems like I should
// be making changes to the Model and having them automatically update View.
items.append(newItem)
}
}
}
There are a few different issues here and multiple strategies to handle them.
From the top, yes, you can create your data model at the App level:
#main
struct DIApp: App {
var dataModel = DataModel()
var body: some Scene {
WindowGroup {
ListView(dataModel: dataModel)
.environmentObject(dataModel)
}
}
}
Notice that I've passed dataModel explicitly to ListView and as an environmentObject. This is because if you want to use it in init, it has to be passed explicitly. But, perhaps subviews will want a reference to it as well, so environmentObject will get it sent down the hierarchy automatically.
The next issue is that your ListView won't update because you have nested ObservableObjects. If you change the child object (DataModel in this case), the parent doesn't know to update the view unless you explicitly call objectWillChange.send().
struct ListView: View {
#StateObject private var vm: ViewModel
init(dataModel: DataModel) {
_vm = StateObject(wrappedValue: ViewModel(dataModel: dataModel))
}
var body: some View {
NavigationView {
List {
ForEach(vm.dataModel.items) { item in
Text(item.title)
}
}
.navigationTitle("List")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
Button(action: {
addItem()
}) {
Image(systemName: "plus.circle")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
func addItem() {
vm.addRandomItem()
}
}
extension ListView {
class ViewModel: ObservableObject {
let dataModel: DataModel
init(dataModel: DataModel) {
self.dataModel = dataModel
}
func addRandomItem() {
let newID = Int.random(in: 100..<999)
let newItem = Item(id: newID, title: "New Item \(newID)")
dataModel.addItem(newItem)
self.objectWillChange.send()
}
}
}
An alternate approach would be including DataModel on your ListView as an #ObservedObject. That way, when it changes, the view will update, even if ViewModel doesn't have any #Published properties:
struct ListView: View {
#StateObject private var vm: ViewModel
#ObservedObject private var dataModel: DataModel
init(dataModel: DataModel) {
_dataModel = ObservedObject(wrappedValue: dataModel)
_vm = StateObject(wrappedValue: ViewModel(dataModel: dataModel))
}
var body: some View {
NavigationView {
List {
ForEach(vm.dataModel.items) { item in
Text(item.title)
}
}
.navigationTitle("List")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
Button(action: {
addItem()
}) {
Image(systemName: "plus.circle")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
func addItem() {
vm.addRandomItem()
}
}
extension ListView {
class ViewModel: ObservableObject {
let dataModel: DataModel
init(dataModel: DataModel) {
self.dataModel = dataModel
}
func addRandomItem() {
let newID = Int.random(in: 100..<999)
let newItem = Item(id: newID, title: "New Item \(newID)")
dataModel.addItem(newItem)
}
}
}
Yet another object would be using Combine to automatically send objectWilLChange updates when items is updated:
struct ListView: View {
#StateObject private var vm: ViewModel
init(dataModel: DataModel) {
_vm = StateObject(wrappedValue: ViewModel(dataModel: dataModel))
}
var body: some View {
NavigationView {
List {
ForEach(vm.dataModel.items) { item in
Text(item.title)
}
}
.navigationTitle("List")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
Button(action: {
addItem()
}) {
Image(systemName: "plus.circle")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
func addItem() {
vm.addRandomItem()
}
}
import Combine
extension ListView {
class ViewModel: ObservableObject {
let dataModel: DataModel
private var cancellable : AnyCancellable?
init(dataModel: DataModel) {
self.dataModel = dataModel
cancellable = dataModel.$items.sink { [weak self] _ in
self?.objectWillChange.send()
}
}
func addRandomItem() {
let newID = Int.random(in: 100..<999)
let newItem = Item(id: newID, title: "New Item \(newID)")
dataModel.addItem(newItem)
}
}
}
As you can see, there are a few options (these, and others). You can pick the design pattern that works best for you.
You are probably unable to find a working solution because it is not a valid approach. In SwiftUI we do not use MVVM pattern of view model objects. The View data structs are already the view model that SwiftUI uses to create and update actual views like UILabels, etc. on the screen. You should also be aware that when you use property wrappers like #State it makes our super efficient View data struct behave like an object, but without the memory hog of an actual heap object. If you create extra objects then you are slowing SwiftUI down and will lose the magic like dependency tracking etc.
Here is your fixed code:
import SwiftUI
#main
struct DIApp: App {
#StateObject var dataModel = DataModel()
var body: some Scene {
WindowGroup {
ListView()
.environmentObject(dataModel)
}
}
}
struct Item: Identifiable {
let id: Int
let title: String
}
class DataModel: ObservableObject {
#Published var items = [Item]()
init() {
items.append(Item(id: 1, title: "First Item"))
items.append(Item(id: 2, title: "Second Item"))
items.append(Item(id: 3, title: "Third Item"))
}
func addItem(_ item: Item) {
items.append(item)
print("DM adding \(item.title)")
}
}
struct ListView: View {
#EnvironmentObject private var dataModel: DataModel
var body: some View {
NavigationView {
List {
// ForEach($dataModel.items) { $item in // if you want write access
ForEach(dataModel.items) { item in
Text(item.title)
}
}
.navigationTitle("List")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
Button(action: {
addItem()
}) {
Image(systemName: "plus.circle")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
func addItem() {
let newID = Int.random(in: 100..<999)
let newItem = Item(id: newID, title: "New Item \(newID)")
dataModel.addItem(newItem)
}
}

UserDefault value does not update in a list SwiftUI

I have two views, embedded in TabView.
I am using userdefaults in a class called usersettings.
class UserSettings: ObservableObject {
#Published var favList: [String] {
willSet {
print("willset")
}
didSet {
UserDefaults.standard.set(favList, forKey: "isAccountPrivate")
print("didset")
}
}
init() {
self.favList = UserDefaults.standard.object(forKey: "isAccountPrivate") as? [String] ?? ["Sepetiniz Boş"]
}
}
In Button View, which acts like add/remove favorite. It successfully adds and remove from the UserDefaults. But when I add something it does not show on the other view (please see the next code after FavButton)
struct FavButton: View {
#Binding var passedFood: String
#ObservedObject var userSettings = UserSettings()
var body: some View {
Button(action: {
if userSettings.favList.contains(passedFood) {
userSettings.favList.remove(at: userSettings.favList.firstIndex(of: passedFood )!)
} else {
userSettings.favList.append(passedFood)
}
})
}
}
But it does not update my list in this other view unless I close and open my app. If I remove something from the list, it actually removes from the userdefault.
If I add a new word within this view, it works too.
My only problem is when I add something from another view (FavButton) it does not show in this view (FavView).
struct FavView: View {
#ObservedObject var userSettings = UserSettings()
#State private var newWord = ""
var body: some View {
NavigationView {
List {
TextField("Ürün Ekleyin...", text: $newWord, onCommit: addNewWord)
ForEach( self.userSettings.favList, id: \.self) { list in
Text(list)
.font(.headline)
.padding()
}
.onDelete(perform: self.deleteRow)
}
.navigationTitle("Sepetim")
}
}
private func deleteRow(at indexSet: IndexSet) {
self.userSettings.favList.remove(atOffsets: indexSet)
}
private func addNewWord() {
let answer = newWord.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
self.userSettings.favList.append(answer)
guard answer.count > 0 else {
return
}
newWord = ""
}
}
A better approach to follow the SwiftUI idiom is to use the .environmentObject() modifier.
When you declare your app:
struct AppScene: App {
#StateObject private var userSettings = UserSettings() // Use state object to persist the object
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(userSettings) // Inject userSettings into the environment
}
}
}
and then in you ContentView you can reach into your environment and get the object:
struct ContentView: View {
#EnvironmentObject private var userSettings: UserSettings
var body: some View {
Text("Number of items in favList: \(userSettings.favList.count)")
}
}
You need to use same instance of UserSettings in all views where you want to have observed user settings, like
class UserSettings: ObservableObject {
static let global = UserSettings()
//... other code
}
and now
struct FavButton: View {
#ObservedObject var userSettings = UserSettings.global // << here !!
// ... other code
}
and
struct FavView: View {
#ObservedObject var userSettings = UserSettings.global // << here !!
// ... other code
}

Two way binding to multiple instances

I have a view with a ForEach with multiple instances of another view. I want to be able to:
Click a button in the main view and trigger validation on the nested views, and
On the way back, populate an array with the results of the validations
I've simplified the project so it can be reproduced. Here's what I have:
import SwiftUI
final class AddEditItemViewModel: ObservableObject {
#Published var item : String
#Published var isValid : Bool
#Published var doValidate: Bool {
didSet{
print(doValidate) // This is never called
validate()
}
}
init(item : String, isValid : Bool, validate: Bool) {
self.item = item
self.isValid = isValid
self.doValidate = validate
}
func validate() { // This is never called
isValid = Int(item) != nil
}
}
struct AddEditItemView: View {
#ObservedObject var viewModel : AddEditItemViewModel
var body: some View {
Text(viewModel.item)
}
}
final class AddEditProjectViewModel: ObservableObject {
let array = ["1", "2", "3", "nope"]
#Published var countersValidationResults = [Bool]()
#Published var performValidation = false
init() {
for _ in array {
countersValidationResults.append(false)
}
}
}
struct ContentView: View {
#ObservedObject var viewModel : AddEditProjectViewModel
#State var result : Bool = false
var body: some View {
VStack {
ForEach(
viewModel.countersValidationResults.indices, id: \.self) { i in
AddEditItemView(viewModel: AddEditItemViewModel(
item: viewModel.array[i],
isValid: viewModel.countersValidationResults[i],
validate: viewModel.performValidation
)
)
}
Button(action: {
viewModel.performValidation = true
result = viewModel.countersValidationResults.filter{ $0 == false }.count == 0
}) {
Text("Validate")
}
Text("All is valid: \(result.description)")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(viewModel: AddEditProjectViewModel())
}
}
When I change the property in the main view, the property doesn't change in the nested views, even though it's a #Published property.
Since this first step is not working, I haven't even been able to test the second part (updating the array of books with the validation results)
I need the setup to be like this because if an item is not valid, that view will show an error message, so the embedded views need to know whether they are valid or not.
UPDATE:
My issue was that you can't seem to be able to store Binding objects in view models, only in views, so I moved my properties to the view, and it works:
import SwiftUI
final class AddEditItemViewModel: ObservableObject {
#Published var item : String
init(item : String) {
self.item = item
print("item",item)
}
func validate() -> Bool{
return Int(item) != nil
}
}
struct AddEditItemView: View {
#ObservedObject var viewModel : AddEditItemViewModel
#Binding var doValidate: Bool
#Binding var isValid : Bool
init(viewModel: AddEditItemViewModel, doValidate:Binding<Bool>, isValid : Binding<Bool>) {
self.viewModel = viewModel
self._doValidate = doValidate
self._isValid = isValid
}
var body: some View {
Text("\(viewModel.item): \(isValid.description)").onChange(of: doValidate) { _ in isValid = viewModel.validate() }
}
}
struct ContentView: View {
#State var performValidation = false
#State var countersValidationResults = [false,false,false,false] // had to hard code this here
#State var result : Bool = false
let array = ["1", "2", "3", "nope"]
// init() {
// for _ in array {
// countersValidationResults.append(false) // For some weird reason this appending doesn't happen!
// }
// }
var body: some View {
VStack {
ForEach(array.indices, id: \.self) { i in
AddEditItemView(viewModel: AddEditItemViewModel(item: array[i]), doValidate: $performValidation, isValid: $countersValidationResults[i])
}
Button(action: {
performValidation.toggle()
result = countersValidationResults.filter{ $0 == false }.count == 0
}) {
Text("Validate")
}
Text("All is valid: \(result.description)")
Text(countersValidationResults.description)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I'm having trouble reconciling the question with the example code and figuring out what's supposed to be happening. Think that there are a few issues going on.
didSet will not get called on #Published properties. You can (SwiftUI - is it possible to get didSet to fire when changing a #Published struct?) but the gist is that it's not a normal property, because of the #propertyWrapper around it
You say in your question that you want a "binding", but you never in fact us a Binding. If you did want to bind the properties together, you should look into using either #Binding or creating a binding without the property wrapper. Here's some additional reading on that: https://swiftwithmajid.com/2020/04/08/binding-in-swiftui/
You have some circular logic in your example code. Like I said, it's a little hard to figure out what's a symptom of the code and what you're really trying to achieve. Here's an example that strips away a lot of the extraneous stuff going on and functions:
struct AddEditItemView: View {
var item : String
var isValid : Bool
var body: some View {
Text(item)
}
}
final class AddEditProjectViewModel: ObservableObject {
let array = ["1", "2", "3"]// "nope"]
#Published var countersValidationResults = [Bool]()
init() {
for _ in array {
countersValidationResults.append(false)
}
}
func validate(index: Int) { // This is never called
countersValidationResults[index] = Int(array[index]) != nil
}
}
struct ContentView: View {
#ObservedObject var viewModel : AddEditProjectViewModel
#State var result : Bool = false
var body: some View {
VStack {
ForEach(
viewModel.countersValidationResults.indices, id: \.self) { i in
AddEditItemView(item: viewModel.array[i], isValid: viewModel.countersValidationResults[i])
}
Button(action: {
viewModel.array.enumerated().forEach { (index,_) in
viewModel.validate(index: index)
}
result = viewModel.countersValidationResults.filter{ $0 == false }.count == 0
}) {
Text("Validate")
}
Text("All is valid: \(result.description)")
}
}
}
Note that it your array, if you include the "nope" item, not everything validates, since there's a non-number, and if you omit it, everything validates.
In your case, there really wasn't the need for that second view model on the detail view. And, if you did have it, at least the way you had things written, it would have gotten you into a recursive loop, as it would've validated, then refreshed the #Published property on the parent view, which would've triggered the list to be refreshed, etc.
If you did get in a situation where you needed to communicate between two view models, you can do that by passing a Binding to the parent's #Published property by using the $ operator:
class ViewModel : ObservableObject {
#Published var isValid = false
}
struct ContentView : View {
#ObservedObject var viewModel : ViewModel
var body: some View {
VStack {
ChildView(viewModel: ChildViewModel(isValid: $viewModel.isValid))
}
}
}
class ChildViewModel : ObservableObject {
var isValid : Binding<Bool>
init(isValid: Binding<Bool>) {
self.isValid = isValid
}
func toggle() {
isValid.wrappedValue.toggle()
}
}
struct ChildView : View {
#ObservedObject var viewModel : ChildViewModel
var body: some View {
VStack {
Text("Valid: \(viewModel.isValid.wrappedValue ? "true" : "false")")
Button(action: {
viewModel.toggle()
}) {
Text("Toggle")
}
}
}
}