Skip to content

Commit

Permalink
VSM Property Wrappers (#26)
Browse files Browse the repository at this point in the history
* Added view state wrappers and updated sample app

* Update StateContainer+Debug.swift
  • Loading branch information
albertbori authored Feb 1, 2023
1 parent 8214180 commit 43f5975
Show file tree
Hide file tree
Showing 28 changed files with 475 additions and 155 deletions.
4 changes: 2 additions & 2 deletions Demos/Shopping/Shopping.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<group>";
Expand Down
4 changes: 2 additions & 2 deletions Demos/Shopping/Shopping/ShoppingApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 10 additions & 6 deletions Demos/Shopping/Shopping/Views/Cart/CartButtonView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,32 @@
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<CartButtonViewState>
@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 {
Button(action: { showCart.toggle() }) {
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())
}
}
}
}

Expand All @@ -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)
}
}

Expand Down
36 changes: 18 additions & 18 deletions Demos/Shopping/Shopping/Views/Cart/CartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CartViewState>
@ViewState var state: CartViewState

init(dependencies: Dependencies, showModal: Binding<Bool>) {
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...")
Expand All @@ -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())
}
}
}
Expand All @@ -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())
}
Expand Down Expand Up @@ -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")
}
Expand All @@ -102,29 +102,29 @@ 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()
}
}
}

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")
}
}
Expand Down Expand Up @@ -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)
}
}

Expand Down
34 changes: 19 additions & 15 deletions Demos/Shopping/Shopping/Views/Favorites/FavoritesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<FavoritesViewState>
@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):
Expand All @@ -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 {
Expand All @@ -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")
}
Expand All @@ -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())
}
}
}
Expand All @@ -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)
Expand All @@ -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()) }))
}
}
}
Expand All @@ -112,7 +116,7 @@ struct FavoritesView: View, ViewStateRendering {

extension FavoritesView {
init(state: FavoritesViewState) {
container = .init(state: state)
_state = .init(wrappedValue: state)
}
}

Expand Down
15 changes: 9 additions & 6 deletions Demos/Shopping/Shopping/Views/Main/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<MainViewState>
@ViewState var state: MainViewState

init(appDependenciesProvider: AsyncResource<MainView.Dependencies>) {
let loaderModel = DependenciesLoaderModel(appDependenciesProvider: appDependenciesProvider)
container = .init(state: .initialized(loaderModel))
container.observe(loaderModel.loadDependencies())
_state = .init(wrappedValue: .initialized(loaderModel))
}

var body: some View {
Expand All @@ -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)
Expand Down Expand Up @@ -59,7 +62,7 @@ struct MainView: View, ViewStateRendering {

extension MainView {
init(state: MainViewState) {
container = .init(state: state)
_state = .init(wrappedValue: state)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FavoriteButtonViewState> = .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:
Expand All @@ -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()
}
}) {
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -79,7 +79,7 @@ extension FavoriteButtonView {
self.dependencies = dependencies
self.productId = 0
self.productName = ""
_container = .init(state: state)
_state = .init(wrappedValue: state)
}
}

Expand Down
Loading

0 comments on commit 43f5975

Please sign in to comment.