SwiftUI: calling a method from nested view - ios

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

Related

SwiftUI: Is there a way to assign a View to a variable for later use of the same instance?

I have a parent View:
struct ParentView<pViewModel> : View where pViewModel: ParentViewModel {
var body: some View {
VStack {
ChildView(viewModel: ViewModel(list:["a", "b", "c"]))
ChildView(viewModel: ViewModel(list:["x", "y", "z"]))
}
}
}
In my child view, I have the following:
struct ChildView<cViewModel>: View where cViewModel: ChildViewModel {
#ObservedObject var vm: cViewModel
var body: some View {
...
}
}
and this is the ViewModel of the child view:
protocol ChildViewModel: ObservableObject { ... }
class ChildViewModelImp: ChildViewModel {
#Published var toggles: [String: Bool] = [:]
var list: [String] = []
init(list: [String]) {
self.list = list
for item in list {
toggles[item] = false
}
}
func toggleItem(item: String) {
toggles[item].toggle()
}
}
When an item in toggles gets toggled (via func toggleItem), both the parent view and the child view get re-rendered. The parent view creates a new instance of the child view so the viewModel init() is called again and I lose the data in the toggles property.
I tried saving the two child views into two properties in the parent View model, and call this in my Parent View like so:
struct ParentView<pViewModel> : View where pViewModel: ParentViewModel {
#StateObject var vm: pViewModel
var body: some View {
vm.displayChildView(list: [...], isTop: true)
vm.displayChildView(list: [...], isTop: false)
}
}
protocol ParentViewModel: ObservableObject {
associatedtype childVM: ChildViewModel
func displayChildView(list: [String]) -> ChildView<childVM>
}
class ParentViewModelImp: ParentViewModel {
private var childViewTop: ChildView<some ChildViewModel>?
private var childViewBottom: ChildView<some ChildViewModel>?
func displayChildView(list: [String], isTop: Bool) -> ChildView<some ChildViewModel> {
if isTop {
if childViewTop == nil {
childViewTop = ChildView(viewModel: getChildViewModel(list: list))
} else {
return childViewTop
}
} else {
if childViewBottom == nil {
childViewBottom = ChildView(viewModel: getChildViewModel(list: list))
} else {
return childViewBottom
}
}
}
private func getChildViewModel(list: [String]) -> some ChildViewModel {
var viewModel = ChildViewModelImp(list:list)
...
return viewModel
}
}
However it seems like the childViewTop and childViewBottom have different type from what getChildViewModel() returns.
Because of "some" keyword, it seems like my types do not match. How can I save the child views in variables so that I can reuse the same instance of the child views when it gets re-rendered and I don't lose my data in the child view model?

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

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

SwiftUI view not getting updated on changing #ObservedObject

Here is a simple MVVM based TestView:
import SwiftUI
public struct Test: View {
#ObservedObject public var viewModel = TestViewModel()
public init() {
}
public var body: some View {
VStack {
Text(viewModel.model.stri)
Button(action: {
self.viewModel.change()
}) {
Text("Change")
}
}.padding(50)
}
}
public class TestModel {
#Published public var condition: Bool = false
#Published var stri = "Random numbers"
}
public class TestViewModel: ObservableObject {
#Published var model = TestModel()
func change() {
model.condition.toggle()
model.stri = "\(Int.random(in: 1...10))"
}
}
The view does not get updated when the model is updated from inside the view model.
The text should finally produce some random number between 1 and 10. Please let me know where I am going wrong.
It is because your Test view observes viewModel but not viewModel.model which is not changed in your scenario because it is a reference-type
The following is a solution
func change() {
model.condition.toggle()
model.stri = "\(Int.random(in: 1...10))"
self.objectWillChange.send() // << this one !!
}

How to run a method in ContentView when an ObservedObject changes in other class [Swift 5 iOS 13.4]

Here is my basic ContentView
struct ContentView: View
{
#ObservedObject var model = Model()
init(model: Model)
{
self.model = model
}
// How to observe model.networkInfo's value over here and run "runThis()" whenever the value changes?
func runThis()
{
// Function that I want to run
}
var body: some View
{
VStack
{
// Some widgets here
}
}
}
}
Here is my model
class Model: ObservableObject
{
#Published var networkInfo: String
{
didSet
{
// How to access ContentView and run "runThis" method from there?
}
}
}
I'm not sure if it is accessible ? Or if I can observe ObservableObject changes from View and run any methods?
Thanks in advance!
There are a number of ways to do this. If you want to runThis() when the
networkInfo changes then you could use something like this:
class Model: ObservableObject {
#Published var networkInfo: String = ""
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
VStack {
Button(action: {
self.model.networkInfo = "test"
}) {
Text("change networkInfo")
}
}.onReceive(model.$networkInfo) { _ in self.runThis() }
}
func runThis() {
print("-------> runThis")
}
}
another global way is this:
class Model: ObservableObject {
#Published var networkInfo: String = "" {
didSet {
NotificationCenter.default.post(name: NSNotification.Name("runThis"), object: nil)
}
}
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
VStack {
Button(action: {
self.model.networkInfo = "test"
}) {
Text("change networkInfo")
}
}.onReceive(
NotificationCenter.default.publisher(for: NSNotification.Name("runThis"))) { _ in
self.runThis()
}
}
func runThis() {
print("-------> runThis")
}
}

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