Computed Property from Child's ViewModel does not update #ObservedObject Parent's ViewModel - ios

I have a parent view and a child view, each with their own viewModels. The parent view injects the child view's viewModel.
The parent view does not correctly react to the changes on the child's computed property isFormInvalid (the child view does).
#Published cannot be added to a computed property, and other questions/answers I've seen around that area have not focused on having separate viewModels as this question does. I want separate viewModels to increase testability, since the child view could become quite a complex form.
Here is a file to minimally reproduce the issue:
import SwiftUI
extension ParentView {
final class ViewModel: ObservableObject {
#ObservedObject var childViewViewModel: ChildView.ViewModel
init(childViewViewModel: ChildView.ViewModel = ChildView.ViewModel()) {
self.childViewViewModel = childViewViewModel
}
}
}
struct ParentView: View {
#ObservedObject private var viewModel: ViewModel
init(viewModel: ViewModel = ViewModel()) {
self.viewModel = viewModel
}
var body: some View {
ChildView(viewModel: viewModel.childViewViewModel)
.navigationBarTitle("Form", displayMode: .inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
addButton
}
}
}
private var addButton: some View {
Button {
print("======")
print(viewModel.childViewViewModel.$name)
} label: {
Text("ParentIsValid?")
}
.disabled(viewModel.childViewViewModel.isFormInvalid) // FIXME: doesn't work, but the actual fields work in terms of two way updating
}
}
struct ParentView_Previews: PreviewProvider {
static var previews: some View {
let childVm = ChildView.ViewModel()
let vm = ParentView.ViewModel(childViewViewModel: childVm)
NavigationView {
ParentView(viewModel: vm)
}
}
}
// MARK: child view
extension ChildView {
final class ViewModel: ObservableObject {
// MARK: - public properties
#Published var name = ""
var isFormInvalid: Bool {
print("isFormInvalid")
return name.isEmpty
}
}
}
struct ChildView: View {
#ObservedObject private var viewModel: ViewModel
init(viewModel: ViewModel = ViewModel()) {
self.viewModel = viewModel
}
var body: some View {
Form {
Section(header: Text("Name")) {
nameTextField
}
Button {} label: {
Text("ChildIsValid?: \(String(!viewModel.isFormInvalid))")
}
.disabled(viewModel.isFormInvalid)
}
}
private var nameTextField: some View {
TextField("Add name", text: $viewModel.name)
.autocapitalization(.words)
}
}
struct ChildView_Previews: PreviewProvider {
static var previews: some View {
let vm = ChildView.ViewModel()
ChildView(viewModel: vm).preferredColorScheme(.light)
}
}
Thank you!

Computed properties do not trigger any updates. It is the changed to #Publised property that triggers an update, when that happens the computed property is reevaluated. This works as expected which you can see in your ChildView. The problem you are facing is that ObservableObjects are not really designed for chaining (updated to child does not trigger update to the parent. You can workaround the fact by republishing an update from the child: you have subscribe to child's objectWillChange and each time it emits manually trigger objectWillChange on the parent.
extension ParentView {
final class ViewModel: ObservableObject {
#ObservedObject var childViewViewModel: ChildView.ViewModel
private var cancellables = Set<AnyCancellable>()
init(childViewViewModel: ChildView.ViewModel = ChildView.ViewModel()) {
self.childViewViewModel = childViewViewModel
childViewViewModel
.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
}
}

Related

In SwiftUI how do I update a Published property inside ViewModel1 from ViewModel2?

Fairly new to SwiftUI and trying to figure out how to use ViewModels. Coming from UIKit I tend to like binding button presses to view model events, then apply the business logic and return a new value.
I am trying this in SwiftUI:
struct MainView: View {
#ObservedObject private var viewModel: MainViewModel
#State private var isShowingBottomSheet = false
var body: some View {
VStack {
Text("Hello \(viewModel.username)")
.font(.title)
Button("Show bottom sheet") {
isShowingBottomSheet = true
}
.sheet(isPresented: $isShowingBottomSheet) {
let viewModel = SheetViewModel()
viewModel.event.usernameUpdated
.assign(to: &$viewModel.username)
SheetView(viewModel: viewModel)
.presentationDetents([.fraction(0.15), .medium])
}
}
}
// MARK: - Initializers
init(viewModel: MainViewModel) {
self.viewModel = viewModel
}
}
With the view model:
final class MainViewModel: ObservableObject {
// MARK: - Properties
#Published var username = "John"
}
And SheetView:
struct SheetView: View {
#ObservedObject private var viewModel: SheetViewModel
var body: some View {
VStack {
Text("Some Sheet")
.font(.title)
Button("Change Name") {
viewModel.event.updateUsernameButtonTapped.send(())
}
}
}
// MARK: - Initializers
init(viewModel: SheetViewModel) {
self.viewModel = viewModel
}
}
And SheetViewModel:
final class SheetViewModel: ObservableObject {
// MARK: - Events
struct Event {
let updateUsernameButtonTapped = PassthroughSubject<Void, Never>()
let usernameUpdated = PassthroughSubject<String, Never>()
}
// MARK: - Properties
let event = Event()
private var cancellables = Set<AnyCancellable>()
// MARK: - Binding
private func bindEvents() {
event.updateUsernameButtonTapped
.map { "Sam" }
.sink { [weak self] name in
self?.event.usernameUpdated.send(name)
}
.store(in: &cancellables)
}
}
I am getting the error Cannot convert value of type 'Binding<String>' to expected argument type 'Published<String>.Publisher'. I want my SheetViewModel to update the value of #Published var username in the MainViewModel. How would I go about this?
We usually don't need view model objects in SwiftUI which has a design that benefits from value semantics, rather than the more error prone reference semantics of UIKit. If you want to move logic out of the View struct you can group related state vars and mutating funcs in their own struct, e.g.
struct ContentView: View {
#State var config = SheetConfig()
var body: some View {
VStack {
Text(config.text)
Button(action: show) {
Text("Edit Text")
}
}
.sheet(isPresented: $config.isShowing,
onDismiss: didDismiss) {
TextField("Text", $config.text)
}
}
func show() {
config.show(initialText: "Hello")
}
func didDismiss() {
// Handle the dismissing action.
}
}
struct SheetConfig {
var text = ""
var isShowing = false
mutating func show(initialText: String) {
text = initialText
isShowing = true
}
}
If you want to persist/sync data, or use Combine then you will need to resort to the reference type version of state which is #StateObject. However if you use the new async/await and .task then it's possible to still not need it.

#EnvironmentObject with parent child classes

I am trying to create a global loader which needs to work across all loading activity in the app. Created a baseStore with loading property which holds the boolean indicator for whole app. All other stores inherit BaseStore.
When trying to update the published variable from child views its not updating header indicator. Any ideas what am I doing wrong?
class BaseStore : ObservableObject {
#Published var isLoading = false
}
class ChildStore: BaseStore {
func update(){
super.isLoading = true
}
}
somewhere in the view
import SwiftUI
struct HeaderView: View {
#EnvironmentObject var baseStore: BaseStore
var body: some View {
if(baseStore.isLoading == true){
ProgressView()
.scaleEffect(0.6, anchor: .center).progressViewStyle(ConfiguredProgressViewStyle())
}
}
}
struct HeaderView_Previews: PreviewProvider {
static var previews: some View {
return HeaderView()
.environmentObject(BaseStore());
}
}
struct MainView: View {
#EnvironmentObject var childStore: ChildStore
var body: some View {
}.onAppear(perform: {childStore.update()})
}

Using #StateObject in iOS 14.0 while supporting iOS 13.0

I need help finding the best way to support the new #StateObject in iOS 14.0 and still supporting some alternative in iOS 13.0. Admittedly, I do not know what is the best approach in iOS 13.0. Below is what I currently have.
Does anyone have ideas on a better approach?
struct HomeView: View {
let viewModel: HomeViewModel
var body: some View {
if #available(iOS 14, *) {
HomeViewWrapper(viewModel: viewModel)
} else {
CompatibleHomeViewWrapper(viewModel: viewModel)
}
}
}
#available(iOS 14, *)
private struct HomeViewWrapper: View {
#StateObject var viewModel: HomeViewModel
var body: some View {
CompatibleHomeView(viewModel: viewModel)
}
}
private struct CompatibleHomeViewWrapper: View {
#State var viewModel: HomeViewModel
var body: some View {
CompatibleHomeView(viewModel: viewModel)
}
}
struct CompatibleHomeView: View {
#ObservedObject var viewModel: HomeViewModel
var body: some View {
Text(viewModel.someRandomName)
}
}
You can get the #StateObject behaviour by wrapping a custom propertyWrapper around #State and #ObservedObject like so:
import Combine
import PublishedObject // https://github.com/Amzd/PublishedObject
/// A property wrapper type that instantiates an observable object.
#propertyWrapper
public struct StateObject<ObjectType: ObservableObject>
where ObjectType.ObjectWillChangePublisher == ObservableObjectPublisher {
/// Wrapper that helps with initialising without actually having an ObservableObject yet
private class ObservedObjectWrapper: ObservableObject {
#PublishedObject var wrappedObject: ObjectType? = nil
init() {}
}
private var thunk: () -> ObjectType
#ObservedObject private var observedObject = ObservedObjectWrapper()
#State private var state = ObservedObjectWrapper()
public var wrappedValue: ObjectType {
if state.wrappedObject == nil {
// There is no State yet so we need to initialise the object
state.wrappedObject = thunk()
}
if observedObject.wrappedObject == nil {
// Retrieve the object from State and observe it in ObservedObject
observedObject.wrappedObject = state.wrappedObject
}
return state.wrappedObject!
}
public init(wrappedValue thunk: #autoclosure #escaping () -> ObjectType) {
self.thunk = thunk
}
}
I use this myself too so I will keep it updated at:
https://gist.github.com/Amzd/8f0d4d94fcbb6c9548e7cf0c1493eaff
Note: The most upvoted comment was that ObservedObject was very similar, which is just not true at all.
StateObject retains the object between view inits AND relays object changes to the view through willChangeObserver.
ObservedObject only relays changes to the view BUT if you create it in the view init, every time the parent view changes the object will be initialised again (losing your state).
This explanation is very crude and there is better out there please read up on it as it is an impactful part of SwiftUI.
struct StateObjectView<ViewModel: ObservableObject, Content: View>: View {
let viewModel: ViewModel
let content: () -> Content
var body: some View {
VStack {
if #available(iOS 14, *) {
StateObjectView14(viewModel: viewModel, content: content)
} else {
StateObjectView13(viewModel: viewModel, content: content)
}
}
}
}
#available(iOS 14.0, *)
struct StateObjectView14<ViewModel: ObservableObject, Content: View>: View {
#SwiftUI.StateObject var viewModel : ViewModel
let content: () -> Content
var body: some View {
content()
.environmentObject(viewModel)
}
}
struct StateObjectView13<ViewModel: ObservableObject, Content: View>: View {
#Backport.StateObject var viewModel : ViewModel
let content: () -> Content
var body: some View {
content()
.environmentObject(viewModel)
}
}
Usage:
struct ContentView: View {
#State private var reset = false
var body: some View {
VStack {
Button("reset") {
reset.toggle()
}
ScoreView()
}
}
}
class ScoreViewModel: ObservableObject {
init() {
//score = 0
print("Model Created")
}
#Published var score: Int = 0
}
struct ScoreView: View {
let viewModel : ScoreViewModel = .init()
var body: some View {
StateObjectView(viewModel: viewModel) {
ScoreContentView()
}
.onAppear {
print("ScoreView Appear")
}
}
}
struct ScoreContentView: View {
//#ObservedObject var viewModel : ScoreViewModel = .init()
#EnvironmentObject var viewModel : ScoreViewModel
#State private var niceScore = false
var body: some View {
VStack {
Button("Add Score") {
viewModel.score += 1
print("viewModel.score: \(viewModel.score)")
if viewModel.score > 3 {
niceScore = true
}
}
Text("Content Score: \(viewModel.score)")
Text("Nice? \(niceScore ? "YES" : "NO")")
}
.padding()
.background(Color.red)
}
}
Backports:
https://github.com/shaps80/SwiftUIBackports

SwiftUI: calling a method from nested view

I am pretty nw to SwiftUI, and to make my code more readable, I usually break my views into smaller views.
Let's say I have a viewModel, what is the best way to call a method of my viewModel from a nested view? Right now I am passing around my ViewModel as a parameter of every nested view, but I don't find it very optimal and clean...
Is there a way to notify MyMainView that the button of HeaderSubview was tapped ? Can I use Combine for example?
ViewModel
class MyViewModel {
func fetchSomeData() {
print("Fetching Some Data")
}
}
MainView
struct MyMainView: View {
var myViewModel = MyViewModel()
var body: some View {
HeaderView(viewModel: myViewModel)
}
}
struct HeaderView: View {
var viewModel: MyViewModel
var body: some View {
HeaderSubview(viewModel: viewModel)
}
}
struct HeaderSubview: View {
var viewModel: MyViewModel
var body: some View {
Button("Search") {
// I want to call my View Model method here
viewModel.fetchSomeData()
}
}
}
You can make your class ObservableObject and inject it as environment object, so any subview can use it and main view would be able to observe it.
Something like
class MyViewModel: ObservableObject {
#Published var loading = false
func fetchSomeData() {
loading = true
DispatchQueue.global(qos: .background).async {
print("Fetching Some Data")
// ... long activity here
DispatchQueue.main.async { [weak self] in
self?.loading = false
}
}
}
}
struct MyMainView: View {
#StateObject var myViewModel = MyViewModel()
var body: some View {
HeaderView().environmentObject(myViewModel)
if myViewModel.loading {
Text("Loading...")
}
}
}
struct HeaderView: View {
var body: some View {
HeaderSubview()
}
}
struct HeaderSubview: View {
#EnvironmentObject var viewModel: MyViewModel
var body: some View {
Button("Search") {
// I want to call my View Model method here
viewModel.fetchSomeData()
}
}
}

SwiftUI MVVM: child view model re-initialized when parent view updated

I'm attempting to use MVVM in a SwiftUI app, however it appears that view models for child views (e.g. ones in a NavigationLink) are re-initialized whenever an ObservableObject that's observed by both the parent and child is updated. This causes the child's local state to be reset, network data to be reloaded, etc.
I'm guessing it's because this causes parent's body to be reevaluated, which contains a constructor to SubView's view model, but I haven't been able to find an alternative that lets me create view models that don't live beyond the life of the view. I need to be able to pass data to the child view model from the parent.
Here's a very simplified playground of what we're trying to accomplish, where incrementing EnvCounter.counter resets SubView.counter.
import SwiftUI
import PlaygroundSupport
class EnvCounter: ObservableObject {
#Published var counter = 0
}
struct ContentView: View {
#ObservedObject var envCounter = EnvCounter()
var body: some View {
VStack {
Text("Parent view")
Button(action: { self.envCounter.counter += 1 }) {
Text("EnvCounter is at \(self.envCounter.counter)")
}
.padding(.bottom, 40)
SubView(viewModel: .init())
}
.environmentObject(envCounter)
}
}
struct SubView: View {
class ViewModel: ObservableObject {
#Published var counter = 0
}
#EnvironmentObject var envCounter: EnvCounter
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
Text("Sub view")
Button(action: { self.viewModel.counter += 1 }) {
Text("SubView counter is at \(self.viewModel.counter)")
}
Button(action: { self.envCounter.counter += 1 }) {
Text("EnvCounter is at \(self.envCounter.counter)")
}
}
}
}
PlaygroundPage.current.setLiveView(ContentView())
A new property wrapper is added to SwiftUI in Xcode 12, #StateObject. You should be able to fix it by simply changing #ObservedObject for #StateObject as follows.
struct SubView: View {
class ViewModel: ObservableObject {
#Published var counter = 0
}
#EnvironmentObject var envCounter: EnvCounter
#StateObject var viewModel: ViewModel // change on this line
var body: some View {
// ...
}
}
To solve this problem I created a custom helper class called ViewModelProvider.
The provider takes a hash for your view, and a method that builds the ViewModel. It then either returns the ViewModel, or builds it if its the first time that it received that hash.
As long as you make sure the hash stays the same as long as you want the same ViewModel, this solves the problem.
class ViewModelProvider {
private static var viewModelStore = [String:Any]()
static func makeViewModel<VM>(forHash hash: String, usingBuilder builder: () -> VM) -> VM {
if let vm = viewModelStore[hash] as? VM {
return vm
} else {
let vm = builder()
viewModelStore[hash] = vm
return vm
}
}
}
Then in your View, you can use the ViewModel:
Struct MyView: View {
#ObservedObject var viewModel: MyViewModel
public init(thisParameterDoesntChangeVM: String, thisParameterChangesVM: String) {
self.viewModel = ViewModelProvider.makeViewModel(forHash: thisParameterChangesVM) {
MOFOnboardingFlowViewModel(
pages: pages,
baseStyleConfig: style,
buttonConfig: buttonConfig,
onFinish: onFinish
)
}
}
}
In this example, there are two parameters. Only thisParameterChangesVM is used in the hash. This means that even if thisParameterDoesntChangeVM changes and the View is rebuilt, the view model stays the same.
I was having the same problem, your guesses are right, SwiftUI computes all your parent body every time its state changes. The solution is moving the child ViewModel init to the parent's ViewModel, this is the code from your example:
class EnvCounter: ObservableObject {
#Published var counter = 0
#Published var subViewViewModel = SubView.ViewModel.init()
}
struct CounterView: View {
#ObservedObject var envCounter = EnvCounter()
var body: some View {
VStack {
Text("Parent view")
Button(action: { self.envCounter.counter += 1 }) {
Text("EnvCounter is at \(self.envCounter.counter)")
}
.padding(.bottom, 40)
SubView(viewModel: envCounter.subViewViewModel)
}
.environmentObject(envCounter)
}
}

Resources