swiftUI : Updating Text after changes in settings - swiftui

[Edit(1) to reflect posting of streamlined app to illustrate the issue : ].
[Edit (2) : completely removed EnvironmentObject and app now works ! Not understanding WHY body is refreshed as NO #State vars are being modified...Code at end of text]
I am writing an app that, at some point, displays some text, related to the contents of 2 Arrays, depending on a set of rules. These rules can be set in a Settings view, as User's preference.
So, when a user changes the rules he wants applied in Settings, that text needs to be re-assessed.
But of course, things aren't that easy.
I present my settings view as modal on my main ContentView, and when I dismiss that modal, the body of the ContentView is not redrawn...
I created an EnvironmentObject with #Published vars in order to keep track of all the user preferences (that are also written to UserDefaults), and shared that #EnvironmentObject with both my ContentView and SettingsView, in the hope that, being an observedObject, its changes would trigger a refresh of my ContentView.
Not so...
Any ideas to help me go forward on this ? Any pointers would be greatly appreciated (again!).
Posted app on GitHub has following architecture :
An appState EnvironmentObject,
A ContentView that displays a set of texts, depending on some user preferences set in
A settingsView
UserDefaults are initialized in AppDelegate.
Thanks for any help on this...
Content view :
import SwiftUI
struct ContentView: View {
#State var modalIsPresented = false // The "settingsView" modally presented as a sheet
#State private var modalViewCaller = 0 // This triggers the appropriate modal (only one in this example)
var body: some View {
NavigationView {
VStack {
Spacer()
VStack {
Text(generateStrings().text1)
.foregroundColor(Color(UIColor.systemGreen))
Text(generateStrings().text2)
} // end of VStack
.frame(maxWidth: .infinity, alignment: .center)
.lineLimit(nil) // allows unlimited lines
.padding(.all)
Spacer()
} // END of main VStack
.onAppear() {
self.modalViewCaller = 0
}
.navigationBarTitle("Test app", displayMode: .inline)
.navigationBarItems(leading: (
Button(action: {
self.modalViewCaller = 6 // SettingsView
self.modalIsPresented = true
}
) {
Image(systemName: "gear")
.imageScale(.large)
}
))
} // END of NavigationView
.sheet(isPresented: $modalIsPresented, content: sheetContent)
.navigationViewStyle(StackNavigationViewStyle()) // This avoids dual column on iPad
} // END of var body: some View
// MARK: #ViewBuilder func sheetContent() :
#ViewBuilder func sheetContent() -> some View {
if modalViewCaller == 6 {
SettingsView()
}
} // END of func sheetContent
// MARK: generateStrings() : -
func generateStrings() -> (text1: String, text2: String, recapText: String, isHappy: Bool) { // minimumNumberOfEventsCheck
var myBool = false
var aString = "" // The text 1 string
var bString = "" // The text 2 string
var cString = "" // The recap string
if UserDefaults.standard.bool(forKey: kmultiRules) { // The user chose the dual rules option
let ruleSet = UserDefaults.standard.integer(forKey: kruleSelection) + 1
aString = "User chose 2 rules option"
bString = "User chose rule set # \(ruleSet)"
myBool = true
print("isDualRules true loop : generateStrings was called at \(Date().debugDescription)")
cString = "Dual rules option, user chose rule set nb \(ruleSet)"
}
else // The user chose the single rule option
{
aString = "User chose single rule option"
bString = "User had no choice : there is only one set of rules !"
myBool = false
print("isDualRules false loop : generateStrings was called at \(Date().debugDescription)")
cString = "Single rule option, user chose nothing."
}
return (aString, bString, cString, myBool)
} // End of func generatestrings() -> String
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
return ContentView()
}
}
SettingsView :
import SwiftUI
import UIKit
struct SettingsView: View {
#Environment(\.presentationMode) var presentationMode // in order to dismiss the Sheet
#State public var multiRules = UserDefaults.standard.bool(forKey: kmultiRules)
#State private var ruleSelection = UserDefaults.standard.integer(forKey: kruleSelection) // 0 is rule 1, 1 is rule 2
var body: some View {
NavigationView {
List {
Toggle(isOn: $multiRules)
{
Text("more than one rule ?")
}
.padding(.horizontal)
if multiRules {
Picker("", selection: $ruleSelection){
Text("rules 1").tag(0)
Text("rules 2").tag(1)
}.pickerStyle(SegmentedPickerStyle())
.padding(.horizontal)
}
} // End of List
.navigationBarItems(
leading:
Button("Done") {
self.saveDefaults() // We try to save once more if needed
self.presentationMode.wrappedValue.dismiss() // This dismisses the view
}
)
.navigationBarTitle("Settings", displayMode: .inline)
} // END of Navigation view
} // END of some View
func saveDefaults() {
UserDefaults.standard.set(multiRules, forKey: kmultiRules)
UserDefaults.standard.set(ruleSelection, forKey: kruleSelection)
}
}
// MARK: Preview struct
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
return SettingsView()
}
}
Constants.swift file :
import Foundation
import SwiftUI
let kmultiRules = "two rules"
let kruleSelection = "rules selection"
let kappStateChanged = "appStateChanged"
AppDelegate :
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
UserDefaults.standard.register(defaults: [ // We initialize the UserDefaults
"two rules": false,
"rules selection": 0, // 0 is ruel 1, 1 is rule 2
"appStateChanged": false
])
return true
}

If you have a shared #EnvironmentObject with #Published properties in two views, if you change such a property from one view, the other one will be re-execute the body property and the view will be updated.
It really helps to create simple standalone examples - not only for asking here, also for gaining a deeper understanding / getting an idea why it doesn't work in the complex case.
For example:
import SwiftUI
class TextSettings: ObservableObject {
#Published var count: Int = 1
}
struct TextSettingsView: View {
#EnvironmentObject var settings: TextSettings
var body: some View {
Form {
Picker(selection: $settings.count, label:
Text("Text Repeat Count"))
{
ForEach(Array(1...5), id: \.self) { value in
Text(String(value)).tag(value)
}
}
}
}
}
struct TextWithSettingExampleView: View {
#EnvironmentObject var settings: TextSettings
var body: some View {
Text(String(repeating: "Hello ", count: Int(settings.count)))
.navigationBarItems(trailing: NavigationLink("Settings", destination: TextSettingsView()))
}
}
struct TextWithSettingExampleView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
TextWithSettingExampleView()
}
.environmentObject(TextSettings())
}
}

Not sure I fully understand the question, but I had what I believe might be a similar problem where I never got my contentview to reflect the updates in my observed object when the changes were triggered from a modal. I solved/hacked this by triggering an action in my observed object when dismissing the modal like this:
struct ContentView: View {
//
#State var isPresentingModal = false
var body: some View {
//
.sheet(isPresented: self.$isPresentingModal) {
PresentedModalView()
.onDisappear {
//Do something here
}
}
}
}

Related

SwiftUI publishing an environment change from within view update

The app has a model that stores the user's current preference for light/dark mode, which the user can change by clicking on a button:
class DataModel: ObservableObject {
#Published var mode: ColorScheme = .light
The ContentView's body tracks the model, and adjusts the colorScheme when the model changes:
struct ContentView: View {
#StateObject private var dataModel = DataModel()
var body: some View {
NavigationStack(path: $path) { ...
}
.environmentObject(dataModel)
.environment(\.colorScheme, dataModel.mode)
As of Xcode Version 14.0 beta 5, this is producing a purple warning: Publishing changes from within view updates is not allowed, this will cause undefined behavior. Is there another way to do this? Or is it a hiccup in the beta release? Thanks!
Update: 2022-09-28
Xcode 14.1 Beta 3 (finally) fixed the "Publishing changes from within view updates is not allowed, this will cause undefined behavior"
See: https://www.donnywals.com/xcode-14-publishing-changes-from-within-view-updates-is-not-allowed-this-will-cause-undefined-behavior/
Full disclosure - I'm not entirely sure why this is happening but these have been the two solutions I have found that seem to work.
Example Code
// -- main view
#main
struct MyApp: App {
#StateObject private var vm = ViewModel()
var body: some Scene {
WindowGroup {
ViewOne()
.environmentObject(vm)
}
}
}
// -- initial view
struct ViewOne: View {
#EnvironmentObject private var vm: ViewModel
var body: some View {
Button {
vm.isPresented.toggle()
} label: {
Text("Open sheet")
}
.sheet(isPresented: $vm.isPresented) {
SheetView()
}
}
}
// -- sheet view
struct SheetView: View {
#EnvironmentObject private var vm: ViewModel
var body: some View {
Button {
vm.isPresented.toggle()
} label: {
Text("Close sheet")
}
}
}
// -- view model
class ViewModel: ObservableObject {
#Published var isPresented: Bool = false
}
Solution 1
Note: from my testing and the example below I still get the error to appear. But if I have a more complex/nested app then the error disappears..
Adding a .buttonStyle() to the button that does the initial toggling.
So within the ContentView on the Button() {} add in a .buttonStyle(.plain) and it will remove the purple error:
struct ViewOne: View {
#EnvironmentObject private var vm: ViewModel
var body: some View {
Button {
vm.isPresented.toggle()
} label: {
Text("Open sheet")
}
.buttonStyle(.plain) // <-- here
.sheet(isPresented: $vm.isPresented) {
SheetView()
}
}
}
^ This is probably more of a hack than solution since it'll output a new view from the modifier and that is probably what is causing it to not output the error on larger views.
Solution 2
This one is credit to Alex Nagy (aka. Rebeloper)
As Alex explains:
.. with SwiftUI 3 and SwiftUI 4 the data handling kind of changed. How SwiftUI handles, more specifically the #Published variable ..
So the solution is to have the boolean trigger to be a #State variable within the view and not as a #Published one inside the ViewModel. But as Alex points out it can make your views messy and if you have a lot of states in it, or not be able to deep link, etc.
However, since this is the way that SwiftUI 4 wants these to operate, we run the code as such:
// -- main view
#main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ViewOne()
}
}
}
// -- initial view
struct ViewOne: View {
#State private var isPresented = false
var body: some View {
Button {
isPresented.toggle()
} label: {
Text("Open sheet")
}
.sheet(isPresented: $isPresented) {
SheetView(isPresented: $isPresented)
// SheetView() <-- if using dismiss() in >= iOS 15
}
}
}
// -- sheet view
struct SheetView: View {
// I'm showing a #Binding here for < iOS 15
// but you can use the dismiss() option if you
// target higher
// #Environment(\.dismiss) private var dismiss
#Binding var isPresented: Bool
var body: some View {
Button {
isPresented.toggle()
// dismiss()
} label: {
Text("Close sheet")
}
}
}
Using the #Published and the #State
Continuing from the video, if you need to still use the #Published variable as it might tie into other areas of your app you can do so with a .onChange and a .onReceive to link the two variables:
struct ViewOne: View {
#EnvironmentObject private var vm: ViewModel
#State private var isPresented = false
var body: some View {
Button {
vm.isPresented.toggle()
} label: {
Text("Open sheet")
}
.sheet(isPresented: $isPresented) {
SheetView(isPresented: $isPresented)
}
.onReceive(vm.$isPresented) { newValue in
isPresented = newValue
}
.onChange(of: isPresented) { newValue in
vm.isPresented = newValue
}
}
}
However, this can become really messy in your code if you have to trigger it for every sheet or fullScreenCover.
Creating a ViewModifier
So to make it easier for you to implement it you can create a ViewModifier which Alex has shown works too:
extension View {
func sync(_ published: Binding<Bool>, with binding: Binding<Bool>) -> some View {
self
.onChange(of: published.wrappedValue) { newValue in
binding.wrappedValue = newValue
}
.onChange(of: binding.wrappedValue) { newValue in
published.wrappedValue = newValue
}
}
}
And in use on the View:
struct ViewOne: View {
#EnvironmentObject private var vm: ViewModel
#State private var isPresented = false
var body: some View {
Button {
vm.isPresented.toggle()
} label: {
Text("Open sheet")
}
.sheet(isPresented: $isPresented) {
SheetView(isPresented: $isPresented)
}
.sync($vm.isPresented, with: $isPresented)
// .onReceive(vm.$isPresented) { newValue in
// isPresented = newValue
// }
// .onChange(of: isPresented) { newValue in
// vm.isPresented = newValue
// }
}
}
^ Anything denoted with this is my assumptions and not real technical understanding - I am not a technical knowledgeable :/
Try running the code that's throwing the purple error asynchronously, for example, by using DispatchQueue.main.async or Task.
DispatchQueue.main.async {
// environment changing code comes here
}
Task {
// environment changing code comes here
}
Improved Solution of Rebel Developer
as a generic function.
Rebeloper solution
It helped me a lot.
1- Create extension for it:
extension View{
func sync<T:Equatable>(_ published:Binding<T>, with binding:Binding<T>)-> some View{
self
.onChange(of: published.wrappedValue) { published in
binding.wrappedValue = published
}
.onChange(of: binding.wrappedValue) { binding in
published.wrappedValue = binding
}
}
}
2- sync() ViewModel #Published var to local #State var
struct ContentView: View {
#EnvironmentObject var viewModel:ViewModel
#State var fullScreenType:FullScreenType?
var body: some View {
//..
}
.sync($viewModel.fullScreenType, with: $fullScreenType)

Reset NagivationView stack in TabView

I have a tabview with two tabs (tabs A and B).
Clicking tab A opens a master View. In that master view there is a navigation link to Page 1. Within Page 1 there is also a link to Page 2.
When the user is on Page 1 or 2, and I tap Tab A, it doesn’t revert to master View. Similarly if the user clicks Tab B and then Tab A again, it returns to Page 1 or 2 (whichever the user was on), rather than master View.
How to I make the navigation stack reset in both cases?
Thanks!
That's because the View won't be rerendered. Here is a possible approach how to achieve your behavior:
You can use ProxyBinding for the TabView to detect changes and then reset the NavigationLink by changing the internal State variable.
struct ContentView: View {
#State var activeView: Int = 0
#State var showNavigation: Bool = false
var body: some View {
TabView(selection: Binding<Int>(
get: {
activeView
}, set: {
activeView = $0
showNavigation = false //<< when pressing Tab Bar Reset Navigation View
}))
{
NavigationView {
NavigationLink("Click", destination: Text("Page A"), isActive: $showNavigation)
}
.tabItem {
Image(systemName: "1.circle")
Text("First")
}
.tag(0)
Text("Second View")
.padding()
.tabItem {
Image(systemName: "2.circle")
Text("Second")
}
.tag(1)
}
}
}
You can create RootView with MainView
import SwiftUI
struct RootView: View {
#ObservedObject var viewModel = RootViewModel()
init(){
viewModel.prepare()
}
var body: some View {
MainView(tab: viewModel.mainTab)
.id(UUID().uuidString)
}
}
Create RootViewModel with listeners to screen updating
import SwiftUI
class RootViewModel: ObservableObject{
#Published var mainTab: SelectedTab = .firstTab
let mainScreenNotification = NSNotification.Name("mainScreenNotification")
private var observerMain: Any?
func prepare(){
observerMain = NotificationCenter.default.addObserver(forName: mainScreenNotification, object: nil, queue: nil, using: { [unowned self] notification in
self.mainTab = (notification.userInfo?["selectedTab"])! as! SelectedTab
})
}
}
enum SelectedTab {
case firstTab, secondTab
}
Run this to inflating new tab screen from tab child:
NotificationCenter.default.post(name:
NSNotification.Name("mainScreenNotification"),
object: nil,
userInfo: ["selectedTab": SelectedTab.firstTab]
)

ContentView redraw from change in modalView not triggered

Ever since the advent of swiftUI 2.0, I have been unable to update a view according to a change done in another modally-presented view (the settings view).
I display a string on my main ContentView that derives its content from a segmented Picker value on the SettingsView.
The problem is that after the user changes the setting and discards the SettingsView, the string in ContentView is not updated. The body is not redrawn.
I am making use of #ObservableObject and #StateObject so every change to it should trigger a redraw, but I can't make it work...
I created a class that conforms to the ObservableObject protocol : AppState
I am using that class to try and pass data and -more importantly- data changes between the views in order to have my ContentView redrawn according to the the user's settings.
In order to instantiate this class, I registered a single UserDefaults in my AppDelegate file.
I also imported the Combine Framework into my project and added the import Combine line in each and every file !
I've simplified my code as much as possible, in order to illustrate the issue, so the following might seem a bit circumvolutated, but it is derived from a much more complex app, sorry about that.
Here is my ContentView code :
import SwiftUI
import Combine
struct ContentView: View {
#StateObject var appState: AppState
#State var modalViewCaller = 0 // used to present correct modalView
#State var modalIsPresented = false // to present the modal views
var body: some View {
let stringArray = generateString() // func to generate string according to user's pref
let recapString = stringArray[0]
return ZStack {
NavigationView {
VStack {
// MARK: - texts :
VStack {
Text(recapString)
.bold()
.multilineTextAlignment(/*#START_MENU_TOKEN#*/.center/*#END_MENU_TOKEN#*/)
} // end of VStack
.padding()
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(Color(UIColor.systemBlue), lineWidth: 4))
.padding()
} // END of VStack
.onAppear() {
self.modalViewCaller = 0
print("\n\n*********** Content View onAppear triggered ! ************\n")
}
.navigationBarTitle("DataFun", displayMode: .inline)
.navigationBarItems(leading: (
Button(action: {
self.modalViewCaller = 1 // SettingsView
self.modalIsPresented = true
}
) {
Image(systemName: "gear")
.imageScale(.large)
}
))
} // END of NavigationView
.onAppear() {
self.appState.updateValues()
}
} // End of ZStack
.sheet(isPresented: $modalIsPresented) {
sheetContent(modalViewCaller: $modalViewCaller, appState: AppState())
}
.navigationViewStyle(StackNavigationViewStyle())
}
// MARK: - struct sheetContent() :
struct sheetContent: View {
#Binding var modalViewCaller: Int // Binding to the #State modalViewCaller variable from ContentView
#StateObject var appState: AppState
var body: some View {
if modalViewCaller == 1 { // The settings view is called
SettingsView(appState: AppState())
.navigationViewStyle(StackNavigationViewStyle())
.onDisappear { self.modalViewCaller = 0 }
} else if modalViewCaller == 2 { // the "other view" is called
OtherView()
.navigationViewStyle(StackNavigationViewStyle())
.onDisappear { self.modalViewCaller = 0 }
}
}
} // END of func sheetContent
// MARK: - generateString()
func generateString() -> [String] {
var recapString = "" // The recap string
var myArray = [""]
// We create the recap string :
if UserDefaults.standard.integer(forKey: "rules selection") == 0 { // ICAO
recapString = "User chose LEFT"
} else if UserDefaults.standard.integer(forKey: "rules selection") == 1 { // AF Rules
recapString = "User chose RIGHT"
}
myArray = [recapString]
return myArray
} // End of func generateString()
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(appState: AppState())
}
}
Here is my AppState code :
import Foundation
import SwiftUI
import Combine
class AppState: ObservableObject {
#Published var rulesSelection: Int = UserDefaults.standard.integer(forKey: "rules selection")
func updateValues() { // When the user changes a setting, the UserDefault is updated. Here, we align the AppState's value with what is now in the UserDefaults
self.rulesSelection = UserDefaults.standard.integer(forKey: "rules selection")
print("\nappState value (ruleSelection) updated from Appstate class func \"updateValues")
}
}
Here is my SettingsView code :
import SwiftUI
import Combine
struct SettingsView: View {
#Environment(\.presentationMode) var presentationMode // in order to dismiss the Sheet
#StateObject var appState: AppState
#State private var rulesSelection = UserDefaults.standard.integer(forKey: "rules selection") // 0 is LEFT, 1 is RIGHT
var body: some View {
NavigationView {
VStack {
Spacer()
Text("Choose a setting below")
.padding()
Picker("", selection: $rulesSelection) {
Text("LEFT").tag(0)
Text("RIGHT").tag(1)
}
.pickerStyle(SegmentedPickerStyle())
.padding()
Spacer()
}
.navigationBarItems(
leading:
Button("Done") {
self.saveDefaults() // We set the UserDefaults
self.presentationMode.wrappedValue.dismiss() // This dismisses the view
// self.modalViewCaller = 0
}
) // END of NavBarItems
} // END of NavigationBiew
} // END of body
func saveDefaults() {
UserDefaults.standard.set(rulesSelection, forKey: "rules selection")
self.appState.updateValues() // This is a func from the AppState class that will align the appState's value to the UserDefaults
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
SettingsView(appState: AppState())
}
}
And a working project if anyone has the time to check this "live" :
https://github.com/Esowes/dataFun
Thanks for any pointers.
Regards.
Well... it was... in short many changes, so here is complete ContentView.swift with fixes.
Note: you need only one StateObject, and one instance set into it, and you need to have published property of observable object in view otherwise it is not refreshed, and changes in UserDefaults do not refresh view until you use AppStorage, etc.
Verified with Xcode 12.1 / iOS 14.1
import SwiftUI
import Combine
struct ContentView: View {
#StateObject var appState: AppState
#State var modalViewCaller = 0 // used to present correct modalView
#State var modalIsPresented = false // to present the modal views
var body: some View {
return ZStack {
NavigationView {
VStack {
// MARK: - texts :
VStack {
RecapStringView(appState: appState)
} // end of VStack
.padding()
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(Color(UIColor.systemBlue), lineWidth: 4))
.padding()
} // END of VStack
.onAppear() {
self.modalViewCaller = 0
print("\n\n*********** Content View onAppear triggered ! ************\n")
}
.navigationBarTitle("DataFun", displayMode: .inline)
.navigationBarItems(leading: (
Button(action: {
self.modalViewCaller = 1 // SettingsView
self.modalIsPresented = true
}
) {
Image(systemName: "gear")
.imageScale(.large)
}
))
} // END of NavigationView
.onAppear() {
self.appState.updateValues()
}
} // End of ZStack
.sheet(isPresented: $modalIsPresented) {
sheetContent(modalViewCaller: $modalViewCaller, appState: appState)
}
.navigationViewStyle(StackNavigationViewStyle())
}
// MARK: - struct sheetContent() :
struct sheetContent: View {
#Binding var modalViewCaller: Int // Binding to the #State modalViewCaller variable from ContentView
#ObservedObject var appState: AppState
var body: some View {
if modalViewCaller == 1 { // The settings view is called
SettingsView(appState: appState)
.navigationViewStyle(StackNavigationViewStyle())
.onDisappear { self.modalViewCaller = 0 }
} else if modalViewCaller == 2 { // the "other view" is called
OtherView()
.navigationViewStyle(StackNavigationViewStyle())
.onDisappear { self.modalViewCaller = 0 }
}
}
} // END of func sheetContent
}
struct RecapStringView: View {
#ObservedObject var appState: AppState
var body: some View {
Text("User chose " + "\(appState.rulesSelection == 0 ? "LEFT" : "RIGHT")")
.bold()
.multilineTextAlignment(.center)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(appState: AppState())
}
}

PresentationMode.dismiss weird behaviour when using multiple NavigationLinks inside ForEach

My app has 4 views (let's call them View_A[root] -> View_B -> View_C -> View_D). The navigation between them was made using NavigationView/NavigationLink.
When I call self.presentationMode.wrappedValue.dismiss() from the last view(View_D) I expect it to dismiss the current view (D) only, but for some reason it dismissed ALL the views and stops at view A (root view).
That's weird.
I spent a couple of hours trying to figure out what's going on there and I found that
- if I remove "ForEach" from "View_A" it works correctly and only the last view is dismissed. Even though ForEach gets just 1 static object in this example.
The second weird thing is that
- if I don't change "self.thisSession.stats" to false it also works correctly dismissing only the last view.
This is super weird as View_A (as far as I understand) is not dependent on thisSession environment variable.
Any ideas on how to prevent View_C and View_B from being dismissed in this case? I wanna end up at View_C after clicking the link, not at View_A.
Any help is appreciated, it took me a while to find out where it comes from but I'm not smart enough to proceed any further ;)
import SwiftUI
struct A_View: View {
#EnvironmentObject var thisSession: CurrentSession
var body: some View {
NavigationView {
VStack {
Text("View A")
ForEach([TestObject()], id: \.id) { _ in
NavigationLink(destination: View_B() ) {
Text("Move to View B")
}
}
}
}
}
}
struct View_B: View {
var body: some View {
NavigationView {
NavigationLink(destination: View_C()
) {
Text("GO TO VIEW C")
}
}
}
}
struct View_C: View {
var body: some View {
ZStack {
NavigationView {
NavigationLink(destination: View_D()) {
Text("GO TO VIEW D")
}
}
}
}
}
struct View_D: View {
#EnvironmentObject var thisSession: CurrentSession
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
ZStack {
VStack {
Button(action: {
self.thisSession.stats = false
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Return!")
}
}
}
}
}
class CurrentSession: ObservableObject {
#Published var stats: Bool = false
#Published var user: String = "user"
}
struct TestObject: Identifiable, Codable {
let id = UUID()
}
Your issue is with:
NavigationView
There is only supposed to be one NavigationView in an entire view stack. Try removing the NavigationView from views B and C

Passing data between two views

I wanted to create quiet a simple app on watchOS 6, but after Apple has changed the ObjectBindig in Xcode 11 beta 5 my App does not run anymore. I simply want to synchronize data between two Views.
So I have rewritten my App with the new #Published, but I can't really set it up:
class UserInput: ObservableObject {
#Published var score: Int = 0
}
struct ContentView: View {
#ObservedObject var input = UserInput()
var body: some View {
VStack {
Text("Hello World\(self.input.score)")
Button(action: {self.input.score += 1})
{
Text("Adder")
}
NavigationLink(destination: secondScreen()) {
Text("Next View")
}
}
}
}
struct secondScreen: View {
#ObservedObject var input = UserInput()
var body: some View {
VStack {
Text("Button has been pushed \(input.score)")
Button(action: {self.input.score += 1
}) {
Text("Adder")
}
}
}
}
Your code has a couple of errors:
1) You didn't put your ContentView in a NavigationView, so the navigation between the two views never happened.
2) You used data binding in a wrong way. If you need the second view to rely on some state belonging to the first view you need to pass a binding to that state to the second view. Both in your first view and in your second view you had an #ObservedObject created inline:
#ObservedObject var input = UserInput()
so, the first view and the second one worked with two totally different objects. Instead, you are interested in sharing the score between the views. Let the first view own the UserInput object and just pass a binding to the score integer to the second view. This way both the views will work on the same value (you can copy paste the code below and try yourself).
import SwiftUI
class UserInput: ObservableObject {
#Published var score: Int = 0
}
struct ContentView: View {
#ObservedObject var input = UserInput()
var body: some View {
NavigationView {
VStack {
Text("Hello World\(self.input.score)")
Button(action: {self.input.score += 1})
{
Text("Adder")
}
NavigationLink(destination: secondScreen(score: self.$input.score)) {
Text("Next View")
}
}
}
}
}
struct secondScreen: View {
#Binding var score: Int
var body: some View {
VStack {
Text("Button has been pushed \(score)")
Button(action: {self.score += 1
}) {
Text("Adder")
}
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
If you really need it you can even pass the entire UserInput object to the second view:
import SwiftUI
class UserInput: ObservableObject {
#Published var score: Int = 0
}
struct ContentView: View {
#ObservedObject var input = UserInput() //please, note the difference between this...
var body: some View {
NavigationView {
VStack {
Text("Hello World\(self.input.score)")
Button(action: {self.input.score += 1})
{
Text("Adder")
}
NavigationLink(destination: secondScreen(input: self.input)) {
Text("Next View")
}
}
}
}
}
struct secondScreen: View {
#ObservedObject var input: UserInput //... and this!
var body: some View {
VStack {
Text("Button has been pushed \(input.score)")
Button(action: {self.input.score += 1
}) {
Text("Adder")
}
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
I tried a lot of different approaches on how to pass data from one view to another and came up with a solution that fits for simple and complex views / view models.
Version
Apple Swift version 5.3.1 (swiftlang-1200.0.41 clang-1200.0.32.8)
This solution works with iOS 14.0 upwards, because you need the .onChange() view modifier. The example is written in Swift Playgrounds. If you need an onChange like modifier for lower versions, you should write your own modifier.
Main View
The main view has a #StateObject viewModel handling all of the views logic, like the button tap and the "data" (testingID: String) -> Check the ViewModel
struct TestMainView: View {
#StateObject var viewModel: ViewModel = .init()
var body: some View {
VStack {
Button(action: { self.viewModel.didTapButton() }) {
Text("TAP")
}
Spacer()
SubView(text: $viewModel.testingID)
}.frame(width: 300, height: 400)
}
}
Main View Model (ViewModel)
The viewModel publishes a testID: String?. This testID can be any kind of object (e.g. configuration object a.s.o, you name it), for this example it is just a string also needed in the sub view.
final class ViewModel: ObservableObject {
#Published var testingID: String?
func didTapButton() {
self.testingID = UUID().uuidString
}
}
So by tapping the button, our ViewModel will update the testID. We also want this testID in our SubView and if it changes, we also want our SubView to recognize and handle these changes. Through the ViewModel #Published var testingID we are able to publish changes to our view. Now let's take a look at our SubView and SubViewModel.
SubView
So the SubView has its own #StateObject to handle its own logic. It is completely separated from other views and ViewModels. In this example the SubView only presents the testID from its MainView. But remember, it can be any kind of object like presets and configurations for a database request.
struct SubView: View {
#StateObject var viewModel: SubviewModel = .init()
#Binding var test: String?
init(text: Binding<String?>) {
self._test = text
}
var body: some View {
Text(self.viewModel.subViewText ?? "no text")
.onChange(of: self.test) { (text) in
self.viewModel.updateText(text: text)
}
.onAppear(perform: { self.viewModel.updateText(text: test) })
}
}
To "connect" our testingID published by our MainViewModel we initialize our SubView with a #Binding. So now we have the same testingID in our SubView. But we don't want to use it in the view directly, instead we need to pass the data into our SubViewModel, remember our SubViewModel is a #StateObject to handle all the logic. And we can't pass the value into our #StateObject during view initialization. Also if the data (testingID: String) changes in our MainViewModel, our SubViewModel should recognize and handle these changes.
Therefore we are using two ViewModifiers.
onChange
.onChange(of: self.test) { (text) in
self.viewModel.updateText(text: text)
}
The onChange modifier subscribes to changes in our #Binding property. So if it changes, these changes get passed to our SubViewModel. Note that your property needs to be Equatable. If you pass a more complex object, like a Struct, make sure to implement this protocol in your Struct.
onAppear
We need onAppear to handle the "first initial data" because onChange doesn't fire the first time your view gets initialized. It is only for changes.
.onAppear(perform: { self.viewModel.updateText(text: test) })
Ok and here is the SubViewModel, nothing more to explain to this one I guess.
class SubviewModel: ObservableObject {
#Published var subViewText: String?
func updateText(text: String?) {
self.subViewText = text
}
}
Now your data is in sync between your MainViewModel and SubViewModel and this approach works for large views with many subviews and subviews of these subviews and so on. It also keeps your views and corresponding viewModels enclosed with high reusability.
Working Example
Playground on GitHub:
https://github.com/luca251117/PassingDataBetweenViewModels
Additional Notes
Why I use onAppear and onChange instead of only onReceive: It appears that replacing these two modifiers with onReceive leads to a continuous data stream firing the SubViewModel updateText multiple times. If you need to stream data for presentation, it could be fine but if you want to handle network calls for example, this can lead to problems. That's why I prefer the "two modifier approach".
Personal Note: Please don't modify the StateObject outside the corresponding view's scope. Even if it is somehow possible, it is not what its meant for.
My question is still related to how to pass data between two views but I have a more complicated JSON data set and I am running into problems both with the passing the data and with it's initialization. I have something that works but I am sure it is not correct. Here is the code. Help!!!!
/ File: simpleContentView.swift
import SwiftUI
// Following is the more complicated #ObservedObject (Buddy and class Buddies)
struct Buddy : Codable, Identifiable, Hashable {
var id = UUID()
var TheirNames: TheirNames
var dob: String = ""
var school: String = ""
enum CodingKeys1: String, CodingKey {
case id = "id"
case Names = "Names"
case dob = "dob"
case school = "school"
}
}
struct TheirNames : Codable, Identifiable, Hashable {
var id = UUID()
var first: String = ""
var middle: String = ""
var last: String = ""
enum CodingKeys2: String, CodingKey {
case id = "id"
case first = "first"
case last = "last"
}
}
class Buddies: ObservableObject {
#Published var items: [Buddy] {
didSet {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(items) {UserDefaults.standard.set(encoded, forKey: "Items")}
}
}
#Published var buddy: Buddy
init() {
if let items = UserDefaults.standard.data(forKey: "Items") {
let decoder = JSONDecoder()
if let decoded = try? decoder.decode([Buddy].self, from: items) {
self.items = decoded
// ??? How to initialize here
self.buddy = Buddy(TheirNames: TheirNames(first: "c", middle: "r", last: "c"), dob: "1/1/1900", school: "hard nocks")
return
}
}
// ??? How to initialize here
self.buddy = Buddy(TheirNames: TheirNames(first: "c", middle: "r", last: "c"), dob: "1/1/1900", school: "hard nocks")
self.items = []
}
}
struct simpleContentView: View {
#Environment(\.presentationMode) var presentationMode
#State private var showingSheet = true
#ObservedObject var buddies = Buddies()
var body: some View {
VStack {
Text("Simple View")
Button(action: {self.showingSheet.toggle()}) {Image(systemName: "triangle")
}.sheet(isPresented: $showingSheet) {
simpleDetailView(buddies: self.buddies, item: self.buddies.buddy)}
}
}
}
struct simpleContentView_Previews: PreviewProvider {
static var previews: some View {
simpleContentView()
}
}
// End of File: simpleContentView.swift
// This is in a separate file: simpleDetailView.swift
import SwiftUI
struct simpleDetailView: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var buddies = Buddies()
var item: Buddy
var body: some View {
VStack {
Text(/*#START_MENU_TOKEN#*/"Hello, World!"/*#END_MENU_TOKEN#*/)
Text("First Name = \(item.TheirNames.first)")
Button(action: {self.presentationMode.wrappedValue.dismiss()}){ Text("return"); Image(systemName: "gobackward")}
}
}
}
// ??? Correct way to make preview call
struct simpleDetailView_Previews: PreviewProvider {
static var previews: some View {
// ??? Correct way to call here
simpleDetailView(item: Buddy(TheirNames: TheirNames(first: "", middle: "", last: ""), dob: "", school: "") )
}
}
// end of: simpleDetailView.swift
Using directly #State variable will help you to achieve this, but if you want to sync that variable for both the screens using view model or #Published, this is what you can do. As the #State won't be binded to the #Published property. To achieve this follow these steps.
Step1: - Create a delegate to bind the value on pop or disappearing.
protocol BindingDelegate {
func updateOnPop(value : Int)
}
Step 2:- Follow the code base for Content View
class UserInput: ObservableObject {
#Published var score: Int = 0
}
struct ContentView: View , BindingDelegate {
#ObservedObject var input = UserInput()
#State var navIndex : Int? = nil
var body: some View {
NavigationView {
VStack {
Text("Hello World\(self.input.score)")
Button(action: {self.input.score += 1}) {
Text("Adder")
}
ZStack {
NavigationLink(destination: secondScreen(score: self.$input.score,
del: self, navIndex: $navIndex),
tag: 1, selection: $navIndex) {
EmptyView()
}
Button(action: {
self.navIndex = 1
}) {
Text("Next View")
}
}
}
}
}
func updateOnPop(value: Int) {
self.input.score = value
}
}
Step 3: Follow these steps for secondScreen
final class ViewModel : ObservableObject {
#Published var score : Int
init(_ value : Int) {
self.score = value
}
}
struct secondScreen: View {
#Binding var score: Int
#Binding var navIndex : Int?
#ObservedObject private var vm : ViewModel
var delegate : BindingDelegate?
init(score : Binding<Int>, del : BindingDelegate, navIndex : Binding<Int?>) {
self._score = score
self._navIndex = navIndex
self.delegate = del
self.vm = ViewModel(score.wrappedValue)
}
private var btnBack : some View { Button(action: {
self.delegate?.updateOnPop(value: self.vm.score)
self.navIndex = nil
}) {
HStack {
Text("Back")
}
}
}
var body: some View {
VStack {
Text("Button has been pushed \(vm.score)")
Button(action: {
self.vm.score += 1
}) {
Text("Adder")
}
}
.navigationBarBackButtonHidden(true)
.navigationBarItems(leading: btnBack)
}
}