Last active
December 4, 2021 05:21
-
-
Save garsdle/6be29d7a23d7f5135c7e0f1ff27694e9 to your computer and use it in GitHub Desktop.
Count app using vanilla SwiftUI, container views and Equatable
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import Combine | |
import ComposableArchitecture | |
// MARK: - MODELS | |
struct Counter: Identifiable, Equatable { | |
let id: UUID | |
let createdAt: Date | |
var count: Int | |
} | |
struct FactPrompt: Equatable { | |
let count: Int | |
var fact: String | |
var isLoading = false | |
} | |
struct Alert: Equatable, Identifiable { | |
var message: String | |
var title: String | |
var id: String { | |
self.title + self.message | |
} | |
} | |
// MARK: - APP | |
struct AppEnvironment { | |
let uuid: () -> UUID | |
let date: () -> Date | |
let mainQueue: AnySchedulerOf<DispatchQueue> | |
let factClient: FactClient | |
} | |
extension AppEnvironment { | |
static var live = Self(uuid: UUID.init, | |
date: Date.init, | |
mainQueue: .main, | |
factClient: .live) | |
} | |
class AppData: ObservableObject { | |
@Published private(set) var counters: [Counter.ID: Counter] = [:] | |
@Published private(set) var factPrompt: FactPrompt? | |
@Published private(set) var factAlert: Alert? | |
private let environment: AppEnvironment | |
private var factCancellable: AnyCancellable? | |
init(environment: AppEnvironment) { | |
self.environment = environment | |
} | |
func addCounter() { | |
let newCounter = Counter(id: environment.uuid(), createdAt: environment.date(), count: 0) | |
counters[newCounter.id] = newCounter | |
} | |
func decrement(counterId: Counter.ID) { | |
counters[counterId]?.count -= 1 | |
} | |
func increment(counterId: Counter.ID) { | |
counters[counterId]?.count += 1 | |
} | |
func remove(counterId: Counter.ID) { | |
counters.removeValue(forKey: counterId) | |
} | |
func fectchFactPrompt(count: Int) { | |
factPrompt = FactPrompt(count: count, fact: "", isLoading: true) | |
factCancellable = environment.factClient.fetch(count) | |
.receive(on: environment.mainQueue) | |
.catchToEffect() | |
.sink(receiveValue: factResponse(result:)) | |
} | |
func factResponse(result: Result<String, FactClient.Error>) { | |
switch result { | |
case .failure(let error): | |
print(error) | |
factAlert = Alert(message: "\(error)", title: "Failed to fetch fact") | |
case .success(let fact): | |
factPrompt?.fact = fact | |
factPrompt?.isLoading = false | |
} | |
} | |
func dismissFactPrompt() { | |
factPrompt = nil | |
} | |
func update(alert: Alert?) { | |
factAlert = alert | |
} | |
} | |
// MARK: - APP | |
@main | |
struct DerivedBehaviorApp: App { | |
@StateObject var appData = AppData(environment: .live) | |
var body: some Scene { | |
WindowGroup { | |
NavigationView { | |
MainViewContainer() | |
.environmentObject(appData) | |
} | |
} | |
} | |
} | |
// MARK: - MAIN | |
struct MainViewModel: Equatable { | |
let counters: [Counter] | |
let factPrompt: FactPrompt? | |
} | |
extension MainViewModel { | |
init(counters: [Counter.ID: Counter], factPrompt: FactPrompt?) { | |
self.counters = counters.lazy.map(\.value).sorted(by: \.createdAt) | |
self.factPrompt = factPrompt | |
} | |
} | |
struct MainViewContainer: View { | |
@EnvironmentObject var appData: AppData | |
var body: some View { | |
EquatableValue(MainViewModel(counters: appData.counters, factPrompt: appData.factPrompt)) { model in | |
MainView(model: model, onAddCounter: appData.addCounter) | |
} | |
} | |
} | |
struct MainView: View { | |
let model: MainViewModel | |
let onAddCounter: () -> Void | |
var body: some View { | |
ZStack(alignment: .bottom) { | |
List { | |
ForEach(model.counters) { counter in | |
CounterRowContainer(counter: counter) | |
} | |
} | |
.listStyle(PlainListStyle()) | |
if let factPrompt = model.factPrompt { | |
FactPromptViewContainer(factPrompt: factPrompt) | |
} | |
} | |
.navigationTitle("Counters") | |
.navigationBarItems( | |
trailing: Button("Add", action: onAddCounter) | |
) | |
} | |
} | |
// MARK: - COUNTER | |
struct CounterRowViewModel: Equatable { | |
let counter: Counter | |
let alert: Alert? | |
let count: String | |
} | |
struct CounterRowContainer: View { | |
@EnvironmentObject var appData: AppData | |
let counter: Counter | |
var body: some View { | |
EquatableValue(CounterRowViewModel(counter: counter, alert: appData.factAlert)) { model in | |
CounterRow( | |
model: model, | |
onDecrement: { appData.decrement(counterId: counter.id) }, | |
onIncrement: { appData.increment(counterId: counter.id) }, | |
onRemove: { appData.remove(counterId: counter.id) }, | |
onFact: { appData.fectchFactPrompt(count: counter.count) }, | |
onUpdate: appData.update(alert:) | |
) | |
} | |
} | |
} | |
extension CounterRowViewModel { | |
init(counter: Counter, alert: Alert?) { | |
self.counter = counter | |
self.alert = alert | |
self.count = "\(counter.count)" | |
} | |
} | |
struct CounterRow: View { | |
var model: CounterRowViewModel | |
let onDecrement: () -> Void | |
let onIncrement: () -> Void | |
let onRemove: () -> Void | |
let onFact: () -> Void | |
let onUpdate: (Alert?) -> Void | |
var body: some View { | |
HStack { | |
VStack { | |
HStack { | |
Button("-", action: onDecrement) | |
Text(model.count) | |
Button("+", action: onIncrement) | |
} | |
Button("Fact", action: onFact) | |
} | |
.alert(item: Binding(get: model.alert, set: onUpdate)) { alert in | |
SwiftUI.Alert( | |
title: Text(alert.title), | |
message: Text(alert.message) | |
) | |
} | |
Spacer() | |
Button("Remove", action: onRemove) | |
} | |
.padding() | |
.buttonStyle(PlainButtonStyle()) | |
} | |
} | |
// MARK: - FACT | |
struct FactPrompViewModel: Equatable { | |
let factPrompt: FactPrompt | |
} | |
struct FactPromptViewContainer: View { | |
@EnvironmentObject var appData: AppData | |
let factPrompt: FactPrompt | |
var body: some View { | |
EquatableValue(FactPrompViewModel(factPrompt: factPrompt)) { model in | |
FactPromptView(model: model, | |
onFetchFactPrompt: { appData.fectchFactPrompt(count: factPrompt.count) }, | |
onDismissFactPrompt: appData.dismissFactPrompt) | |
} | |
} | |
} | |
struct FactPromptView: View { | |
let model: FactPrompViewModel | |
let onFetchFactPrompt: () -> Void | |
let onDismissFactPrompt: () -> Void | |
var body: some View { | |
VStack(alignment: .leading, spacing: 16) { | |
VStack(alignment: .leading, spacing: 12) { | |
HStack { | |
Image(systemName: "info.circle.fill") | |
Text("Fact") | |
} | |
.font(.title3.bold()) | |
if model.factPrompt.isLoading { | |
ProgressView() | |
} else { | |
Text(model.factPrompt.fact) | |
} | |
} | |
HStack(spacing: 12) { | |
Button("Get another fact", action: onFetchFactPrompt) | |
Button("Dismiss", action: onDismissFactPrompt) | |
} | |
} | |
.padding() | |
.frame(maxWidth: .infinity, alignment: .leading) | |
.background(Color.white) | |
.cornerRadius(8) | |
.shadow(color: .black.opacity(0.1), radius: 20) | |
.padding() | |
} | |
} | |
// MARK: - EQUATABLE HELPERS | |
struct WrappedEquatableValue<Value: Equatable, Content: View>: View, Equatable { | |
let content: (Value) -> Content | |
let value: Value | |
var body: some View { | |
content(value) | |
} | |
static func == (lhs: WrappedEquatableValue<Value, Content>, rhs: WrappedEquatableValue<Value, Content>) -> Bool { | |
lhs.value == rhs.value | |
} | |
} | |
struct EquatableValue<Value: Equatable, Content: View>: View { | |
let value: Value | |
@ViewBuilder let content: (Value) -> Content | |
init(_ value: Value, @ViewBuilder content: @escaping (Value) -> Content) { | |
self.value = value | |
self.content = content | |
} | |
var body: some View { | |
WrappedEquatableValue(content: content, value: value) | |
.equatable() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment