From 43f597503508d9e5f10b683463dde8596d75645a Mon Sep 17 00:00:00 2001 From: Albert Bori Date: Wed, 1 Feb 2023 14:32:56 -0500 Subject: [PATCH] VSM Property Wrappers (#26) * Added view state wrappers and updated sample app * Update StateContainer+Debug.swift --- .../Shopping.xcodeproj/project.pbxproj | 4 +- Demos/Shopping/Shopping/ShoppingApp.swift | 4 +- .../Shopping/Views/Cart/CartButtonView.swift | 16 +- .../Shopping/Views/Cart/CartView.swift | 36 ++--- .../Views/Favorites/FavoritesView.swift | 34 +++-- .../Shopping/Views/Main/MainView.swift | 15 +- .../FavoriteButtonView.swift | 18 +-- .../ProductDetailView/ProductDetailView.swift | 25 +-- .../ProductDetailViewController.swift | 16 +- .../Shopping/Views/Product/ProductView.swift | 19 ++- .../Views/Product/ProductViewController.swift | 20 +-- .../Views/Products/ProductsView.swift | 16 +- .../Shopping/Views/Profile/ProfileView.swift | 14 +- .../Views/Settings/SettingsView.swift | 62 ++++---- .../Views/Settings/SettingsViewState.swift | 14 +- Package.swift | 2 +- .../StateContainer+Binding.swift | 0 .../StateContainer+Debug.swift | 10 +- .../{ => StateContainer}/StateContainer.swift | 0 .../VSM/StateContainer/StateObserving.swift | 4 +- Sources/VSM/StateObject+StateInit.swift | 1 + Sources/VSM/StateSequence.swift | 2 +- .../AtomicStateChangeSubscriber.swift | 38 +++++ .../ViewState/RenderedViewState+Debug.swift | 13 ++ Sources/VSM/ViewState/RenderedViewState.swift | 143 ++++++++++++++++++ Sources/VSM/ViewState/ViewState+Debug.swift | 13 ++ Sources/VSM/ViewState/ViewState.swift | 84 ++++++++++ Sources/VSM/ViewStateRendering.swift | 7 + 28 files changed, 475 insertions(+), 155 deletions(-) rename Sources/VSM/{ => StateContainer}/StateContainer+Binding.swift (100%) rename Sources/VSM/{ => StateContainer}/StateContainer+Debug.swift (97%) rename Sources/VSM/{ => StateContainer}/StateContainer.swift (100%) create mode 100644 Sources/VSM/ViewState/AtomicStateChangeSubscriber.swift create mode 100644 Sources/VSM/ViewState/RenderedViewState+Debug.swift create mode 100644 Sources/VSM/ViewState/RenderedViewState.swift create mode 100644 Sources/VSM/ViewState/ViewState+Debug.swift create mode 100644 Sources/VSM/ViewState/ViewState.swift diff --git a/Demos/Shopping/Shopping.xcodeproj/project.pbxproj b/Demos/Shopping/Shopping.xcodeproj/project.pbxproj index 195f0df..2a77e5b 100644 --- a/Demos/Shopping/Shopping.xcodeproj/project.pbxproj +++ b/Demos/Shopping/Shopping.xcodeproj/project.pbxproj @@ -295,10 +295,10 @@ 32E5E76D27B48408002E9C47 /* Cart */ = { isa = PBXGroup; children = ( - 322B21F027BC55C700D147A4 /* CartView.swift */, - 322B21F227BC55D400D147A4 /* CartViewState.swift */, 32F1B5E327C0626200819F76 /* CartButtonView.swift */, 3289790727BF2D5400651ADE /* CartButtonViewState.swift */, + 322B21F027BC55C700D147A4 /* CartView.swift */, + 322B21F227BC55D400D147A4 /* CartViewState.swift */, ); path = Cart; sourceTree = ""; diff --git a/Demos/Shopping/Shopping/ShoppingApp.swift b/Demos/Shopping/Shopping/ShoppingApp.swift index 56e9a34..813b68d 100644 --- a/Demos/Shopping/Shopping/ShoppingApp.swift +++ b/Demos/Shopping/Shopping/ShoppingApp.swift @@ -12,9 +12,9 @@ import VSM struct ShoppingApp: App { init() { - // Uncomment this line to see state changes printed to the console for every StateContainer in the app. + // Uncomment this line to see state changes printed to the console for every ViewState in the app. // NOTE: The line below will produce a compiler warning in DEBUG, and will break any non-DEBUG build. - // StateContainer._debug() + // ViewState._debug() // Configure for UI testing if necessary configureUITestBehavior() diff --git a/Demos/Shopping/Shopping/Views/Cart/CartButtonView.swift b/Demos/Shopping/Shopping/Views/Cart/CartButtonView.swift index 3a2718e..9922e35 100644 --- a/Demos/Shopping/Shopping/Views/Cart/CartButtonView.swift +++ b/Demos/Shopping/Shopping/Views/Cart/CartButtonView.swift @@ -8,17 +8,16 @@ import SwiftUI import VSM -struct CartButtonView: View, ViewStateRendering { +struct CartButtonView: View { typealias Dependencies = CartCountLoaderModel.Dependencies & CartView.Dependencies let dependencies: Dependencies @State var showCart: Bool = false - @ObservedObject var container: StateContainer + @ViewState var state: CartButtonViewState init(dependencies: Dependencies) { self.dependencies = dependencies let loaderModel = CartCountLoaderModel(dependencies: dependencies) - container = .init(state: .initialized(loaderModel)) - container.observe(loaderModel.loadCount()) + _state = .init(wrappedValue: .initialized(loaderModel)) } var body: some View { @@ -26,10 +25,15 @@ struct CartButtonView: View, ViewStateRendering { Image(systemName: "cart") } .accessibilityIdentifier("Show Cart") - .overlay(Badge(count: container.state.cartItemCount)) + .overlay(Badge(count: state.cartItemCount)) .fullScreenCover(isPresented: $showCart) { CartView(dependencies: dependencies, showModal: $showCart) } + .onAppear { + if case .initialized(let loaderModel) = state { + $state.observe(loaderModel.loadCount()) + } + } } } @@ -38,7 +42,7 @@ struct CartButtonView: View, ViewStateRendering { extension CartButtonView { init(dependencies: Dependencies, state: CartButtonViewState) { self.dependencies = dependencies - container = .init(state: state) + _state = .init(wrappedValue: state) } } diff --git a/Demos/Shopping/Shopping/Views/Cart/CartView.swift b/Demos/Shopping/Shopping/Views/Cart/CartView.swift index a4987ce..c9a314f 100644 --- a/Demos/Shopping/Shopping/Views/Cart/CartView.swift +++ b/Demos/Shopping/Shopping/Views/Cart/CartView.swift @@ -8,23 +8,23 @@ import SwiftUI import VSM -struct CartView: View, ViewStateRendering { +struct CartView: View { typealias Dependencies = CartLoaderModel.Dependencies & CartLoadedModel.Dependencies & CartRemovingProductModel.Dependencies let dependencies: Dependencies @Binding var showModal: Bool - @StateObject var container: StateContainer + @ViewState var state: CartViewState init(dependencies: Dependencies, showModal: Binding) { self.dependencies = dependencies self._showModal = showModal - _container = .init(state: .initialized(CartLoaderModel(dependencies: dependencies))) + _state = .init(wrappedValue: .initialized(CartLoaderModel(dependencies: dependencies))) } var body: some View { NavigationView { ZStack { - cartView(title: container.state.isOrderComplete ? "Receipt" : "Cart", cart: container.state.cart) - switch container.state { + cartView(title: state.isOrderComplete ? "Receipt" : "Cart", cart: state.cart) + switch state { case .initialized, .loading: ProgressView() .accessibilityIdentifier("Loading Cart...") @@ -45,8 +45,8 @@ struct CartView: View, ViewStateRendering { .navigationBarItems(trailing: dismissButtonView()) } .onAppear { - if case .initialized(let loaderModel) = container.state { - container.observe(loaderModel.loadCart()) + if case .initialized(let loaderModel) = state { + $state.observe(loaderModel.loadCart()) } } } @@ -56,7 +56,7 @@ struct CartView: View, ViewStateRendering { Text("Oops!").font(.title) Text(errorModel.message) Button("Retry") { - container.observe(errorModel.retry()) + $state.observe(errorModel.retry()) } .buttonStyle(DemoButtonStyle()) } @@ -89,10 +89,10 @@ struct CartView: View, ViewStateRendering { } .frame(height: 44) .swipeActions(edge: .trailing, allowsFullSwipe: false) { - switch container.state { + switch state { case .loaded(let loadedModel), .removingProductError(_, let loadedModel), .checkoutError(_, let loadedModel): Button(role: .destructive) { - container.observe(loadedModel.removeProduct(id: product.cartId)) + $state.observe(loadedModel.removeProduct(id: product.cartId)) } label : { Label("Delete", systemImage: "trash.fill") } @@ -102,17 +102,17 @@ struct CartView: View, ViewStateRendering { } } } - if case .orderComplete = container.state { } else { + if case .orderComplete = state { } else { Spacer() - Button(container.state.isCheckingOut ? "Placing Order..." : "Place Order") { - switch container.state { + Button(state.isCheckingOut ? "Placing Order..." : "Place Order") { + switch state { case .loaded(let loadedModel), .removingProductError(_, let loadedModel), .checkoutError(_, let loadedModel): - container.observe(loadedModel.checkout()) + $state.observe(loadedModel.checkout()) default: break } } - .buttonStyle(DemoButtonStyle(enabled: container.state.canCheckout)) + .buttonStyle(DemoButtonStyle(enabled: state.canCheckout)) .padding() } } @@ -120,11 +120,11 @@ struct CartView: View, ViewStateRendering { func dismissButtonView() -> some View { Button(action: { - if container.state.allowModalDismissal { + if state.allowModalDismissal { self.showModal = false } }) { - if container.state.allowModalDismissal { + if state.allowModalDismissal { Image(systemName: "xmark") } } @@ -155,7 +155,7 @@ extension CartView { init(state: CartViewState) { dependencies = MockAppDependencies.noOp _showModal = .init(get: { true }, set: { _ in }) - _container = .init(state: state) + _state = .init(wrappedValue: state) } } diff --git a/Demos/Shopping/Shopping/Views/Favorites/FavoritesView.swift b/Demos/Shopping/Shopping/Views/Favorites/FavoritesView.swift index 8e7d59f..b81e229 100644 --- a/Demos/Shopping/Shopping/Views/Favorites/FavoritesView.swift +++ b/Demos/Shopping/Shopping/Views/Favorites/FavoritesView.swift @@ -8,22 +8,21 @@ import SwiftUI import VSM -struct FavoritesView: View, ViewStateRendering { +struct FavoritesView: View { typealias Dependencies = FavoritesLoaderModel.Dependencies & FavoritesViewLoadedModel.Dependencies - @ObservedObject var container: StateContainer + @ViewState var state: FavoritesViewState @State var showErrorAlert: Bool = false init(dependencies: Dependencies) { let loaderModel = FavoritesLoaderModel(dependencies: dependencies, modelBuilder: FavoritesViewModelBuilder(dependencies: dependencies)) - container = .init(state: .initialized(loaderModel)) - container.observe(loaderModel.loadFavorites()) + _state = .init(wrappedValue: .initialized(loaderModel)) } var body: some View { ZStack { - favoritedProductListView(favorites: container.state.favorites) - switch container.state { + favoritedProductListView(favorites: state.favorites) + switch state { case .initialized, .loading: ProgressView() case .loaded(let loadedModel): @@ -39,11 +38,16 @@ struct FavoritesView: View, ViewStateRendering { } } .navigationTitle("Favorites") - .onReceive(container.$state) { state in + .onReceive($state.publisher) { state in if case .deletingError = state { showErrorAlert = true } } + .onAppear { + if case .initialized(let loaderModel) = state { + $state.observe(loaderModel.loadFavorites()) + } + } } func favoritedProductListView(favorites: [FavoritedProduct]) -> some View { @@ -52,9 +56,9 @@ struct FavoritesView: View, ViewStateRendering { .frame(maxWidth: .infinity, alignment: .leading) .padding() .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if case .loaded(let loadedModel) = container.state { + if case .loaded(let loadedModel) = state { Button(role: .destructive) { - container.observe(loadedModel.delete(productId: favorite.id)) + $state.observe(loadedModel.delete(productId: favorite.id)) } label : { Label("Delete", systemImage: "trash.fill") } @@ -77,7 +81,7 @@ struct FavoritesView: View, ViewStateRendering { Text("Oops!").font(.title) Text(errorModel.message) Button("Retry") { - container.observe(errorModel.retry()) + $state.observe(errorModel.retry()) } } } @@ -88,10 +92,10 @@ struct FavoritesView: View, ViewStateRendering { VStack { } .alert("Oops!", isPresented: $showErrorAlert) { Button("Retry") { - container.observe(deletingErrorModel.retry()) + $state.observe(deletingErrorModel.retry()) } Button("Cancel") { - container.observe(deletingErrorModel.cancel()) + $state.observe(deletingErrorModel.cancel()) } } message: { Text(deletingErrorModel.message) @@ -101,8 +105,8 @@ struct FavoritesView: View, ViewStateRendering { .alert(isPresented: $showErrorAlert) { Alert(title: Text("Oops!"), message: Text(deletingErrorModel.message), - primaryButton: .default(Text("Retry"), action: { container.observe(deletingErrorModel.retry()) }), - secondaryButton: .default(Text("Cancel"), action: { container.observe(deletingErrorModel.cancel()) })) + primaryButton: .default(Text("Retry"), action: { $state.observe(deletingErrorModel.retry()) }), + secondaryButton: .default(Text("Cancel"), action: { $state.observe(deletingErrorModel.cancel()) })) } } } @@ -112,7 +116,7 @@ struct FavoritesView: View, ViewStateRendering { extension FavoritesView { init(state: FavoritesViewState) { - container = .init(state: state) + _state = .init(wrappedValue: state) } } diff --git a/Demos/Shopping/Shopping/Views/Main/MainView.swift b/Demos/Shopping/Shopping/Views/Main/MainView.swift index a9d10c3..06e7a93 100644 --- a/Demos/Shopping/Shopping/Views/Main/MainView.swift +++ b/Demos/Shopping/Shopping/Views/Main/MainView.swift @@ -8,14 +8,13 @@ import SwiftUI import VSM -struct MainView: View, ViewStateRendering { +struct MainView: View { typealias Dependencies = ProductsView.Dependencies & AccountView.Dependencies - @ObservedObject private(set) var container: StateContainer + @ViewState var state: MainViewState init(appDependenciesProvider: AsyncResource) { let loaderModel = DependenciesLoaderModel(appDependenciesProvider: appDependenciesProvider) - container = .init(state: .initialized(loaderModel)) - container.observe(loaderModel.loadDependencies()) + _state = .init(wrappedValue: .initialized(loaderModel)) } var body: some View { @@ -24,7 +23,11 @@ struct MainView: View, ViewStateRendering { ProgressView() .onAppear { // Enable the following debug-only flag to view all state changes in _this_ view - // container._debug() + // $state._debug() + + if case .initialized(let loaderModel) = state { + $state.observe(loaderModel.loadDependencies()) + } } case .loaded(let loadedModel): loadedView(loadedModel) @@ -59,7 +62,7 @@ struct MainView: View, ViewStateRendering { extension MainView { init(state: MainViewState) { - container = .init(state: state) + _state = .init(wrappedValue: state) } } diff --git a/Demos/Shopping/Shopping/Views/Product/FavoriteButtonView/FavoriteButtonView.swift b/Demos/Shopping/Shopping/Views/Product/FavoriteButtonView/FavoriteButtonView.swift index a6be6f7..451e37a 100644 --- a/Demos/Shopping/Shopping/Views/Product/FavoriteButtonView/FavoriteButtonView.swift +++ b/Demos/Shopping/Shopping/Views/Product/FavoriteButtonView/FavoriteButtonView.swift @@ -8,15 +8,15 @@ import SwiftUI import VSM -struct FavoriteButtonView: View, ViewStateRendering { +struct FavoriteButtonView: View { typealias Dependencies = FavoriteInfoLoaderModel.Dependencies & FavoriteButtonLoadedModel.Dependencies let dependencies: Dependencies let productId: Int let productName: String - @StateObject var container: StateContainer = .init(state: .initialized(FavoriteInfoLoaderModel())) + @ViewState var state: FavoriteButtonViewState = .initialized(FavoriteInfoLoaderModel()) var isLoading: Bool { - switch container.state { + switch state { case .initialized, .loading, .error: return true default: @@ -32,7 +32,7 @@ struct FavoriteButtonView: View, ViewStateRendering { var body: some View { Button(action: { - if case .loaded(let loadedModel) = container.state { + if case .loaded(let loadedModel) = state { loadedModel.toggleFavorite() } }) { @@ -43,14 +43,14 @@ struct FavoriteButtonView: View, ViewStateRendering { .accessibilityIdentifier(getAccessibilityId()) .disabled(isLoading) .onAppear { - if case .initialized(let loaderModel) = container.state { - container.observe(loaderModel.loadFavoriteInfo(dependencies: dependencies, productId: productId, productName: productName)) + if case .initialized(let loaderModel) = state { + $state.observe(loaderModel.loadFavoriteInfo(dependencies: dependencies, productId: productId, productName: productName)) } } } func getSystemImageName() -> String { - switch container.state { + switch state { case .initialized, .loading: return "heart" case .loaded(let loadedModel): @@ -61,7 +61,7 @@ struct FavoriteButtonView: View, ViewStateRendering { } func getAccessibilityId() -> String { - switch container.state { + switch state { case .initialized, .loading: return "Inactive Favorite Button" case .loaded(let loadedModel): @@ -79,7 +79,7 @@ extension FavoriteButtonView { self.dependencies = dependencies self.productId = 0 self.productName = "" - _container = .init(state: state) + _state = .init(wrappedValue: state) } } diff --git a/Demos/Shopping/Shopping/Views/Product/ProductDetailView/ProductDetailView.swift b/Demos/Shopping/Shopping/Views/Product/ProductDetailView/ProductDetailView.swift index 4d8a64d..ee7d2b7 100644 --- a/Demos/Shopping/Shopping/Views/Product/ProductDetailView/ProductDetailView.swift +++ b/Demos/Shopping/Shopping/Views/Product/ProductDetailView/ProductDetailView.swift @@ -8,19 +8,19 @@ import SwiftUI import VSM -struct ProductDetailView: View, ViewStateRendering { +struct ProductDetailView: View { typealias Dependencies = AddToCartModel.Dependencies & FavoriteButtonView.Dependencies let dependencies: Dependencies let productDetail: ProductDetail - @ObservedObject var container: StateContainer + @ViewState var state: ProductDetailViewState init(dependencies: Dependencies, productDetail: ProductDetail) { self.dependencies = dependencies self.productDetail = productDetail - container = .init(state: .viewing(AddToCartModel(dependencies: dependencies, productId: productDetail.id))) + _state = .init(wrappedValue: .viewing(AddToCartModel(dependencies: dependencies, productId: productDetail.id))) } - var body: some View { + var body: some View { ZStack { VStack { productDetailsView() @@ -28,10 +28,11 @@ struct ProductDetailView: View, ViewStateRendering { addToCartButtonView() } .navigationTitle(productDetail.name) - if case .addedToCart = container.state { + + if case .addedToCart = state { addToCartToastView() } - if case .addToCartError(let message, _) = container.state { + if case .addToCartError(let message, _) = state { addToCartErrorView(message: message) } } @@ -62,17 +63,17 @@ struct ProductDetailView: View, ViewStateRendering { } func addToCartButtonView() -> some View { - Button(container.state.isAddingToCart ? "Adding to Cart..." : "Add to Cart") { - switch container.state { + Button(state.isAddingToCart ? "Adding to Cart..." : "Add to Cart") { + switch state { case .viewing(let addToCartModel), .addedToCart(let addToCartModel), .addToCartError(_, let addToCartModel): - container.observe(addToCartModel.addToCart()) + $state.observe(addToCartModel.addToCart()) case .addingToCart: break } } - .buttonStyle(DemoButtonStyle(enabled: container.state.canAddToCart)) + .buttonStyle(DemoButtonStyle(enabled: state.canAddToCart)) .padding() - .disabled(!container.state.canAddToCart) + .disabled(!state.canAddToCart) } func addToCartToastView() -> some View { @@ -101,7 +102,7 @@ extension ProductDetailView { init(productDetail: ProductDetail, state: ProductDetailViewState, dependencies: Dependencies = MockAppDependencies.noOp) { self.dependencies = dependencies self.productDetail = productDetail - container = .init(state: state) + _state = .init(wrappedValue: state) } } diff --git a/Demos/Shopping/Shopping/Views/Product/ProductDetailView/ProductDetailViewController.swift b/Demos/Shopping/Shopping/Views/Product/ProductDetailView/ProductDetailViewController.swift index f7b5554..71dfd52 100644 --- a/Demos/Shopping/Shopping/Views/Product/ProductDetailView/ProductDetailViewController.swift +++ b/Demos/Shopping/Shopping/Views/Product/ProductDetailView/ProductDetailViewController.swift @@ -5,12 +5,11 @@ // Created by Albert Bori on 1/27/23. // -import Combine import SwiftUI import UIKit import VSM -class ProductDetailViewController: UIViewController, ViewStateRendering { +class ProductDetailViewController: UIViewController { typealias Dependencies = AddToCartModel.Dependencies & FavoriteButtonView.Dependencies @IBOutlet weak var priceLabel: UILabel! @@ -31,14 +30,13 @@ class ProductDetailViewController: UIViewController, ViewStateRendering { let dependencies: Dependencies let productDetail: ProductDetail - var container: StateContainer - private var stateSubscription: AnyCancellable? + @RenderedViewState var state: ProductDetailViewState init?(dependencies: Dependencies, productDetail: ProductDetail, coder: NSCoder) { self.dependencies = dependencies self.productDetail = productDetail let addToCartModel = AddToCartModel(dependencies: dependencies, productId: productDetail.id) - container = .init(state: .viewing(addToCartModel)) + _state = .init(wrappedValue: .viewing(addToCartModel), render: Self.render) super.init(coder: coder) } @@ -48,10 +46,6 @@ class ProductDetailViewController: UIViewController, ViewStateRendering { override func viewDidLoad() { super.viewDidLoad() - stateSubscription = container.$state - .sink { [weak self] newState in - self?.render(newState) - } // create a FavoriteButtonView and add it to the favoriteButtonContainerView let favoriteButtonViewController = UIHostingController(rootView: FavoriteButtonView(dependencies: dependencies, productId: productDetail.id, productName: productDetail.name)) @@ -70,7 +64,7 @@ class ProductDetailViewController: UIViewController, ViewStateRendering { guard let strongSelf = self else { return } switch strongSelf.state { case .viewing(let addToCartModel), .addedToCart(let addToCartModel), .addToCartError(message: _, let addToCartModel): - self?.container.observe(addToCartModel.addToCart()) + self?.$state.observe(addToCartModel.addToCart()) default: break } @@ -86,7 +80,7 @@ class ProductDetailViewController: UIViewController, ViewStateRendering { errorView.isHidden = true } - func render(_ state: ProductDetailViewState) { + func render() { switch state { case .viewing: // update the UI with the product details diff --git a/Demos/Shopping/Shopping/Views/Product/ProductView.swift b/Demos/Shopping/Shopping/Views/Product/ProductView.swift index a88a665..4df2f8b 100644 --- a/Demos/Shopping/Shopping/Views/Product/ProductView.swift +++ b/Demos/Shopping/Shopping/Views/Product/ProductView.swift @@ -8,11 +8,11 @@ import SwiftUI import VSM -struct ProductView: View, ViewStateRendering { +struct ProductView: View { typealias Dependencies = ProductDetailLoaderModel.Dependencies & ProductDetailView.Dependencies & CartButtonView.Dependencies let dependencies: Dependencies let productId: Int - @ObservedObject var container: StateContainer + @ViewState var state: ProductViewState init(dependencies: Dependencies, productId: Int) { self.dependencies = dependencies @@ -21,19 +21,19 @@ struct ProductView: View, ViewStateRendering { dependencies: dependencies, productId: productId ) - container = .init(state: .initialized(initializedModule)) - container.observe(initializedModule.loadProductDetail()) + _state = .init(wrappedValue: .initialized(initializedModule)) } var body: some View { Group { - switch container.state { + switch state { case .initialized, .loading: ProgressView() case .loaded(let productDetail): ProductDetailView(dependencies: dependencies, productDetail: productDetail) case .error(message: let message, retry: let retryAction): - loadingErrorView(message: message, retryAction: { container.observe(retryAction()) }) + loadingErrorView(message: message, retryAction: { $state.observe(retryAction()) }) + } } .toolbar { @@ -41,6 +41,11 @@ struct ProductView: View, ViewStateRendering { CartButtonView(dependencies: dependencies) } } + .onAppear { + if case .initialized(let initializedModule) = state { + $state.observe(initializedModule.loadProductDetail()) + } + } } func addToCartButton(text: String, style: Style, action: (() -> Void)?) -> some View { @@ -69,7 +74,7 @@ extension ProductView { init(state: ProductViewState, productId: Int = 0, dependencies: Dependencies = MockAppDependencies.noOp) { self.dependencies = dependencies self.productId = productId - container = .init(state: state) + _state = .init(wrappedValue: state) } } diff --git a/Demos/Shopping/Shopping/Views/Product/ProductViewController.swift b/Demos/Shopping/Shopping/Views/Product/ProductViewController.swift index 37062da..21f99ee 100644 --- a/Demos/Shopping/Shopping/Views/Product/ProductViewController.swift +++ b/Demos/Shopping/Shopping/Views/Product/ProductViewController.swift @@ -5,7 +5,6 @@ // Created by Albert Bori on 1/27/23. // -import Combine import SwiftUI import UIKit import VSM @@ -14,8 +13,7 @@ class ProductViewController: UIViewController { typealias Dependencies = ProductDetailLoaderModel.Dependencies & ProductDetailView.Dependencies & CartButtonView.Dependencies let dependencies: Dependencies let productId: Int - var container: StateContainer - private var stateSubscription: AnyCancellable? + @RenderedViewState var state: ProductViewState lazy var activityIndicatorView: UIActivityIndicatorView = UIActivityIndicatorView.init() private var productDetailViewController: ProductDetailViewController? @@ -27,7 +25,7 @@ class ProductViewController: UIViewController { dependencies: dependencies, productId: productId ) - container = .init(state: ProductViewState.initialized(initializedModel)) + _state = .init(wrappedValue: ProductViewState.initialized(initializedModel), render: Self.render) super.init(nibName: nil, bundle: nil) } @@ -37,10 +35,6 @@ class ProductViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - stateSubscription = container.$state - .sink { [weak self] newState in - self?.render(newState) - } view.addSubview(activityIndicatorView) activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false @@ -48,8 +42,8 @@ class ProductViewController: UIViewController { activityIndicatorView.centerYAnchor.constraint(equalTo: view.centerYAnchor), activityIndicatorView.centerXAnchor.constraint(equalTo: view.centerXAnchor) ]) - if case .initialized(let initializedModel) = container.state { - container.observe(initializedModel.loadProductDetail()) + if case .initialized(let initializedModel) = state { + $state.observe(initializedModel.loadProductDetail()) } } @@ -60,7 +54,7 @@ class ProductViewController: UIViewController { parent?.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: cartButtonViewController.view) } - func render(_ state: ProductViewState) { + func render() { switch state { case .initialized, .loading: activityIndicatorView.isHidden = false @@ -88,11 +82,11 @@ class ProductViewController: UIViewController { contentViewController.view.centerXAnchor.constraint(equalTo: view.centerXAnchor) ]) productDetailViewController = contentViewController - contentViewController.didMove(toParent: self) + contentViewController.didMove(toParent: self) case .error(message: let message, retry: let retry): showErrorAlert(message: message, button: (title: "Retry", action: { [weak self] in - self?.container.observe(retry()) + self?.$state.observe(retry()) })) } } diff --git a/Demos/Shopping/Shopping/Views/Products/ProductsView.swift b/Demos/Shopping/Shopping/Views/Products/ProductsView.swift index f4dd784..43de6bb 100644 --- a/Demos/Shopping/Shopping/Views/Products/ProductsView.swift +++ b/Demos/Shopping/Shopping/Views/Products/ProductsView.swift @@ -8,16 +8,15 @@ import SwiftUI import VSM -struct ProductsView: View, ViewStateRendering { +struct ProductsView: View { typealias Dependencies = ProductsLoaderModel.Dependencies & ProductGridItemView.Dependencies & CartButtonView.Dependencies let dependencies: Dependencies - @ObservedObject private(set) var container: StateContainer + @ViewState var state: ProductsViewState init(dependencies: Dependencies) { self.dependencies = dependencies let loaderModel = ProductsLoaderModel(dependencies: dependencies) - container = .init(state: .initialized(loaderModel)) - container.observe(loaderModel.loadProducts()) + _state = .init(wrappedValue: .initialized(loaderModel)) } var body: some View { @@ -28,7 +27,7 @@ struct ProductsView: View, ViewStateRendering { case .loaded(let loadedModel): loadedView(loadedModel) case .error(let message, let retryAction): - errorView(message: message, retryAction: { container.observe(retryAction()) }) + errorView(message: message, retryAction: { $state.observe(retryAction()) }) } } .navigationTitle("Products") @@ -37,6 +36,11 @@ struct ProductsView: View, ViewStateRendering { CartButtonView(dependencies: dependencies) } } + .onAppear { + if case .initialized(let loaderModel) = state { + $state.observe(loaderModel.loadProducts()) + } + } } @ViewBuilder @@ -73,7 +77,7 @@ struct ProductsView: View, ViewStateRendering { extension ProductsView { init(dependencies: Dependencies, state: ProductsViewState) { self.dependencies = dependencies - container = .init(state: state) + _state = .init(wrappedValue: state) } } diff --git a/Demos/Shopping/Shopping/Views/Profile/ProfileView.swift b/Demos/Shopping/Shopping/Views/Profile/ProfileView.swift index 1bf4ab2..a4fb696 100644 --- a/Demos/Shopping/Shopping/Views/Profile/ProfileView.swift +++ b/Demos/Shopping/Shopping/Views/Profile/ProfileView.swift @@ -8,14 +8,14 @@ import SwiftUI import VSM -struct ProfileView: View, ViewStateRendering { +struct ProfileView: View { typealias Dependencies = ProfileLoaderModel.Dependencies - @StateObject var container: StateContainer + @ViewState var state: ProfileViewState @State private var username: String? init(dependencies: Dependencies) { let loaderModel = ProfileLoaderModel(dependencies: dependencies, error: nil) - _container = .init(state: .initialized(loaderModel)) + _state = .init(wrappedValue: .initialized(loaderModel)) } var body: some View { @@ -38,7 +38,7 @@ struct ProfileView: View, ViewStateRendering { func initializedView(loaderModel: ProfileLoaderModeling) -> some View { ProgressView() .onAppear { - container.observeAsync({ await loaderModel.load() }) + $state.observeAsync({ await loaderModel.load() }) } .alert( "Oops!", @@ -46,7 +46,7 @@ struct ProfileView: View, ViewStateRendering { presenting: loaderModel.error ) { error in Button("Retry") { - container.observeAsync({ await loaderModel.load() }) + $state.observeAsync({ await loaderModel.load() }) } } message: { error in Text(error) @@ -61,7 +61,7 @@ struct ProfileView: View, ViewStateRendering { username ?? editingModel.username }, set: { newValue in username = newValue - container.observeAsync({ await editingModel.save(username: newValue)}, debounced: .seconds(0.5)) + $state.observeAsync({ await editingModel.save(username: newValue)}, debounced: .seconds(0.5)) })) .textFieldStyle(.roundedBorder) if case .saving = editingModel.editingState { @@ -84,7 +84,7 @@ struct ProfileView: View, ViewStateRendering { extension ProfileView { init(state: ProfileViewState) { - _container = .init(state: state) + _state = .init(wrappedValue: state) } } diff --git a/Demos/Shopping/Shopping/Views/Settings/SettingsView.swift b/Demos/Shopping/Shopping/Views/Settings/SettingsView.swift index db0f380..9158baa 100644 --- a/Demos/Shopping/Shopping/Views/Settings/SettingsView.swift +++ b/Demos/Shopping/Shopping/Views/Settings/SettingsView.swift @@ -10,50 +10,46 @@ import VSM // This view shows two examples of how to handle cases where a value that is controlled by the view should be synchronized with the State and/or State Model. // Note that in this example, the "S" in "VSM" is silent, because the corresponding view has a single state, which is implied by a single State-Model type -struct SettingsView: View, ViewStateRendering { +struct SettingsView: View { typealias Dependencies = SettingsViewState.Dependencies - @StateObject var container: StateContainer + @ViewState var state: SettingsViewStating // a. Custom Binding Approach (generally recommended) - var isCustomBindingExampleEnabled: Binding + var isCustomBindingExampleEnabled: Binding { + .init( + get: { + state.isCustomBindingExampleEnabled + }, + set: { enabled in + $state.observe(state.toggleIsCustomBindingExampleEnabled(enabled)) + } + ) + } // b. Value Observation & Synchronization - // Be careful with this approach. Incorrect use of `onChange` or `onReceive` can result in undesired side-effects + // Be careful with this approach. Incorrect use of `onChange` or `onReceive` can result in undesired side-effects if configured incorrectly @State var isStateBindingExampleEnabled: Bool // c State-Model Binding Convenience Functions (recommended for when your `ViewState` is not an enum) // c.1 - var isConvenienceBindingExampleEnabled1: Binding - // c.2 - var isConvenienceBindingExampleEnabled2: Binding - - init(dependencies: Dependencies) { - let container: StateContainer = .init(state: SettingsViewState(dependencies: dependencies)) - _container = .init(wrappedValue: container) - - // a. - isCustomBindingExampleEnabled = .init( - get: { - container.state.isCustomBindingExampleEnabled - }, - set: { enabled in - container.observe(container.state.toggleIsCustomBindingExampleEnabled(enabled)) - }) - - // b. - _isStateBindingExampleEnabled = .init(initialValue: container.state.isStateBindingExampleEnabled) - - // c.1 - isConvenienceBindingExampleEnabled1 = container.bind( + var isConvenienceBindingExampleEnabled1: Binding { + $state.bind( \.isConvenienceBindingExampleEnabled1, to: { state, newValue in state.toggleIsConvenienceBindingExampleEnabled1(newValue) } ) - - // c.2 - isConvenienceBindingExampleEnabled2 = container.bind(\.isConvenienceBindingExampleEnabled2, to: ViewState.toggleIsConvenienceBindingExampleEnabled2) - // The last parameter in c.2 can also be `SettingsViewState.toggleIsConvenienceBindingExampleEnabled2`. ViewState is just a generic type alias. + } + + // c.2 + var isConvenienceBindingExampleEnabled2: Binding { + $state.bind(\.isConvenienceBindingExampleEnabled2, to: SettingsViewStating.toggleIsConvenienceBindingExampleEnabled2) + } + + init(dependencies: Dependencies) { + let state = SettingsViewState(dependencies: dependencies) + _state = .init(wrappedValue: state) + _isStateBindingExampleEnabled = .init(initialValue: state.isStateBindingExampleEnabled) } var body: some View { @@ -65,10 +61,10 @@ struct SettingsView: View, ViewStateRendering { // b. Toggle("State Binding", isOn: $isStateBindingExampleEnabled) .onChange(of: isStateBindingExampleEnabled) { enabled in - container.observe(container.state.toggleIsStateBindingExampleEnabled(enabled)) + $state.observe(state.toggleIsStateBindingExampleEnabled(enabled)) } - .onReceive(container.$state) { state in - isStateBindingExampleEnabled = state.isStateBindingExampleEnabled + .onReceive($state.publisher.map(\.isStateBindingExampleEnabled)) { enabled in + isStateBindingExampleEnabled = enabled } .accessibilityIdentifier("State Binding Toggle") diff --git a/Demos/Shopping/Shopping/Views/Settings/SettingsViewState.swift b/Demos/Shopping/Shopping/Views/Settings/SettingsViewState.swift index a638f3a..ec9ca86 100644 --- a/Demos/Shopping/Shopping/Views/Settings/SettingsViewState.swift +++ b/Demos/Shopping/Shopping/Views/Settings/SettingsViewState.swift @@ -9,7 +9,19 @@ import Foundation import VSM // Note that in this example, the "S" in "VSM" is silent, because the corresponding view has a single state, which is implied by a single State-Model type -struct SettingsViewState: MutatingCopyable { +protocol SettingsViewStating { + var isCustomBindingExampleEnabled: Bool { get } + var isStateBindingExampleEnabled: Bool { get } + var isConvenienceBindingExampleEnabled1: Bool { get } + var isConvenienceBindingExampleEnabled2: Bool { get } + + func toggleIsCustomBindingExampleEnabled(_ enabled: Bool) -> Self + func toggleIsStateBindingExampleEnabled(_ enabled: Bool) -> Self + func toggleIsConvenienceBindingExampleEnabled1(_ enabled: Bool) -> Self + func toggleIsConvenienceBindingExampleEnabled2(_ enabled: Bool) -> Self +} + +struct SettingsViewState: SettingsViewStating, MutatingCopyable { typealias Dependencies = UserDefaultsDependency enum SettingKey { diff --git a/Package.swift b/Package.swift index 1f90dd2..6ba0822 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let package = Package( name: "VSM", platforms: [ .iOS(.v13), - .macOS(.v10_15) + .macOS(.v11) ], products: [ .library( diff --git a/Sources/VSM/StateContainer+Binding.swift b/Sources/VSM/StateContainer/StateContainer+Binding.swift similarity index 100% rename from Sources/VSM/StateContainer+Binding.swift rename to Sources/VSM/StateContainer/StateContainer+Binding.swift diff --git a/Sources/VSM/StateContainer+Debug.swift b/Sources/VSM/StateContainer/StateContainer+Debug.swift similarity index 97% rename from Sources/VSM/StateContainer+Debug.swift rename to Sources/VSM/StateContainer/StateContainer+Debug.swift index 9ad0d33..0e4ce6d 100644 --- a/Sources/VSM/StateContainer+Debug.swift +++ b/Sources/VSM/StateContainer/StateContainer+Debug.swift @@ -12,12 +12,12 @@ import Foundation public extension StateContaining { - @available(*, deprecated, message: "This debug statement only compiles in DEBUG schemas.") - @discardableResult /// Prints all state changes in this `StateContainer`, starting with the current state. ⚠️ Requires DEBUG configuration. /// This can be used multiple times per state container with different options. /// - Parameter options: Controls the type of information you want to see in each log. Defaults to `.default` /// - Returns: Self + @available(*, deprecated, message: "This debug statement only compiles in DEBUG schemas.") + @discardableResult func _debug(options: _StateContainerDebugOptions = .defaults) -> Self { if let stateContainer = self as? StateContainer { stateContainer.debugLogger.startLogging(for: stateContainer, options: options) @@ -26,7 +26,9 @@ public extension StateContaining { } } -public extension StateContaining where State == Any { +public protocol _StateContainerStaticDebugging { } + +public extension _StateContainerStaticDebugging { /// Prints all state changes in every `StateContainer` created after this line. ⚠️ Requires DEBUG configuration. /// - Parameter options: Controls the type of information you want to see in each log. Defaults to `.default` @@ -36,6 +38,8 @@ public extension StateContaining where State == Any { } } +extension StateContainer: _StateContainerStaticDebugging where State == Any { } + #endif extension StateContainer { diff --git a/Sources/VSM/StateContainer.swift b/Sources/VSM/StateContainer/StateContainer.swift similarity index 100% rename from Sources/VSM/StateContainer.swift rename to Sources/VSM/StateContainer/StateContainer.swift diff --git a/Sources/VSM/StateContainer/StateObserving.swift b/Sources/VSM/StateContainer/StateObserving.swift index 5863ff1..121c4e6 100644 --- a/Sources/VSM/StateContainer/StateObserving.swift +++ b/Sources/VSM/StateContainer/StateObserving.swift @@ -60,7 +60,7 @@ public protocol StateObserving { /// - dueTime: The amount of time required to pass before invoking the most recent action /// - identifier: The identifier for grouping actions for debouncing func observeAsync( - _ nextState: @escaping () async -> State, // "async" parameter name solves runtime closure disambiguation bug + _ nextState: @escaping () async -> State, debounced dueTime: DispatchQueue.SchedulerTimeType.Stride, identifier: AnyHashable ) @@ -118,7 +118,7 @@ public extension StateObserving { /// - nextState: The action to be debounced before invoking /// - dueTime: The amount of time required to pass before invoking the most recent action func observeAsync( - _ nextState: @escaping () async -> State, // "async" parameter name solves runtime closure disambiguation bug + _ nextState: @escaping () async -> State, debounced dueTime: DispatchQueue.SchedulerTimeType.Stride, file: String = #file, line: UInt = #line diff --git a/Sources/VSM/StateObject+StateInit.swift b/Sources/VSM/StateObject+StateInit.swift index ec0e49c..cbd374c 100644 --- a/Sources/VSM/StateObject+StateInit.swift +++ b/Sources/VSM/StateObject+StateInit.swift @@ -10,6 +10,7 @@ import SwiftUI @available(macOS 11.0, *) @available(iOS 14.0, *) +@available(*, deprecated, message: "Use the ViewState property wrapper instead") public extension StateObject { /// VSM convenience initializer for creating a `StateObject>` directly from a `StateContainer.State` value. diff --git a/Sources/VSM/StateSequence.swift b/Sources/VSM/StateSequence.swift index 98191c2..a7c7e61 100644 --- a/Sources/VSM/StateSequence.swift +++ b/Sources/VSM/StateSequence.swift @@ -9,7 +9,7 @@ import Foundation /// Emits multiple `State`s as an `AsyncSequence` /// -/// Usable with ``StateContainer/observe(_:)-4pebt`` (found in ``StateContainer`` or ``ViewStateRendering``) +/// Usable with ``StateContainer/observe(_:)-4pebt`` (found in ``StateContainer``) /// /// Example Usage /// diff --git a/Sources/VSM/ViewState/AtomicStateChangeSubscriber.swift b/Sources/VSM/ViewState/AtomicStateChangeSubscriber.swift new file mode 100644 index 0000000..cbc7da8 --- /dev/null +++ b/Sources/VSM/ViewState/AtomicStateChangeSubscriber.swift @@ -0,0 +1,38 @@ +// +// AtomicStateChangeSubscriber.swift +// +// +// Created by Albert Bori on 12/8/22. +// + +import Combine +import Foundation + +class AtomicStateChangeSubscriber { + private var subscription: AnyCancellable? + /// This property ensures that if `subscribe` is called while we're still subscribing (recursively), it will not cause an infinite loop. + private var willSubscribe: Bool = true + + /// Subscribes to changes in state. This subscription happens exactly once even if called repeatedly in a multi-threaded environment. + /// + /// This is primarily used for wiring up any non-standard state observation, such as within the static subscript of a property wrapper. + func subscribeOnce(to statePublisher: some Publisher, receivedValue: @escaping (State) -> Void) { + // Ensure we are subscribing on the main thread for thread safety of shared mutable properties of this object + if Thread.isMainThread { + subscribeOnceSynced(to: statePublisher, receivedValue: receivedValue) + } else { + DispatchQueue.main.async { + self.subscribeOnceSynced(to: statePublisher, receivedValue: receivedValue) + } + } + } + + private func subscribeOnceSynced(to statePublisher: some Publisher, receivedValue: @escaping (State) -> Void) { + guard subscription == nil, willSubscribe else { return } + willSubscribe = false + subscription = statePublisher + .sink { state in + receivedValue(state) + } + } +} diff --git a/Sources/VSM/ViewState/RenderedViewState+Debug.swift b/Sources/VSM/ViewState/RenderedViewState+Debug.swift new file mode 100644 index 0000000..93eddc2 --- /dev/null +++ b/Sources/VSM/ViewState/RenderedViewState+Debug.swift @@ -0,0 +1,13 @@ +// +// RenderedViewState+Debug.swift +// +// +// Created by Albert Bori on 1/31/23. +// + +#if DEBUG + +@available(iOS 14.0, *) +extension RenderedViewState: _StateContainerStaticDebugging where State == Any { } + +#endif diff --git a/Sources/VSM/ViewState/RenderedViewState.swift b/Sources/VSM/ViewState/RenderedViewState.swift new file mode 100644 index 0000000..aca38cf --- /dev/null +++ b/Sources/VSM/ViewState/RenderedViewState.swift @@ -0,0 +1,143 @@ +// +// RenderedViewState.swift +// +// +// Created by Albert Bori on 12/23/22. +// + +/// **(UIKit Only)** Manages the view state for a UIView or UIViewController in VSM. Automatically calls `render()` when the view state changes. +/// +/// This property wrapper encapsulates a view's state property with an underlying `StateContainer` to provide the current view state . +/// A subset of `StateContainer` members are available through the `$` prefix, such as `observe(...)` and `bind(...)`. +/// +/// **Usage** +/// +/// Decorate your view state property with this property wrapper. +/// +/// Direct Initialization Example: +/// +/// ```swift +/// class MyViewController: UIViewController { +/// @RenderedViewState var state: MyViewState +/// +/// init(state: MyViewState) { +/// _state = .init(wrappedValue: state, render: Self.render) +/// super.init(bundle: nil, nib: nil) +/// } +/// +/// func render() { +/// if state.someValue { +/// ... +/// $state.observe(state.someAction()) +/// } +/// } +/// } +/// ``` +/// +/// Implicit Initialization Example: +/// +/// ```swift +/// class MyViewController: UIViewController { +/// @RenderedViewState(render: MyViewController.render) +/// var state: MyViewState = MyViewState() +/// +/// func render() { +/// if state.someValue { +/// ... +/// $state.observe(state.someAction()) +/// } +/// } +/// } +/// ``` +@available(iOS 14.0, *) +@propertyWrapper +public struct RenderedViewState { + + let container: StateContainer + /// Tracks state changes for invoking `render` when the state changes + let stateDidChangeSubscriber: AtomicStateChangeSubscriber = .init() + /// Implicitly used by UIKit views to automatically call the provided function when the state changes + var render: (AnyObject, State) -> () + + // MARK: - Encapsulating Properties + + public var wrappedValue: State { + get { container.state } + } + + public var projectedValue: some StateContaining { + container + } + + // MARK: - Initializers + + /// **(UIKit only)** Instantiates the rendered view state with a custom state container. + /// - Parameters: + /// - container: The state container that manages the view state. + /// - render: The function to call when the view state changes. + public init(container: StateContainer, render: @escaping (Parent) -> () -> ()) { + self.container = container + let anyRender: (Any, State) -> () = { parent, state in + guard let parent = parent as? Parent else { return } + render(parent)() + } + self.render = anyRender + } + + /// **(UIKit only)** Instantiates the rendered view state with an initial value. + /// + /// Example: + /// ```swift + /// class MyViewController: UIViewController { + /// @RenderedViewState var state: MyViewState + /// + /// init(state: MyViewState) { + /// _state = .init(wrappedValue: state, render: Self.render) + /// super.init(bundle: nil, nib: nil) + /// } + /// + /// func render() { + /// if state.someValue { + /// ... + /// $state.observe(state.someAction()) + /// } + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - wrappedValue: The view state to be managed by the state container. + /// - render: The function to call when the view state changes. + public init( + wrappedValue: State, + render: @escaping (Parent) -> () -> () + ) { + self.init(container: StateContainer(state: wrappedValue), render: render) + } + + // MARK: - Automatic Rendering + + /// Automatically calls `render()` when the state changes on any class that has a property decorated with this property wrapper. (Intended for UIKit only) + /// + /// For the behavior to take effect, the property's parent type must be a `class` that provides a `render` value on initialization and will usually be some sort of `UIView` or `UIViewController` subclass. + /// This is helpful for implementing VSM with **UIKit** views and view controllers in that it handles the "auto-updating" behavior that comes implicitly with SwiftUI. + /// + /// Maintenance Note: The Swift runtime automatically calls this subscript each time the wrapped property is accessed. + /// It can be called many, many times. Any operations within the subscript must be performant, thread-safe, and duplication-resistant. + public static subscript( + _enclosingInstance instance: ParentClass, + wrapped wrappedKeyPath: KeyPath, + storage storageKeyPath: KeyPath> + ) -> State { + get { + let wrapper = instance[keyPath: storageKeyPath] + wrapper + .stateDidChangeSubscriber + .subscribeOnce(to: wrapper.container.publisher) { [weak instance] newState in + guard let instance else { return } + wrapper.render(instance, newState) + } + return wrapper.wrappedValue + } + } +} diff --git a/Sources/VSM/ViewState/ViewState+Debug.swift b/Sources/VSM/ViewState/ViewState+Debug.swift new file mode 100644 index 0000000..6811f9b --- /dev/null +++ b/Sources/VSM/ViewState/ViewState+Debug.swift @@ -0,0 +1,13 @@ +// +// ViewState+Debug.swift +// +// +// Created by Albert Bori on 1/31/23. +// + +#if DEBUG + +@available(iOS 14.0, *) +extension ViewState: _StateContainerStaticDebugging where State == Any { } + +#endif diff --git a/Sources/VSM/ViewState/ViewState.swift b/Sources/VSM/ViewState/ViewState.swift new file mode 100644 index 0000000..85f415e --- /dev/null +++ b/Sources/VSM/ViewState/ViewState.swift @@ -0,0 +1,84 @@ +// +// ViewState.swift +// +// +// Created by Albert Bori on 11/18/22. +// + +#if canImport(SwiftUI) +import Combine +import SwiftUI + +/// **(SwiftUI Only)** Manages the view state for a SwiftUI View in VSM. Automatically updates the view when the state changes. +/// +/// This property wrapper encapsulates a view's state property with an underlying `StateContainer` to provide the current view state . +/// A subset of `StateContainer` members are available through the `$` prefix, such as `observe(...)` and `bind(...)`. +/// +/// **Usage* +/// +/// Decorate your view state property with this property wrapper. +/// +/// Example: +/// +/// ```swift +/// struct MyView: View { +/// @ViewState var state: MyViewState +/// +/// var body: some View { +/// Button(state.someValue) { +/// $state.observe(state.someAction()) +/// } +/// } +/// } +/// ``` +@available(iOS 14.0, *) +@propertyWrapper +public struct ViewState: DynamicProperty { + + @StateObject var container: StateContainer + + // MARK: - Encapsulating Properties + + public var wrappedValue: State { + get { container.state } + } + + public var projectedValue: some StateContaining { + container + } + + // MARK: - Initializers + + /// **(SwiftUI Only)** Instantiates the rendered view state with a custom state container. + /// - Parameter container: The state container that manages the view state. + public init(container: StateContainer) { + self._container = .init(wrappedValue: container) + } + + /// **(SwiftUI only)** Instantiates the view state with an initial value. + /// + /// Example: + /// + /// ```swift + /// struct MyView: View { + /// @ViewState var state: MyViewState + /// + /// init() { + /// let myViewState = MyViewState() + /// _state = .init(wrappedValue: myViewState) + /// } + /// + /// var body: some View { + /// Button(state.someValue) { + /// $state.observe(state.someAction()) + /// } + /// } + /// } + /// ``` + /// + /// - Parameter wrappedValue: The view state to be managed by the state container. + public init(wrappedValue: State) { + self.init(container: StateContainer(state: wrappedValue)) + } +} +#endif diff --git a/Sources/VSM/ViewStateRendering.swift b/Sources/VSM/ViewStateRendering.swift index fea834a..3a55204 100644 --- a/Sources/VSM/ViewStateRendering.swift +++ b/Sources/VSM/ViewStateRendering.swift @@ -67,6 +67,7 @@ import Combine /// } /// } /// ``` +@available(*, deprecated, message: "Adopt the @ViewState (or @RenderedViewState) property wrapper or work directly with a StateContainer.") public protocol ViewStateRendering { /// The type that represents your View's state. @@ -82,6 +83,7 @@ public protocol ViewStateRendering { //MARK: - State Extension +@available(*, deprecated, message: "Adopt the @ViewState (or @RenderedViewState) property wrapper or work directly with a StateContainer.") public extension ViewStateRendering { /// Convenience accessor for the `StateContainer`'s `state` property. @@ -92,6 +94,7 @@ public extension ViewStateRendering { // MARK: - Observe Extensions +@available(*, deprecated, message: "Adopt the @ViewState (or @RenderedViewState) property wrapper or work directly with a StateContainer.") public extension ViewStateRendering { /// Convenience accessor for the `StateContainer`'s `observe` function. @@ -127,6 +130,7 @@ import SwiftUI // MARK: - Synchronous Observed Binding Extensions +@available(*, deprecated, message: "Adopt the @ViewState (or @RenderedViewState) property wrapper or work directly with a StateContainer.") public extension ViewStateRendering where Self: View { /// Creates a unidirectional, auto-observing `Binding` for the `ViewState` using a `KeyPath` and a basic closure. @@ -153,6 +157,7 @@ public extension ViewStateRendering where Self: View { // MARK: - Asynchronous Observed Binding Extensions +@available(*, deprecated, message: "Adopt the @ViewState (or @RenderedViewState) property wrapper or work directly with a StateContainer.") public extension ViewStateRendering where Self: View { /// Creates a unidirectional, auto-observing `Binding` for the `ViewState` using a `KeyPath` and a basic closure. @@ -179,6 +184,7 @@ public extension ViewStateRendering where Self: View { // MARK: - ViewState-Publishing Observed Binding Extensions +@available(*, deprecated, message: "Adopt the @ViewState (or @RenderedViewState) property wrapper or work directly with a StateContainer.") public extension ViewStateRendering where Self: View { /// Creates a unidirectional, auto-observing `Binding` for the `ViewState` using a `KeyPath` and a basic closure. @@ -207,6 +213,7 @@ public extension ViewStateRendering where Self: View { // MARK: Observe Debounce Extensions +@available(*, deprecated, message: "Adopt the @ViewState (or @RenderedViewState) property wrapper or work directly with a StateContainer.") public extension ViewStateRendering { /// Debounces the action calls by `dueTime`, then observes the `State` publisher emitted as a result of invoking the action.