Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

    }
}
like image 737
Hansel Avatar asked Aug 08 '19 14:08

Hansel


Video Answer


2 Answers

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
like image 126
matteopuc Avatar answered Oct 13 '22 22:10

matteopuc


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.

like image 1
Luca Avatar answered Oct 13 '22 22:10

Luca