How to make a SwiftUI NavigationLink conditional based on an Optional Object? - swiftui

Let’s say I have a model class Ball, that conforms to the ObservableObject protocol, and I define an optional instance of it (var ball: Ball?).
Is there a way to trigger a NavigationLink to display its destination view based on setting the value of the optional instance? So when I self.ball = Ball(), a NavigationLink will trigger?
One problem seems to be that an optional (Type?) can’t be an #ObservedObject.
The other problem seems to be that the isActive: parameter for NavigationLink can only take a Binding<Bool>.
/// Contrived minimal example to illustrate the problem.
include SwiftUI
class Ball: ObservableObject {
#Published var colour: String = "red"
// ...
}
struct ContentView: View {
// this won’t trigger view updates when it’s set because it’s not observed:
var ball: Ball?
// this line won’t compile:
#ObservedObject var observedBall: Ball?
// 🛑 Property type 'Ball?' does not match that of the
// 'wrappedValue' property of its wrapper type 'ObservedObject'
var body: some View {
NavigationView {
VStack {
// I want this to navigate to the ballView when isActive becomes true,
// but it won’t accept the test of nil state on the optional value:
NavigationLink(
destination: BallView(ball: self.ball), isActive: self.ball != nil
) {
EmptyView()
} // 🛑 compiler error because `self.ball != nil` isn’t valid for `isActive:`
// Button user taps to set the `ball`,
// which I want to trigger the BallView to be shown.
Button(action: { self.ball = Ball() }, label: { Text("Show Ball") })
}
}
}
}
struct BallView: View {
#ObservedObject var ball: Ball
// typical view stuff here ...
}

So far, the best workaround I have for the above limitations involves:
defining another ObservableObject-conforming class as a wrapper around an Optional Ball instance,
adding a #State var ballIsSet = false (or #Binding var ballIsSet: Bool) variable to my view,
passing in that ballIsSet boolean variable to the wrapper class object,
then having a didSet function on the wrapped Ball variable that updates the passed in boolean.
Phew!
Hopefully someone knows a simpler/better way to do this…
// Still a somewhat contrived example, but it illustrates the points…
class ObservableBall: ObservableObject {
// a closure to call when the ball variable is set
private var whenSetClosure: ((Bool) -> Void)?
#Published var ball: Ball? {
didSet {
// if the closure is assigned, call it when the ball gets set
if let setClosure = self.whenSetClosure {
setClosure(self.ball != nil)
}
}
}
init(_ ball: Ball? = nil, whenSetClosure: ((Bool) -> Void)? = nil) {
self.ball = ball
self.whenSetClosure = whenSetClosure
}
}
struct ContentView: View {
#ObservedObject var observedBall = ObservableBall()
#State var ballIsSet = false
var body: some View {
NavigationView {
VStack {
// Navigate to the ballView when ballIsSet becomes true:
NavigationLink(
// we can force unwrap observedBall.ball! here
// because this only gets called if ballIsSet is true:
destination: BallView(ball: self.observedBall.ball!),
isActive: $ballIsSet
) {
EmptyView()
}
// Button user taps to set the `ball`,
// triggering the BallView to be shown.
Button(
action: { self.observedBall.ball = Ball() },
label: { Text("Show Ball") }
)
}
.onAppear {
observedBall.whenSetClosure = { isSet in
self.ballIsSet = isSet
}
}
}
}
}

You can use the init(get:set:) initializer of the Binding to activate the destination view based on the optional instance or on an arbitrary conditional logic. For example:
struct Ball {
var colour: String = "red"
// ...
}
struct ContentView: View {
#State private var ball: Ball?
var body: some View {
NavigationLink(isActive: Binding<Bool>(get: {
ball != nil
}, set: { _ in
ball = nil
})) {
DestinationView()
} label: {
EmptyView()
}
}
}
I've used struct Ball instead of class Ball: ObservableObject, since #ObservedObject ball: Ball? represents a value semantic of type Optional<Ball>, thus cannot be combined with #ObservedObject or #StateObject, which are property wrappers for reference types. The value types (i.e., Optinal<Ball>) can be used with #State or #Binding, but this is most likely not what you want with an optional observable object.

I encountered similar problem and the way I tried to get my NavigationLink to render based on a nullable object is to wrap around the NavigationLink if a optional binding to that optional object, then attach the onAppear callback to modify the isActive binding boolean to turn on the navigation link (so that it become active after it added to the view hierarchy and thus keep the transition animation)
class ObservableBall: ObservableObject {
#Published var ball: Ball?
#Published var showBallView = false
}
struct ContentView: View {
#ObservedObject var observedBall: Ball
var body: some View {
NavigationView {
VStack {
if let ball = observedBall.ball {
NavigationLink(
destination: BallView(ball: ball),
isActive: $observedBall.showBallView)
{
EmptyView()
}
.onAppear {
observedBall.showBallView = true
}
}
// Button user taps to set the `ball`,
// which I want to trigger the BallView to be shown.
Button(action: { self.observedBall.ball = Ball() }, label: { Text("Show Ball") })
}
}
}
}
struct BallView: View {
#ObservedObject var ball: Ball
// typical view stuff 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)

SwiftUI NavigationLink with constant binding for isActive

I don't understand why SwiftUI NavigationLink's isActive behaves as if it has it's own state. Even though I pass a constant to it, the back button overrides the value of the binding once pressed.
Code:
import Foundation
import SwiftUI
struct NavigationLinkPlayground: View {
#State
var active = true
var body: some View {
NavigationView {
VStack {
Text("Navigation Link playground")
Button(action: { active.toggle() }) {
Text("Toggle")
}
Spacer()
.frame(height: 40)
FixedNavigator(active: active)
}
}
}
}
fileprivate struct FixedNavigator: View {
var active: Bool = true
var body: some View {
return VStack {
Text("Fixed navigator is active: \(active)" as String)
NavigationLink(
destination: SecondScreen(),
// this is technically a constant!
isActive: Binding(
get: { active },
set: { newActive in print("User is setting to \(newActive), but we don't let them!") }
),
label: { Text("Go to second screen") }
)
}
}
}
fileprivate struct SecondScreen: View {
var body: some View {
Text("Nothing to see here")
}
}
This is a minimum reproducible example, my actual intention is to handle the back button press manually. So when the set inside the Binding is called, I want to be able to decide when to actually proceed. (So like based on some validation or something.)
And I don't understand what is going in and why the back button is able to override a constant binding.
Your use of isActive is wrong. isActive takes a binding boolean and whenever you set that binding boolean to true, the navigation link gets activated and you are navigated to the destination.
isActive does not control whether the navigation link is clickable/disbaled or not.
Here's an example of correct use of isActive. You can manually trigger the navigation to your second view by setting activateNavigationLink to true.
EDIT 1:
In this new sample code, you can disable and enable the back button at will as well:
struct ContentView: View {
#State var activateNavigationLink = false
var body: some View {
NavigationView {
VStack {
// This isn't visible and should take 0 space from the screen!
// Because its `label` is an `EmptyView`
// It'll get programmatically triggered when you set `activateNavigationLink` to `true`.
NavigationLink(
destination: SecondScreen(),
isActive: $activateNavigationLink,
label: EmptyView.init
)
Text("Fixed navigator is active: \(activateNavigationLink)" as String)
Button("Go to second screen") {
activateNavigationLink = true
}
}
}
}
}
fileprivate struct SecondScreen: View {
#State var backButtonActivated = false
var body: some View {
VStack {
Text("Nothing to see here")
Button("Back button is visible: \(backButtonActivated)" as String) {
backButtonActivated.toggle()
}
}
.navigationBarBackButtonHidden(!backButtonActivated)
}
}

SwiftUI NavigationLink behaviour is different when selection is set directly, rather than from viewModel

I've been working with SwiftUI and ran into unexpected behavior.
I have View A and View B and View C. View C has EnviromentObject that changes AppState from View A
View B has ViewModel with selection
If I call function from ViewModel to change the selection then
View C is shown for a few seconds and then it automatically pops back to View B
If I change selection directly from View B (not from ViewModel), everything works as expected.
Also, if I comment out onDissapear, it also works. But, I need to change environmentObject when screen dissapeared
Here is View B and ViewModel
import SwiftUI
class AppState: ObservableObject {
#Published
var shouldHideUserInfo = false
}
struct ContentView: View {
#EnvironmentObject
var appState: AppState
#State
var selection: Int? = nil
var body: some View {
NavigationView {
VStack {
if !appState.shouldHideUserInfo {
Text("USER INFO")
}
NavigationLink(
destination: ViewA(),
tag: 1,
selection: $selection,
label: { EmptyView()})
Button("MOVE TO VIEW A") {
selection = 1
}
}
}
}
}
class ViewAModel: ObservableObject {
#Published
var selection: Int? = nil
func navigate() {
selection = 2 //<- this doesnt
}
}
struct ViewA: View {
#ObservedObject
var viewModel: ViewAModel
init() {
viewModel = ViewAModel()
}
#State
var selection: Int? = nil //<- this works
var body: some View {
VStack
{
Text("VIEW A")
NavigationLink(
destination: ViewB(),
tag: 2,
selection: $viewModel.selection,
label: { EmptyView()})
Button("MOVE TO VIEW B") {
//selection = 2 <-- this works
viewModel.navigate() //<- this doesnt
}
}
}
}
struct ViewB: View {
#EnvironmentObject
var appState: AppState
#State
var selection: Int? = nil
var body: some View {
VStack
{
Text("VIEW B")
}
.onAppear {
appState.shouldHideUserInfo = true
}
}
}
Factory pattern didn't solve the issue:
static func makeViewA(param: Int?) -> some View {
let viewModel = ViewAModel(param: param)
return ViewA(viewModel: viewModel)
}
}
I see... it is a bit different than in post. The issue is because view model is recreated (this is long observed behavior of NavigationView) and thus binding lost.
The quick fix is
struct ViewA: View {
#StateObject
var viewModel: ViewAModel = ViewAModel()
init() {
// viewModel = ViewAModel()
}
// ... other code
}
alternate is to keep ownership of view model outside of ViewA.
Update: SwiftUI 1.0 compatible - here is variant that works everywhere. The reason of the issue is in AppState. The code in ViewB updates appState
.onAppear {
appState.shouldHideUserInfo = true
}
that causes rebuild of ContentView body, which recreates ViewA, which recreates NavigationLink, which drops previous link and ViewB got closed.
To prevent this we need to avoid rebuild ViewA. This can be done by making ViewA is-a Equatable, so SwiftUI check if ViewA needs to be recreated and we will answer NO.
Here is how it goes:
NavigationLink(
destination: ViewA().equatable(), // << here !!
tag: 1,
selection: $selection,
label: { EmptyView()})
and
struct ViewA: View, Equatable {
static func == (lhs: ViewA, rhs: ViewA) -> Bool {
true
}
// .. other code

(SwiftUI change detection) What is wrong with this piece of code?

When debugging an issue with an app I am working on, I managed to shrink it down to this minimal example:
class RadioModel: ObservableObject {
#Published var selected: Int = 0
}
struct RadioButton: View {
let idx: Int
#EnvironmentObject var radioModel: RadioModel
var body: some View {
Button(action: {
self.radioModel.selected = self.idx
}, label: {
if radioModel.selected == idx {
Text("Button \(idx)").background(Color.yellow)
} else {
Text("Button \(idx)")
}
})
}
}
struct RadioListTest: View {
#ObservedObject var radioModel = RadioModel()
var body: some View {
return VStack {
Text("You selected: \(radioModel.selected)")
RadioButton(idx: 0)
RadioButton(idx: 1)
RadioButton(idx: 2)
}.environmentObject(radioModel)
}
}
struct ContentView: View {
#State var refreshDate = Date()
func refresh() {
print("Refreshing...")
self.refreshDate = Date()
}
var body: some View {
VStack {
Text("\(refreshDate)")
HStack {
Button(action: {
self.refresh()
}, label: {
Text("Refresh")
})
RadioListTest()
}
}
}
}
This code looks pretty reasonable to me, although it exhibit a peculiar bug: when I hit the Refresh button, the radio buttons stop working. The radio buttons are not refreshed, and keep a reference to the old RadioModel instance, so when I click them they update that, and not the new one created after Refresh causes a new RadioListTest to be constructed. I suspect there is something wrong in the way I use EnvironmentObjects but I didn't find any reference suggesting that what I am doing is wrong. I know I could fix this particular problem in various ways that force a refresh in the radio buttons, but I would like to be able to understand which cases require a refresh forcing hack, I can't sprinkle the code with these just because "better safe than sorry", the performance is going to be hell if I have to redraw everything every time I make a modification.
edit: a clarification. The thing that is weird in my opinion and for which I would want an explanation, is this: why on refresh the RadioListTest is re-created (together with a new RadioModel) and its body re-evaluated but RadioButtons are created and the body properties are not evaluated, but the previous body is used. They both have only a view model as state, the same view model actually, but one have it as ObservedObject and the other as EnvironmentObject. I suspect it is a misuse of EnvironmentObject that I am doing, but I can't find any reference to why it is wrong
this works: (yes, i know, you know how to solve it, but i think this would be the "right" way.
problem is this line:
struct RadioListTest: View {
#ObservedObject var radioModel = RadioModel(). <<< problem
because the radioModel will be newly created each time the RadioListTest view is refreshed, so just create the instance one view above and it won't be created on every refresh (or do you want it to be created every time?!)
class RadioModel: ObservableObject {
#Published var selected: Int = 0
init() {
print("init radiomodel")
}
}
struct RadioButton<Content: View>: View {
let idx: Int
#EnvironmentObject var radioModel: RadioModel
var body: some View {
Button(action: {
self.radioModel.selected = self.idx
}, label: {
if radioModel.selected == idx {
Text("Button \(idx)").background(Color.yellow)
} else {
Text("Button \(idx)")
}
})
}
}
struct RadioListTest: View {
#EnvironmentObject var radioModel: RadioModel
var body: some View {
return VStack {
Text("You selected: \(radioModel.selected)")
RadioButton<Text>(idx: 0)
RadioButton<Text>(idx: 1)
RadioButton<Text>(idx: 2)
}.environmentObject(radioModel)
}
}
struct ContentView: View {
#ObservedObject var radioModel = RadioModel()
#State var refreshDate = Date()
func refresh() {
print("Refreshing...")
self.refreshDate = Date()
}
var body: some View {
VStack {
Text("\(refreshDate)")
HStack {
Button(action: {
self.refresh()
}, label: {
Text("Refresh")
})
RadioListTest().environmentObject(radioModel)
}
}
}
}
What is wrong with this piece of code?
Your RadioListTest subview is not updated on refresh() because it does not depend on changed parameter (refreshDate in this case), so SwiftUI rendering engine assume it is equal to previously created and does nothing with it:
HStack {
Button(action: {
self.refresh()
}, label: {
Text("Refresh")
})
RadioListTest() // << here !!
}
so the solution is to make this view dependent somehow on changed parameter, if it is required of course, and here fixed variant
RadioListTest().id(refreshDate)

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)
}
}