Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Introduction to the Swift Composable Architecture

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.

Introduction to the Swift Composable Architecture

Avatar for Gyula Voros

Gyula Voros

August 11, 2020

Other Decks in Programming

Transcript

  1. struct CounterView_State: View { @State private var counter = 0

    var body: some View { VStack { Button("Increment") { counter += 1 } Text("\(counter)") Button("Decrement") { counter -= 1 } } .navigationTitle("@State") } } State
  2. StateObject struct EvenOddView: View { @StateObject var state: CounterStateObject var

    body: some View { Text(state.counter % 2 "== 0 ? "even" : "odd") } } struct CounterView_StateObject: View { @StateObject var state = CounterStateObject() var body: some View { VStack { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } EvenOddView(state: state) } .navigationTitle("@StateObject") } }
  3. StateObject struct EvenOddView: View { @StateObject var state: CounterStateObject var

    body: some View { Text(state.counter % 2 "== 0 ? "even" : "odd") } } struct CounterView_StateObject: View { @StateObject var state = CounterStateObject() var body: some View { VStack { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } EvenOddView(state: state) } .navigationTitle("@StateObject") } }
  4. StateObject struct EvenOddView: View { @StateObject var state: CounterStateObject var

    body: some View { Text(state.counter % 2 "== 0 ? "even" : "odd") } } struct CounterView_StateObject: View { @StateObject var state = CounterStateObject() var body: some View { VStack { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } EvenOddView(state: state) } .navigationTitle("@StateObject") } }
  5. ObservedObject let counterState = CounterStateObject() "// ""... NavigationLink( "Counter with

    @ObservedObject", destination: CounterView_ObservedObject(state: counterState) ) "// ""... struct CounterView_ObservedObject: View { @ObservedObject var state: CounterStateObject var body: some View { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } .navigationTitle("@ObservedObject") } }
  6. ObservedObject let counterState = CounterStateObject() "// ""... NavigationLink( "Counter with

    @ObservedObject", destination: CounterView_ObservedObject(state: counterState) ) "// ""... struct CounterView_ObservedObject: View { @ObservedObject var state: CounterStateObject var body: some View { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } .navigationTitle("@ObservedObject") } }
  7. EnvironmentObject struct EvenOddView_EnvironmentObject: View { @EnvironmentObject var state: CounterStateObject var

    body: some View { Text(state.counter % 2 "== 0 ? "even" : "odd") } } struct CounterView_EnvironmentObject: View { @EnvironmentObject var state: CounterStateObject var body: some View { VStack { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } EvenOddView_EnvironmentObject() } .navigationTitle("@StateObject") } }
  8. EnvironmentObject struct EvenOddView_EnvironmentObject: View { @EnvironmentObject var state: CounterStateObject var

    body: some View { Text(state.counter % 2 "== 0 ? "even" : "odd") } } struct CounterView_EnvironmentObject: View { @EnvironmentObject var state: CounterStateObject var body: some View { VStack { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { state.counter -= 1 } } EvenOddView_EnvironmentObject() } .navigationTitle("@StateObject") } }
  9. EnvironmentObject let counterState = CounterStateObject() "// ""... NavigationLink( "Counter with

    @EnvironmentObject", destination: CounterView_EnvironmentObject() .environmentObject(counterState) )
  10. EnvironmentObject let counterState = CounterStateObject() "// ""... NavigationLink( "Counter with

    @EnvironmentObject", destination: CounterView_EnvironmentObject() "//.environmentObject(counterState) ) ""--- Fatal error: No ObservableObject of type CounterStateObject found. A View.environmentObject(_:) for CounterStateObject may be missing as an ancestor of this view.: file SwiftUI, line 0
  11. What is missing? struct CounterView_EnvironmentObject: View { @EnvironmentObject var state:

    CounterStateObject var body: some View { VStack { VStack { Button("Increment") { state.counter += 1 } Text("\(state.counter)") Button("Decrement") { if state.counter > 0 { state.counter -= 1 } } } EvenOddView_EnvironmentObject() } .navigationTitle("@EnvironmentObject") } } Business logic in view?
  12. What is missing? struct SomeView: View { private var apiCancellable:

    AnyCancellable? var body: some View { Button("Make network call") { self.apiCancellable = URLSession .shared .dataTaskPublisher(for: URL(string: "https:"//my.api/")!) .sink( receiveCompletion: { _ in }, receiveValue: { (data, response) in } ) } } } Business logic in view? Self is immutable
  13. ViewModel class CounterViewModel: ObservableObject { @Published var counter = 0

    func increment() { counter += 1 } func decrement() { guard counter > 0 else { return } counter -= 1 } }
  14. ViewModel struct CounterView_ViewModel: View { @ObservedObject var viewModel: CounterViewModel var

    body: some View { VStack { Button("Increment") { viewModel.increment() } Text("\(viewModel.counter)") Button("Decrement") { viewModel.decrement() } } .navigationTitle("ViewModel") } }
  15. Brandon Williams & Stephen Celis Implementation of an unidirectional data

    flow Focus on: State management, Composition, Side effects, Testing, Ergonomics https://www.pointfree.co/ https://github.com/pointfreeco/swift-composable-architecture Composable Architecture
  16. State: type that describes your domain model Action: type that

    represents the events in your app Environment: type that holds any dependencies Reducer: function that describes how to evolve state Store: runtime Building blocks
  17. Domain struct CounterState: Equatable { var counter = 0 }

    enum CounterAction { case increment case decrement } typealias CounterEnvironment = Void
  18. Behavior & runtime let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> {

    state, action, _ in switch action { case .increment: state.counter += 1 case .decrement: state.counter -= 1 } return .none } let counterStore = Store( initialState: CounterState(), reducer: counterReducer, environment: CounterEnvironment() )
  19. Testing func test_SimpleCounter() throws { let testStore = TestStore( initialState:

    CounterState(), reducer: counterReducer, environment: CounterEnvironment() ) testStore.assert( .send(.increment) { $0.counter = 1 }, .send(.increment) { $0.counter = 2 }, .send(.decrement) { $0.counter = 1 } "// .receive(action) { } "// .do { } "// .environment { environment in } ) }
  20. Testing func test_SimpleCounter() throws { let testStore = TestStore( initialState:

    CounterState(), reducer: counterReducer, environment: CounterEnvironment() ) testStore.assert( .send(.increment) { $0.counter = 1 }, .send(.increment) { $0.counter = 3 }, .send(.decrement) { $0.counter = 1 } ) }
  21. Simple Counter struct SimpleCounter: View { let store: Store<CounterState, CounterAction>

    var body: some View { WithViewStore(store) { viewStore in VStack { Button("Increment") { viewStore.send(.increment) } Text("\(viewStore.counter)") Button("Decrement") { viewStore.send(.decrement) } } .navigationTitle("Simple Counter") } } }
  22. Simple Counter struct SimpleCounter: View { let store: Store<CounterState, CounterAction>

    var body: some View { WithViewStore(store) { viewStore in VStack { Button("Increment") { viewStore.send(.increment) } Text("\(viewStore.counter)") Button("Decrement") { viewStore.send(.decrement) } } .navigationTitle("Simple Counter") } } } class Store<State, Action> class ViewStore<State, Action">: ObservableObject
  23. Advanced Counter struct CountersState: Equatable { var counters: IdentifiedArrayOf<CounterState> =

    [] var sum = 0 var isResetInFlight = false } enum CountersAction: Equatable { case counter(id: UUID, action: CounterAction) case addCounter case reset case scheduleReset case cancelScheduledReset case alertDismissed } struct CountersEnvironment { var uuid: () "-> UUID = UUID.init var mainQueue = DispatchQueue.main.eraseToAnyScheduler() }
  24. Advanced Counter let countersReducer = Reducer<CountersState, CountersAction, CountersEnvironment> { state,

    action, environment in struct ResetCancellationId: Hashable {} switch action { case .addCounter: state.counters.insert(CounterState(id: environment.uuid()), at: 0) case .scheduleReset: state.isResetInFlight = true return Effect(value: .reset) .delay(for: .milliseconds(3000), scheduler: environment.mainQueue) .eraseToEffect() .cancellable(id: ResetCancellationId(), cancelInFlight: true) case .reset: state.sum = 0 state.counters = [] state.isResetInFlight = false
  25. Advanced Counter let countersReducer = Reducer<CountersState, CountersAction, CountersEnvironment> { state,

    action, environment in struct ResetCancellationId: Hashable {} case .cancelScheduledReset: state.isResetInFlight = false return .cancel(id: ResetCancellationId()) case let .counter(_, action): switch action { case .increment: state.sum += 1 case .decrement: state.sum -= 1 } case .alertDismissed: break } return .none }
  26. Advanced Counter let countersAppReducer = Reducer.combine( countersReducer, counterReducer.forEach( state: \.counters,

    action: /CountersAction.counter, environment: { _ in } ) ) let countersStore = Store( initialState: CountersState(), reducer: countersAppReducer, environment: CountersEnvironment() )
  27. Advanced Counter struct CounterItemView: View { let store: Store<CounterState, CounterAction>

    var body: some View { WithViewStore(store) { viewStore in HStack { Button("Decrement") { viewStore.send(.decrement) } Text("\(viewStore.counter)") Button("Increment") { viewStore.send(.increment) } } } } }
  28. Advanced Counter struct AdvancedCounter: View { let store: Store<CountersState, CountersAction>

    var body: some View { WithViewStore(store) { viewStore in VStack { "// header List { ForEachStore( self.store.scope( state: \.counters, action: CountersAction.counter(id:action:) ), content: CounterItemView.init(store:) ) } "// action sheet }}}}
  29. Advanced Counter struct AdvancedCounter: View { let store: Store<CountersState, CountersAction>

    var body: some View { WithViewStore(store) { viewStore in VStack { "// body } .navigationBarItems( trailing: HStack(spacing: 20) { Button("Reset") { viewStore.send(.scheduleReset) } .disabled(viewStore.isResetInFlight) Button("Add Counter") { viewStore.send(.addCounter) } } )
  30. Advanced tests let testScheduler = DispatchQueue.testScheduler func test_AdvancedCounter() throws {

    let testStore = TestStore( initialState: CountersState(), reducer: countersAppReducer, environment: CountersEnvironment( uuid: UUID.incrementing, mainQueue: testScheduler.eraseToAnyScheduler() ) ) }
  31. Advanced tests testStore.assert( .send(.addCounter) { $0.counters.insert( CounterState(id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!), at:

    0 ) }, .send(.addCounter) { $0.counters.insert( CounterState(id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!), at: 0 ) } )
  32. Advanced tests testStore.assert( "// ""... .send(.counter( id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, action:

    .increment )) { $0.counters = [ CounterState(counter: 1, id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!), CounterState(counter: 0, id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!) ] $0.sum = 1 } "// ""... )
  33. Advanced tests testStore.assert( "// ""... .send(.scheduleReset) { $0.isResetInFlight = true

    }, .do { self.testScheduler.advance(by: 3) }, .receive(.reset) { $0.counters = [] $0.sum = 0 $0.isResetInFlight = false } )
  34. Advanced tests testStore.assert( "// ""... .send(.scheduleReset) { $0.isResetInFlight = true

    }, .do { self.testScheduler.advance(by: 2.5) }, .receive(.reset) { $0.counters = [] $0.sum = 0 $0.isResetInFlight = false } )
  35. Advanced tests testStore.assert( "// ""... .send(.scheduleReset) { $0.isResetInFlight = true

    }, .do { self.testScheduler.advance(by: 3) } "// .receive(.reset) { "// $0.counters = [] "// $0.sum = 0 "// $0.isResetInFlight = false "// } )
  36. Sum up Compelling story for state management ✅ Composition ✅

    Controlled side effects ✅ Testing ✅ Ergonomics ✅
  37. Search example class ComposeSearchActivity : AppCompatActivity() { override fun onCreate(savedInstanceState:

    Bundle?) { super.onCreate(savedInstanceState) setContent { SearchView(store = SearchApp.store) } } }
  38. Search example @Composable fun SearchView(store: Store<SearchState, SearchAction>) { var state

    by state { store.currentState } launchInComposition { store.states.collect { state = it } } Column { val queryState = state { TextFieldValue("") } TextField( value = queryState.value, onValueChange = { queryState.value = it store.send(SearchAction.SearchQueryChanged(it.text)) } ) AdapterList(data = state.locations) { location "-> Text(text = location.title) } } }