Skip to content

Commit

Permalink
Updated demo app to demonstrate using Pull To Refresh with VSM in the…
Browse files Browse the repository at this point in the history
… “ProductsView” SwiftUI view
  • Loading branch information
Bill Dunay committed Aug 22, 2024
1 parent 204526a commit d1bee3f
Show file tree
Hide file tree
Showing 4 changed files with 62 additions and 4 deletions.
15 changes: 14 additions & 1 deletion Demos/Shopping/Shopping/Dependencies/ProductRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import Foundation
protocol ProductRepository {
func getGridProducts() -> AnyPublisher<[GridProduct], Error>
func getProductDetail(id: Int) -> AnyPublisher<ProductDetail, Error>

nonisolated func getGridProductsAsync() async throws -> [GridProduct]
}

protocol ProductRepositoryDependency {
Expand Down Expand Up @@ -60,6 +62,11 @@ class ProductDatabase: ProductRepository {
}.eraseToAnyPublisher()
}

nonisolated func getGridProductsAsync() async throws -> [GridProduct] {
try await Task.sleep(nanoseconds: AppConstants.simulatedNetworkNanoseconds)
return Self.allProducts.map({ .init(id: $0.id, name: $0.name, imageURL: $0.imageURL) })
}

func getProductDetail(id: Int) -> AnyPublisher<ProductDetail, Error> {
struct NotFoundError: Error { }
return Future { promise in
Expand All @@ -79,7 +86,8 @@ struct MockProductRepository: ProductRepository {
static var noOp: Self {
Self.init(
getGridProductsImpl: { .empty() },
getProductsDetailImpl: { _ in .empty() }
getProductsDetailImpl: { _ in .empty() },
getGridProductsAsyncImpl: { [] }
)
}

Expand All @@ -92,4 +100,9 @@ struct MockProductRepository: ProductRepository {
func getProductDetail(id: Int) -> AnyPublisher<ProductDetail, Error> {
getProductsDetailImpl(id)
}

var getGridProductsAsyncImpl: () async throws -> [GridProduct]
nonisolated func getGridProductsAsync() async throws -> [GridProduct] {
try await getGridProductsAsyncImpl()
}
}
19 changes: 18 additions & 1 deletion Demos/Shopping/Shopping/Views/Products/ProductsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,24 @@ struct ProductsView: View {
Text("No products available.")
}
} else {
let columns = Array(repeating: GridItem(.flexible()), count: 2)
loadedGridContentView(columns: Array(repeating: GridItem(.flexible()), count: 2), loadedModel: loadedModel)
}
}

@ViewBuilder
func loadedGridContentView(columns: [GridItem], loadedModel: ProductsLoadedModeling) -> some View {
if #available(iOS 16, *) {
ScrollView(.vertical, showsIndicators: false) {
LazyVGrid(columns: columns) {
ForEach(loadedModel.products, id: \.id) { product in
ProductGridItemView(dependencies: dependencies, product: product)
}
}
}
.refreshable {
await $state.waitFor { await loadedModel.refreshProducts() }
}
} else {
ScrollView(.vertical, showsIndicators: false) {
LazyVGrid(columns: columns) {
ForEach(loadedModel.products, id: \.id) { product in
Expand Down
29 changes: 28 additions & 1 deletion Demos/Shopping/Shopping/Views/Products/ProductsViewState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ protocol ProductsLoadedModeling {
var productDetailId: Int? { get }

func showProductDetail(id: Int) -> ProductsViewState
nonisolated func refreshProducts() async -> ProductsViewState
}

// MARK: - Model Implementations
Expand All @@ -37,7 +38,7 @@ struct ProductsLoaderModel: ProductsLoaderModeling {
func loadProducts() -> AnyPublisher<ProductsViewState, Never> {
let statePublisher = Just(ProductsViewState.loading)
let productsPublisher = dependencies.productRepository.getGridProducts()
.map { products in ProductsViewState.loaded(ProductsLoadedModel(products: products)) }
.map { products in ProductsViewState.loaded(ProductsLoadedModel(dependencies: dependencies, products: products)) }
.catch { error in Just(ProductsViewState.error(message: "\(error)", retry: { self.loadProducts() })).eraseToAnyPublisher() }
return statePublisher
.merge(with: productsPublisher)
Expand All @@ -46,6 +47,9 @@ struct ProductsLoaderModel: ProductsLoaderModeling {
}

struct ProductsLoadedModel: ProductsLoadedModeling {
typealias Dependencies = ProductRepositoryDependency

let dependencies: Dependencies
let products: [GridProduct]
var productDetailId: Int? = nil

Expand All @@ -54,4 +58,27 @@ struct ProductsLoadedModel: ProductsLoadedModeling {
mutableCopy.productDetailId = id
return .loaded(mutableCopy)
}

nonisolated func refreshProducts() async -> ProductsViewState {
do {
let products = try await dependencies.productRepository.getGridProductsAsync()
return .loaded(
ProductsLoadedModel(
dependencies: dependencies,
products: products,
productDetailId: productDetailId
)
)

} catch {
return .error(
message: error.localizedDescription,
retry: {
Just(
.initialized(ProductsLoaderModel(dependencies: dependencies))
)
.eraseToAnyPublisher()
})
}
}
}
3 changes: 2 additions & 1 deletion Demos/Shopping/ShoppingTests/ProductsViewStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ class ProductsViewStateTests: XCTestCase {

/// Tests the navigation binding action for `ProductsLoadedModel`
func testNavigation() throws {
let subject = ProductsLoadedModel(products: [], productDetailId: nil)
let mockDependencies = MockAppDependencies.noOp
let subject = ProductsLoadedModel(dependencies: mockDependencies, products: [], productDetailId: nil)
let output = subject.showProductDetail(id: 1)
if case ProductsViewState.loaded(let loadedModel) = output {
XCTAssertEqual(loadedModel.productDetailId, 1)
Expand Down

0 comments on commit d1bee3f

Please sign in to comment.