Initialize @StateObject with a parameter in SwiftUI

前端 未结 5 1820
无人共我
无人共我 2021-01-03 20:29

I would like to know if there is currently (at the time of asking, the first Xcode 12.0 Beta) a way to initialize a @StateObject with a parameter coming from an

相关标签:
5条回答
  • 2021-01-03 20:46

    The answer given by @Asperi should be avoided Apple says so in their documentation for StateObject.

    You don’t call this initializer directly. Instead, declare a property with the @StateObject attribute in a View, App, or Scene, and provide an initial value.

    Apple tries to optimize a lot under the hood, don't fight the system.

    Just create an ObservableObject with a Published value for the parameter you wanted to use in the first place. Then use the .onAppear() to set it's value and SwiftUI will do the rest.

    Code:

    class SampleObject: ObservableObject {
        @Published var id: Int = 0
    }
    
    struct MainView: View {
        @StateObject private var sampleObject = SampleObject()
        
        var body: some View {
            Text("Identifier: \(sampleObject.id)")
                .onAppear() {
                    sampleObject.id = 9000
                }
        }
    }
    
    0 讨论(0)
  • 2021-01-03 20:53

    Like @Mark pointed out, you should not handle @StateObject anywhere during initialization. That is because the @StateObject gets initialized after the View.init() and slightly before/after the body gets called.

    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, like I wrote in the beginning. 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.

    0 讨论(0)
  • 2021-01-03 20:55

    I know there is already an accepted answer here, but I have to agree with @malhal on this one. I think the init is going to get called multiple times, which is the opposite behaviour of @StateObject intentions.

    I don't really have a good solution for @StateObjects at the moment, but I was trying to use them in the @main App as the initialisation point for @EnvironmentObjects. My solution was not to use them. I am putting this answer here for people who are trying to do the same thing as me.

    I struggled with this for quite a while before coming up with the following:

    These two let declarations are at the file level

    private let keychainManager = KeychainManager(service: "com.serious.Auth0Playground")
    private let authenticatedUser = AuthenticatedUser(keychainManager: keychainManager)
    
    @main
    struct Auth0PlaygroundApp: App {
    
        var body: some Scene {
        
            WindowGroup {
                ContentView()
                    .environmentObject(authenticatedUser)
            }
        }
    }
    

    This is the only way I have found to initialise an environmentObject with a parameter. I cannot create an authenticatedUser object without a keychainManager and I am not about to change the architecture of my whole App to make all my injected objects not take a parameter.

    0 讨论(0)
  • 2021-01-03 21:05

    I guess I found a workaround for being able to control the instantiation of a view model wrapped with @StateObject. If you don't make the view model private on the view you can use the synthesized memberwise init, and there you'll be able to control the instantiation of it without problem. In case you need a public way to instantiate your view, you can create a factory method that receives your view model dependencies and uses the internal synthesized init.

    import SwiftUI
    
    class MyViewModel: ObservableObject {
        @Published var message: String
    
        init(message: String) {
            self.message = message
        }
    }
    
    struct MyView: View {
        @StateObject var viewModel: MyViewModel
    
        var body: some View {
            Text(viewModel.message)
        }
    }
    
    public func myViewFactory(message: String) -> some View {
        MyView(viewModel: .init(message: message))
    }
    
    0 讨论(0)
  • 2021-01-03 21:13

    Here is a demo of solution. Tested with Xcode 12b.

    class MyObject: ObservableObject {
        @Published var id: Int
        init(id: Int) {
            self.id = id
        }
    }
    
    struct MyView: View {
        @StateObject private var object: MyObject
        init(id: Int = 1) {
            _object = StateObject(wrappedValue: MyObject(id: id))
        }
    
        var body: some View {
            Text("Test: \(object.id)")
        }
    }
    
    0 讨论(0)
提交回复
热议问题