Skip to content

Instantly share code, notes, and snippets.

@garsdle
Last active December 4, 2021 05:21
Show Gist options
  • Save garsdle/6be29d7a23d7f5135c7e0f1ff27694e9 to your computer and use it in GitHub Desktop.
Save garsdle/6be29d7a23d7f5135c7e0f1ff27694e9 to your computer and use it in GitHub Desktop.
Count app using vanilla SwiftUI, container views and Equatable
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