Are there best practices about how to prepare lightweight viewmodels with dummy data, that could be used in SwiftUI previews?
Right now, I have a viewmodel for an in-app purchase screen, that needs to fetch products from the app store, but for mocking it in preview (without needing a whole fetching code), I added second init() that lets me just prefill all the properties, and put it inside #if DEBUG section to prevent accidentally calling it from production code.
However, this has forced me to have all the references to an app state (which I need to call loadproducts() on model) as an optional var (because dummy init doesn't set them) instead of let (which I'd prefer because then there is no chance of them being accidentally nil in production).
In other languages I could have abstract ProductViewModelBase (which would store and expose all properties the view needs), and from that a production ProductViewModel and dummy DummyProductViewModel (for previews) could inherit, but Swift doesn't have abstract classes.
While the following solution works and is fast, I am basically having two different classes in one, and distinguishing among them with nil properties, which feels like 'doing javascript in swift' instead of using the types to express semantics and disallow representation of invalid states :/.
Is there a better way?
Example:
ProductViewModel.swift
import Combine
import Foundation
class ProductViewModel: ObservableObject {
@Published fileprivate(set) var isLoaded: Bool
@Published fileprivate(set) var stateLabel: String
@Published fileprivate(set) var product: MyProduct
@Published fileprivate(set) var title: String = ""
@Published fileprivate(set) var description: String = ""
@Published fileprivate(set) var price: String = ""
@Published fileprivate(set) var priceTimeUnit: String = ""
public var transactionHandler: TransactionHandler?
var transactionStateCancellable: AnyCancellable?
init(transactionHandler: TransactionHandler, inAppPurchaseManager: InAppPurchaseManager, product: MyProduct) {
self.transactionHandler = transactionHandler
self.product = product
self.isLoaded = false
self.stateLabel = ""
// all the loading etc.
self.transactionStateCancellable = transactionHandler.$productsState.receive(on: RunLoop.main).sink { [weak self] newState in
guard let self = self else { return }
switch newState {
case .initial:
self.stateLabel = ""
case .productsAreLoading:
self.stateLabel = "Loading \(Constants.ELLIPSIS)"
case .productsLoaded:
self.stateLabel = ""
self.isLoaded = true
case .productsLoadingError:
self.stateLabel = "Error loading products"
}
if let skProduct = inAppPurchaseManager.getAvailableProduct(id: product.id) {
self.price = skProduct.formatProductPriceForButton()
self.title = skProduct.localizedTitle
self.description = product.description()
self.priceTimeUnit = product.priceTimeUnit()
self.isLoaded = true
}
}
}
#if DEBUG
/// Dummy init for preview
init(dummyProduct: MyProduct, title: String, price: String) {
self.isLoaded = true
self.stateLabel = "Product loaded"
self.product = dummyProduct
self.title = title
self.description = dummyProduct.description()
self.price = price
self.priceTimeUnit = dummyProduct.priceTimeUnit()
}
#endif
}
I can then use it in preview:
struct ProductView_Previews: PreviewProvider {
static var previews: some View {
Group {
// Calling dummy init
ProductView(viewModel: ProductViewModel(dummyProduct: .monthlySub, title: "Monthly", price: "$0.99"))
}
}
}
While this is how it would be called in production:
// Calling real init()
ProductView(viewModel: ProductViewModel(transactionHandler: AppState.DummyAppState.transactionHandler, inAppPurchaseManager: AppState.DummyAppState.inAppPurchaseManager, product: .monthly))
.environmentObject(AppState.DummyAppState)
.environmentObject(WindowState.DummyWindowState)