Part III
Shopping App
The Model Layer
KISS - Simple design become more easy to do, more easy to maintain / scale, more easy to find & fix bugs, more easy to test.
You can use in UIKit, SwiftUI, iOS, tvOS, macOS, Terminal, Unit Tests, …hey! You can make a Swift Package and share with other apps. It works like magic!
In a big app you can have more models “Workout model” + Nutrition model”, “LiveTV model” + “VideoOnDemand model” + “Network & Subscription model”, …
You can use the “TEST” (like DEBUG) flag for mocks, in WebService provider level but also in your factory methods. Also you can extend the provider and override the request method… there are 1001 ways to mock… but remember don’t sacrifice the simplicity for testing.
The View Layer
A view has a body and handle presentation logic.
SwiftUI automatically performs most of the work traditionally done by view controllers (and view models), but what about the other work like fetch states or pagination? SwiftUI is very good at composable / components and there are modifiers and property wrappers, just use your imagination and the power of the SwiftUI… yes you don’t need extra layers, in last resort make utility objects (e.g. FetchableObject) in your “Shared” folder. With Swift Concurrency everything becomes easy to use and to reuse.
@AsyncState private var products: [Product] = []
List { … }
.asyncState(…)
Example of fetch state property wrapper + modifier.
import SwiftUI
public enum AsyncStatePhase {
case initial
case loading
case empty
case success(Date)
case failure(Error)
public var isLoading: Bool {
if case .loading = self {
return true
}
return false
}
public var lastUpdated: Date? {
if case let .success(d) = self {
return d
}
return nil
}
public var error: Error? {
if case let .failure(e) = self {
return e
}
return nil
}
}
extension View {
@ViewBuilder
public func asyncState<InitialContent: View,
LoadingContent: View,
EmptyContent: View,
FailureContent: View>(_ phase: AsyncStatePhase,
initialContent: InitialContent,
loadingContent: LoadingContent,
emptyContent: EmptyContent,
failureContent: FailureContent) -> some View {
switch phase {
case .initial:
initialContent
case .loading:
loadingContent
case .empty:
emptyContent
case .success:
self
case .failure:
failureContent
}
}
}
@propertyWrapper
public struct AsyncState<Value: Codable>: DynamicProperty {
@State public var phase: AsyncStatePhase = .initial
@State private var value: Value
public var wrappedValue: Value {
get { value }
nonmutating set {
value = newValue
}
}
public var isEmpty: Bool {
if (value as AnyObject) is NSNull {
return true
} else if let val = value as? Array<Any>, val.isEmpty {
return true
} else {
return false
}
}
public init(wrappedValue value: Value) {
self._value = State(initialValue: value)
}
@State private var retryTask: (() async throws -> Value)? = nil
public func fetch(expiration: TimeInterval = 120, task: @escaping () async throws -> Value) async {
self.retryTask = nil
if !(phase.lastUpdated?.hasExpired(in: expiration) ?? true) {
return
}
Task {
do {
phase = .loading
value = try await task()
if isEmpty {
self.retryTask = task
phase = .empty
} else {
phase = .success(Date())
}
} catch _ as CancellationError {
// Keep current state (loading)
} catch {
self.retryTask = task
phase = .failure(error)
}
}
}
public func retry() async {
guard let task = retryTask else { return }
await fetch(task: task)
}
public func hasExpired(in interval: TimeInterval) -> Bool {
phase.lastUpdated?.hasExpired(in: interval) ?? true
}
public func invalidate() {
if case .success = phase {
phase = .success(.distantPast)
}
}
}
extension View {
@ViewBuilder
public func asyncState<T: Codable,
InitialContent: View,
LoadingContent: View,
EmptyContent: View,
FailureContent: View>(_ state: AsyncState<T>,
initialContent: InitialContent,
loadingContent: LoadingContent,
emptyContent: EmptyContent,
failureContent: FailureContent) -> some View {
asyncState(state.phase,
initialContent: initialContent,
loadingContent: loadingContent,
emptyContent: emptyContent,
failureContent: failureContent)
}
}
Hope popular needs / solutions implemented in SwiftUI out of the box.