Reputation: 465
I have the following SwiftUI View:
struct ProductView: View {
@ObservedObject var productViewModel: ProductViewModel
var body: some View {
VStack {
ZStack(alignment: .top) {
if(self.productViewModel.product != nil) {
URLImage(url: self.productViewModel.product!.imageurl, itemColor: self.productViewModel.selectedColor)
}
else {
Image("loading")
}
}
}
}
that observes a ProductViewModel
class ProductViewModel: ObservableObject {
@Published var selectedColor: UIColor = .white
@Published var product: Product?
private var cancellable: AnyCancellable!
init(productFuture: Future<Product, Never>) {
self.cancellable = productFuture.sink(receiveCompletion: { comp in
print(comp)
}, receiveValue: { product in
self.product = product
print(self.product) // this prints the expected product. The network call works just fine
})
}
The Product is a Swift struct that contains several string properties:
struct Product {
let id: String
let imageurl: String
let price: String
}
It is fetched from a remote API. The service that does the fetching returns a Combine future and passes it to the view model like so:
let productFuture = retrieveProduct(productID: "1")
let productVM = ProductViewModel(productFuture: productFuture)
let productView = ProductView(productViewModel: productViewModel)
func retrieveProduct(productID: String) -> Future<Product, Never>{
let future = Future<Product, Never> { promise in
// networking logic that fetches the remote product, once it finishes the success callback is invoked
promise(.success(product))
}
return future
}
For the sake of brevity, I've excluded the networking and error handling logic since it is irrelevant for the case at hand. To reproduce this as quickly as possible, just initialize a mock product with some dummy values and pass it to the success callback with a delay like this:
let mockproduct = Product(id: "1", imageurl: "https://exampleurl.com", price: "$10")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: {
promise(.success(mockproduct))
})
Once the product arrives over the network, it is assigned to the published product property. The fetching works and the correct value is assigned to the published property. Obviously this happens after the view has been created since the network call takes some time. However, the View never updates even though the published object is changed.
When I pass the product directly through the View Model initializer rather than the future, it works as expected and the view displays the correct product.
Any suggestions on why the view does not react to changes in the state of the view model when it is updated asynchronously through the combine future?
EDIT: When I asked this question I had the ProductViewModel + ProductView nested inside another view. So basically the productview was only a part of a larger CategoryView. The CategoryViewmodel initialized both the ProductViewModel and the ProductView in a dedicated method:
func createProductView() -> AnyView {
let productVM = productViewModels[productIndex]
return AnyView(ProductView(productViewModel: productVM))
}
which was then called by the CategoryView on every update. I guess this got the Published variables in the nested ProductViewModel to not update correctly because the view hierarchy from CategoryView downwards got rebuilt on every update. Accordingly, the method createProductView got invoked on every new update, resulting in a completely new initialization of the ProductView + ProductViewModel.
Maybe someone with more experience with SwiftUI can comment on this.
Is it generally a bad idea to have nested observable objects in nested views or is there a way to make this work that is not an antipattern?
If not, how do you usually solve this problem when you have nested views that each have their own states?
Upvotes: 3
Views: 1753
Reputation: 5644
I have been iterating on patterns like this to find what I think works best. Not sure what the problem is exactly. My intuition suggests that SwiftUI is having trouble making updates on the != nil
part.
Here is the pattern that I have been using which has been working.
public enum NetworkingModelViewState {
case loading
case hasData
case noResults
case error
}
class ProductViewModel: ObservableObject {
@Published public var state: NetworkingModelViewState = .loading
}
self.cancellable = productFuture.sink(receiveCompletion: { comp in
print(comp)
}, receiveValue: { product in
self.product = product
self.state = NetworkingModelViewState.hasData
print(self.product) // this prints the expected product. The network call works just fine
})
SwiftUI
based on the Enum value if(self.productViewModel.state == NetworkingModelViewState.hasData) {
URLImage(url: self.productViewModel.product!.imageurl, itemColor: self.productViewModel.selectedColor)
}
else {
Image("loading")
}
Musings ~ It's hard to debug declarative frameworks. They are powerful and we should keep learning them but be aware of getting stuck. Moving too SwiftUI has forced me to really think about MVVM. My takeaway is that you really need to separate out every possible variable that controls your UI. You should not rely on checks outside of reading a variable. The Combine future pattern has a memory leak that Apple will fix next release. Also, you will be able to switch inside SwiftUI next release.
Upvotes: 2