How to deinit ViewModel with combine framework when views disappears - swiftui

I use Combine in viewModels to update the views. But if I store the AnyCancellable objects into a set of AnyCancellable, the deinit method is never called. I use the deinit to cancel all cancellables objects.
struct View1: View {
#ObservedObject var viewModel:ViewTextModel = ViewTextModel()
#Injected var appActions:AppActions
var body: some View {
VStack {
Text(self.viewModel.viewText)
Button(action: {
self.appActions.goToView2()
}) {
Text("Go to view \(self.viewModel.viewText)")
}
}
}
}
class ViewTextModel: ObservableObject {
#Published var viewText: String
private var cancellables = Set<AnyCancellable>()
init(state:AppState) {
// initial state
viewText = "view \(state.view)"
// updated state
state.$view.removeDuplicates().map{ "view \($0)"}.assign(to: \.viewText, on: self).store(in: &cancellables)
}
deinit {
cancellables.forEach { $0.cancel() }
}
}
Each time the view is rebuilt, a new viewmodel is instantiated but the old one is not destroyed. viewText attribute is updated on each instance with state.$view.removeDuplicates().map{ "view \($0)"}.assign(to: \.viewText, on: self).store(in: &cancellables)
If I don't store the cancellable object in the set, deinit is called but viewText is not updated if the state's changed for the current view.
Do you have an idea of ​​how to manage the update of the state without multiplying the instances of the viewmodel ?
Thanks

You could use sink instead of assign:
state.$view
.removeDuplicates()
.sink { [weak self] in self?.viewText = $0 }
.store(in: &cancellables)
But I question the need for Combine here at all. Just use a computed property:
class ViewTextModel: ObservableObject {
#Published var state: AppState
var viewText: String { "view \(state.view)" }
}
UPDATE
If your deployment target is iOS 14 (or macOS 11) or later:
Because you are storing to an #Published, you can use the assign(to:) operator instead. It manages the subscription for you without returning an AnyCancellable.
state.$view
.removeDuplicates()
.map { "view \($0)" }
.assign(to: &$viewText)
// returns Void, so nothing to store

Related

Is this the right way for using #ObservedObject and #EnvironmentObject?

Cow you give me some confirmation about my understanding about #ObservedObject and #EnvironmentObject?
In my mind, using an #ObservedObject is useful when we send data "in line" between views that are sequenced, just like in "prepare for" in UIKit while using #EnvironmentObject is more like "singleton" in UIKit. My question is, is my code making the right use of these two teniques? Is this the way are applied in real development?
my model used as brain for funcions (IE urls sessions, other data manipulations)
class ModelClass_ViaObservedObject: ObservableObject {
#Published var isOn: Bool = true
}
class ModelClass_ViaEnvironment: ObservableObject {
#Published var message: String = "default"
}
my main view
struct ContentView: View {
//way to send data in views step by step
#StateObject var modelClass_ViaObservedObject = ModelClass_ViaObservedObject()
//way to share data more or less like a singleton
#StateObject var modelClass_ViaEnvironment = ModelClass_ViaEnvironment()
var myBackgroundColorView: Color {
if modelClass_ViaObservedObject.isOn {
return Color.green
} else {
return Color.red
}
}
var body: some View {
NavigationView {
ZStack {
myBackgroundColorView
VStack {
NavigationLink(destination:
SecondView(modelClass_viaObservedObject: modelClass_ViaObservedObject)
) {
Text("Go to secondary view")
.padding()
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(.black, lineWidth: 1)
)
}
Text("text received from second view: \(modelClass_ViaEnvironment.message)")
}
}
.navigationTitle("Titolo")
.navigationBarTitleDisplayMode(.inline)
}
.environmentObject(modelClass_ViaEnvironment)
}
}
my second view
struct SecondView: View {
#Environment(\.dismiss) var dismiss
#ObservedObject var modelClass_viaObservedObject: ModelClass_ViaObservedObject
//global data in environment, not sent step by step view by view
#EnvironmentObject var modelClass_ViaEnvironment: ModelClass_ViaEnvironment
var body: some View {
VStack(spacing: 5) {
Text("Second View")
Button("change bool for everyone") {
modelClass_viaObservedObject.isOn.toggle()
dismiss()
}
TextField("send back", text: $modelClass_ViaEnvironment.message)
Text(modelClass_ViaEnvironment.message)
}
}
}
No, we use #State for view data like if a toggle isOn, which can either be a single value itself or a custom struct containing multiple values and mutating funcs. We pass it down the View hierarchy by declaring a let in the child View or use #Binding var if we need write access. Regardless of if we declare it let or #Binding whenever a different value is passed in to the child View's init, SwiftUI will call body automatically (as long as it is actually accessed in body that is).
#StateObject is for when a single value or a custom struct won't do and we need a reference type instead for view data, i.e. if persisting or syncing data (not using the new async/await though because we use .task for that). The object is init before body is called (usually before it is about to appear) and deinit when the View is no longer needed (usually after it disappears).
#EnvironmentObject is usually for the store object that holds model structs in #Published properties and is responsible for saving or syncing,. The difference is the model data is not tied to any particular View, like #State and #StateObject are for view data. This object is usually a singleton, one for the app and one with sample data for when previewing, because it should never be deinit. The advantage of #EnvironmentObject over #ObservedObject is we don't need to pass it down through each View as a let that don't need the object when we only need it further down the hierarchy. Note the reason it has to be passed down as a let and not #ObservedObject is then body would be needlessly called in the intermediate Views because SwiftUI's dependency tracking doesn't work for objects only value types.
Here is some sample code:
struct MyConfig {
var isOn = false
var message = ""
mutating func reset() {
isOn = false
message = ""
}
}
struct MyView: View {
#State var config = MyConfig() // grouping vars into their struct makes use of value semantics to track changes (a change to any of its properties is detected as a change to the struct itself) and offers testability.
var body: some View {
HStack {
ViewThatOnlyReads(config: config)
ViewThatWrites(config: $config)
}
}
}
struct ViewThatOnlyReads: View {
let config: MyConfig
var body: some View {
Text(config.isOn ? "It's on" : "It's off")
}
}
struct ViewThatWrites: View {
#Binding var config: MyConfig
var body: some View {
Toggle("Is On", isOn: $config.isOn)
}
}

Adding An EnvironmentObject to a ViewModel SwiftUI and MVVM

I am working with SwiftUI and am using MVVM with my VM acting as my EnvironmentObjects.
I first create a AuthSession environment object which has a string for currentUserId stored.
I then create another environment object for Offers that is trying to include the AuthSession environment object so I can can filter results pulled from a database with Combine. When I add an #EnvironmentObject property to the Offer view model I get an error stating that AuthSession is not passed. This makes sense since it's not a view.
My question is, is it best to sort the results in the view or is there a way to add an EnvironmentObject to another EnvironmentObject? I know there is an answer here, but this model answer is not using VM as the EOs.
App File
#main
struct The_ExchangeApp: App {
// #EnvironmentObjects
#StateObject private var authListener = AuthSession()
#StateObject private var offerHistoryViewModel = OfferHistoryViewModel(offerRepository: OfferRepository())
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(authListener)
.environmentObject(offerHistoryViewModel)
}
}
}
AuthSession.swift
class AuthSession: ObservableObject {
#Published var currentUser: User?
#Published var loggedIn = false
#Published var currentUserUid = ""
// Intitalizer
init() {
self.getCurrentUserUid()
}
}
OfferHistoryViewModel.swift - The error is called just after the .filter in startCombine().
class OfferHistoryViewModel: ObservableObject {
// MARK: ++++++++++++++++++++++++++++++++++++++ Properties ++++++++++++++++++++++++++++++++++++++
// Access to AuthSession for filtering offer made by the current user.
#EnvironmentObject var authSession: AuthSession
// Properties
var offerRepository: OfferRepository
// Published Properties
#Published var offerRowViewModels = [OfferRowViewModel]()
// Combine Cancellable
private var cancellables = Set<AnyCancellable>()
// MARK: ++++++++++++++++++++++++++++++++++++++ Methods ++++++++++++++++++++++++++++++++++++++
// Intitalizer
init(offerRepository: OfferRepository) {
self.offerRepository = offerRepository
self.startCombine()
}
// Starting Combine - Filter results for offers created by the current user only.
func startCombine() {
offerRepository
.$offers
.receive(on: RunLoop.main)
.map { offers in
offers
.filter { offer in
(self.authSession.currentUserUid != "" ? offer.userId == self.authSession.currentUserUid : false) // ERROR IS CALLED HERE
}
.map { offer in
OfferRowViewModel(offer: offer)
}
}
.assign(to: \.offerRowViewModels, on: self)
.store(in: &cancellables)
}
}
Error
Thread 1: Fatal error: No ObservableObject of type AuthSession found. A View.environmentObject(_:) for AuthSession may be missing as an ancestor of this view.
I solved this by passing currentUserUid from AuthSession from my view to the view model. The view model changes to the following.
class OfferHistoryViewModel: ObservableObject {
// MARK: ++++++++++++++++++++++++++++++++++++++ Properties ++++++++++++++++++++++++++++++++++++++
var offerRepository: OfferRepository
// Published Properties
#Published var offerRowViewModels = [OfferRowViewModel]()
// Combine Cancellable
private var cancellables = Set<AnyCancellable>()
// MARK: ++++++++++++++++++++++++++++++++++++++ Methods ++++++++++++++++++++++++++++++++++++++
// Intitalizer
init(offerRepository: OfferRepository) {
self.offerRepository = offerRepository
}
// Starting Combine - Filter results for offers created by the current user only.
func startCombine(currentUserUid: String) {
offerRepository
.$offers
.receive(on: RunLoop.main)
.map { offers in
offers
.filter { offer in
(currentUserUid != "" ? offer.userId == currentUserUid : false)
}
.map { offer in
OfferRowViewModel(offer: offer)
}
}
.assign(to: \.offerRowViewModels, on: self)
.store(in: &cancellables)
}
}
Then in the view I pass the currentUserUid in onAppear.
struct OfferHistoryView: View {
// MARK: ++++++++++++++++++++++++++++++++++++++ Properties ++++++++++++++++++++++++++++++++++++++
#EnvironmentObject var authSession: AuthSession
#EnvironmentObject var offerHistoryViewModel: OfferHistoryViewModel
// MARK: ++++++++++++++++++++++++++++++++++++++ View ++++++++++++++++++++++++++++++++++++++
var body: some View {
// BuildView
} // View
.onAppear(perform: {
self.offerHistoryViewModel.startCombine(currentUserUid: self.authSession.currentUserUid)
})
}
}
This works well for me and I hope it helps someone else.

Changes in nested ObservedObject do not updated the UI

When I have a nested ObservedObject, changes in a published property of a nested object do not updated the UI until something happens to the parent object. Is this a feature, a bug (in SwiftUI) or a bug in my code?
Here is a simplified example. Clicking the On/Off button for the parent immediately updates the UI, but clicking the On/Off button for the child does not update until the parent is updated.
I am running Xcode 12.5.1.
import SwiftUI
class NestedObject: NSObject, ObservableObject {
#Published var flag = false
}
class StateObject: NSObject, ObservableObject {
#Published var flag = false
#Published var nestedState = NestedObject()
}
struct ContentView: View {
#ObservedObject var state = StateObject()
var body: some View {
VStack {
HStack {
Text("Parent:")
Button(action: {
state.flag.toggle()
}, label: {
Text(state.flag ? "On" : "Off")
})
}
HStack {
Text("Child:")
Button(action: {
state.nestedState.flag.toggle()
}, label: {
Text(state.nestedState.flag ? "On" : "Off")
})
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
#ObservedObject or #StateObject updates the view when the ObservableObject updates. This happens when a #Published property is changed or when you directly call objectWillChange.send().
So, the "normal" (and the simplest) approach is to use a value type, e.g. a struct, for a #Published property.
struct NestedObject {
var flag = false
}
The reason this works, is that the entire NestedObject changes when its properties are modified, because struct is a value-type. In contrast, a reference-type class doesn't change (i.e. reference remains the same) when its property is modified.
But, sometimes you might need it to be a reference-type object, because it might have its own life cycle, etc...
In that case, you could definitely just call state.objectWillChange.send(), but that would only work if the view initiates the change, not when the nested object initiates the change. The best general approach here, in my opinion, is to use a nested inner view that has its own #ObservedObject to observe changes of the inner object:
struct ContentView: View {
private struct InnerView: View {
#ObservedObject var model: NestedObject
var body: some View {
Text("Child:")
Button(action: {
model.flag.toggle()
}, label: {
Text(model.flag ? "On" : "Off")
})
}
}
#StateObject var state = OuterObject() // see comments 1, 2 below
var body: some View {
VStack {
HStack {
Text("Parent:")
Button(action: {
state.flag.toggle()
}, label: {
Text(state.flag ? "On" : "Off")
})
}
HStack {
InnerView(model: state.nestedObject)
}
}
}
}
1. You shouldn't call your class StateObject, since it clashes with the StateObject property wrapper of SwiftUI. I renamed it to OuterObject.
2. Also, you should use #StateObject instead of #ObservedObject if you instantiate the object inside the view.
It works as supposed: ObservableObject only sends a notification about the changes of #Published properties but doesn't propagate change notification of nested ObservableObjects.
I'd follow the advice from New Dev: use a struct when you can or use a separate View to subscribe to the nested object.
If you truly need nested ObservableObjects, you can propagate the objectWillChange event from the nested object to the outer object using Combine:
import Combine
import SwiftUI
class InnerObject: ObservableObject {
#Published var flag = false
}
class OuterObject: ObservableObject {
#Published var flag = false
var innerObject = InnerObject() {
didSet {
subscribeToInnerObject()
}
}
init() {
subscribeToInnerObject()
}
private var innerObjectSubscription: AnyCancellable?
private func subscribeToInnerObject() {
// subscribe to the inner object and propagate the objectWillChange notification if it changes
innerObjectSubscription = innerObject.objectWillChange.sink(receiveValue: objectWillChange.send)
}
}
struct ContentView: View {
#ObservedObject var state = OuterObject()
var body: some View {
VStack {
Toggle("Parent \(state.flag ? "On" : "Off")", isOn: $state.flag)
Toggle("Child \(state.innerObject.flag ? "On" : "Off")", isOn: $state.innerObject.flag)
}
.padding()
}
}
Thank you for clarifications. The most valuable take-away for me is the fact that this behavior is not a bug (of SwiftUI), but is a by-design behavior.
SwiftUI (more precisely, Combine) see changes only in values, therefore, it can see changes in the property value changes of #Published struct instances, but not #Published class instances.
Therefore, the answer is "use struct instances for the nested objects if you want to update the UI based on the changes in the property values of those nested objects. If you have to use class instances, use another mechanism to explicitly notify changes".
Here is the modified code using struct for NestedObject instead of class.
import SwiftUI
struct NestedObject {
var flag = false
}
class OuterObject: NSObject, ObservableObject {
#Published var flag = false
#Published var nestedState = NestedObject()
}
struct ContentView: View {
#ObservedObject var state = OuterObject()
var body: some View {
VStack {
HStack {
Text("Parent:")
Button(action: {
state.flag.toggle()
}, label: {
Text(state.flag ? "On" : "Off")
})
}
HStack {
Text("Child:")
Button(action: {
state.nestedState.flag.toggle()
}, label: {
Text(state.nestedState.flag ? "On" : "Off")
})
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
try this, works well for me on ios-15, catalyst 15, macos 12, using xcode 13:
HStack {
Text("Child:")
Button(action: {
state.objectWillChange.send() // <--- here
state.nestedState.flag.toggle()
}, label: {
Text(state.nestedState.flag ? "On" : "Off")
})
}

Why does this Combine subscription not cause a retain cycle?

I noticed this was triggering the deinit block even without weak/unowned used. I simplified the code to show what is happening.
final class ViewModel: ObservableObject {
#Published var isLoading = false
private var cancellables: [AnyCancellable] = []
var randomPublisher: AnyPublisher<Void, Never> {
Just(()).eraseToAnyPublisher()
}
func someAction() {
randomPublisher.sink { completion in
self.isLoading = false
switch completion {
case .finished:
break
}
} receiveValue: { _ in }
.store(in: &cancellables)
}
}
struct SampleView: View {
#StateObject private var viewModel = ViewModel()
}
I would think there is a reference cycle when someAction() is called as self is captured inside the subscription and the viewmodel holds the subscription array. It's successfully accessing the deinit block when view is dismissed so why is that the case whereas in other viewmodels I need to make self weak in the same place?

SwiftUI - onReceive not being called for an ObservableObject property change when the property is updated after view is loaded

I have a view that displays a few photos that are loaded from an API in a scroll view. I want to defer fetching the images until the view is displayed. My view, simplified looks something like this:
struct DetailView : View {
#ObservedObject var viewModel: DetailViewModel
init(viewModel: DetailViewModel) {
self.viewModel = viewModel
}
var body: some View {
GeometryReader { geometry in
ZStack {
Color("peachLight").edgesIgnoringSafeArea(.all)
if self.viewModel.errorMessage != nil {
ErrorView(error: self.viewModel.errorMessage!)
} else if self.viewModel.imageUrls.count == 0 {
VStack {
Text("Loading").foregroundColor(Color("blueDark"))
Text("\(self.viewModel.imageUrls.count)").foregroundColor(Color("blueDark"))
}
} else {
VStack {
UIScrollViewWrapper {
HStack {
ForEach(self.viewModel.imageUrls, id: \.self) { imageUrl in
LoadableImage(url: imageUrl)
.scaledToFill()
}.frame(width: geometry.size.width, height: self.scrollViewHeight)
}.edgesIgnoringSafeArea(.all)
}.frame(width: geometry.size.width, height: self.scrollViewHeight)
Spacer()
}
}
}
}.onAppear(perform: { self.viewModel.fetchDetails() })
.onReceive(viewModel.objectWillChange, perform: {
print("Received new value from view model")
print("\(self.viewModel.imageUrls)")
})
}
}
my view model looks like this:
import Foundation
import Combine
class DetailViewModel : ObservableObject {
#Published var imageUrls: [String] = []
#Published var errorMessage : String?
private var fetcher: Fetchable
private var resourceId : String
init(fetcher: Fetchable, resource: Resource) {
self.resourceId = resource.id
// self.fetchDetails() <-- uncommenting this line results in onReceive being called + a view update
}
// this is a stubbed version of my data fetch that performs the same way as my actual
// data call in regards to ObservableObject updates
// MARK - Data Fetching Stub
func fetchDetails() {
if let path = Bundle.main.path(forResource: "detail", ofType: "json") {
do {
let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe)
let parsedData = try JSONDecoder().decode(DetailResponse.self, from: data)
self.imageUrls = parsedData.photos // <-- this doesn't trigger a change, and even manually calling self.objectWillChange.send() here doesn't trigger onReceive/view update
print("setting image urls to \(parsedData.photos)")
} catch {
print("error decoding")
}
}
}
}
If I fetch my data within the init method of my view model, the onReceive block on my view IS called when the #Published imageUrls property is set. However, when I remove the fetch from the init method and call from the view using:
.onAppear(perform: { self.viewModel.fetchDetails() })
the onReceive for viewModel.objectWillChange is NOT called, even though the data is updated. I don't know why this is the case and would really appreciate any help here.
Use instead
.onReceive(viewModel.$imageUrls, perform: { newUrls in
print("Received new value from view model")
print("\(newUrls)")
})
I tested this as I found the same issue, and it seems like only value types can be used with onReceive
use enums, strings, etc.
it doesn't work with reference types because I guess technically a reference type doesn't change reference location and simply points elsewhere when changed? idk haha but ya
as a solution, you can set a viewModel #published property which is like a state enum, make changes to that when you have new data, and then on receive can access that...hope that makes sense, let me know if not