본문 바로가기
이모저모/SwiftUI

TCA - 뽀모도로 앱, Timer View Interaction

by ARpple 2024. 5. 7.

기존 코드

Timer View의 하위 View Components를 Enum으로 구분해 Interaction이 발생하면 해당 Enum 값을 방출함

타이머 뷰 컴포넌트 Interaction Enum 값으로 분리
extension TimerFeature{
    enum ActionType:Equatable{
        case timerFieldTapped
        case catTapped
        case resetTapped
        case triggerTapped
        case triggerWillTap
    }
    enum Action:Equatable{
        case viewAction(ActionType)
          ...
    }
}
View 컴포넌트에서 사용
enum TimerViewComponents{
    ...
    struct ResetButton: View{
        let store: StoreOf<TimerFeature>
        var body: some View{
            Button("Reset"){
                **store.send(.viewAction(.resetTapped))**
            }.resetStyle()
        }
   }
}

TimerFeature에서 유저 Interaction이 일어난 뷰 Action 타입에 맞는 하위 메서드 제작 및 호출

extension TimerFeature{
        func viewAction(_ state:inout State,_ act: ViewAction) -> Effect<Action>{
            switch act{
                    case .timerFieldTapped:
                            return self.timerFieldTapped(state: &state)
                    case .catTapped:
                            return self.catTapped(state: &state)
                    case .resetTapped:
                            return self.resetTapped(state: &state)
                    case .triggerTapped:
                            return self.triggerTapped(state: &state)
                    case .triggerWillTap: return self.triggerWillTap(state: &state)
            }
        }
    //MARK: -- TimerFieldTapped Reducer 실제 구현 부
    func timerFieldTapped(state:inout TimerFeature.State) ->  Effect<TimerFeature.Action>{ ... }
    //MARK: -- Cat Tapped Reducer
    func catTapped(state: inout TimerFeature.State) -> Effect<TimerFeature.Action>{ ... }
    
    func resetTapped(state: inout TimerFeature.State) -> Effect<TimerFeature.Action>{ ... }
    func triggerTapped(state: inout TimerFeature.State) -> Effect<TimerFeature.Action>{ ... }
    func triggerWillTap(state: inout TimerFeature.State) -> Effect<TimerFeature.Action>{ ... }
}

기존 코드의 문제점

1. Dependency 변화 대처의 어려움

탭 액션에 따른 처리해야하는 Dependency가 현재 3가지, 추후에 더 추가될 예정…

  1. 유저 가이드 처리
  2. 타이머 상태 변경
  3. 햅택 처리

➕ 사운드 처리

⇒ 하나의 View Component Interaction에 혼재하는 로직 처리…

Trigger 버튼 Tap 시 처리해야하는 로직 기존 코드

fileprivate extension TimerFeature{
    ...
    func triggerTapped(state: inout TimerFeature.State) -> Effect<TimerFeature.Action>{
        let hapticEffect:Effect<Action> = .run { _ in await haptic.impact(style: .light) }
        switch state.timerStatus{
        case .standBy:
            guard state.count != 0 else {return .none }
            state.startDate = Date()
            state.guideInformation.standByGuide = true
            var effects:[Effect<Action>] = [hapticEffect,.run { send in
                await send(.setStatus(.focus))
            },.run {[guide = state.guideInformation] send in
                await send(.setGuideState(guide))
            }]
            if !state.guideInformation.startGuide{
                var guide = state.guideInformation
                guide.startGuide = true
                effects.append(.run {[guide] send in
                    try await Task.sleep(for: .seconds(3))
                    await send(.setGuideState(guide),animation: .easeInOut)
                })
            }
            return Effect.concatenate(effects)
        case .focus: return .run { send in
            await send(.setStatus(.pause))
        }.merge(with: hapticEffect)
        case .pause:
            return .run {[count = state.count] send in
                await send(.setStatus(.focus,count: count))
            }.merge(with: hapticEffect)
        case .completed: return .run{ send in
            await send(.setStatus(.standBy))
        }.merge(with: hapticEffect)
        case .breakStandBy:
            state.startDate = Date()
            return .run { send in
                await send(.setStatus(.breakTime))
            }.merge(with: hapticEffect)
        case .breakTime: return .concatenate([.cancel(id: CancelID.timer),
                                              .run { await $0(.setStatus(.standBy)) }]).merge(with: hapticEffect)
        case .sleep: return .none
        }
    }

2. 기획 변경에 따른 대처

  1. 개발 도중 기획에 변경으로 사라진 Circle Timer Interaction
  2. 기획에서 Haptic 세기 정도의 변경

Controller 객체로 분화 및 처리

⚠️ Presenter대신, Controller라는 의미를 부여한 이유
- Controller의 정확한 의미를 발견하지 못 했음... 그러나...
1. View Action에 대한 처리
2. TimerFeature State에 참조로 접근하는 경우가 존재함 → State 값에 직접 접근한다.는 점에서 Controller라 이름 붙임

1. Protocol 정의

protocol TimerControllerProtocol{
    func makeReducer(state: inout TimerFeature.State,
                     act:TimerFeature.ControllType) -> Effect<TimerFeature.Action>
    func timerFieldTapped(state:inout TimerFeature.State) -> Effect<TimerFeature.Action>
    func catTapped(state: inout TimerFeature.State) -> Effect<TimerFeature.Action>
    func resetTapped(state: inout TimerFeature.State) -> Effect<TimerFeature.Action>
    func triggerTapped(state: inout TimerFeature.State) -> Effect<TimerFeature.Action>
    func triggerWillTap(state: inout TimerFeature.State) -> Effect<TimerFeature.Action>
}

Protocol Extension을 통해 기본 채택 메서드를 정의

extension TimerControllerProtocol{
    func makeReducer(state: inout TimerFeature.State,act: TimerFeature.ControllType)->Effect<TimerFeature.Action>{
        switch act{
        case .catTapped: catTapped(state: &state)
        case .resetTapped: resetTapped(state: &state)
        case .timerFieldTapped: timerFieldTapped(state: &state)
        case .triggerTapped: triggerTapped(state: &state)
        case .triggerWillTap: triggerWillTap(state: &state)
        }
    }
}

2. 구현부 Controller를 모두 포함하고 TimerFeature에서 접근 가능한 Controller 인스턴스

enum 타입으로 구현부 Controller를 구분함

extension TimerFeature{
    enum ControllerReducers:CaseIterable{
        case haptic,guide,action
        private var myReducer: TimerControllerProtocol{
            switch self{
            case .haptic: HapticReducer()
            case .guide: GuideReducer()
            case .action: ActionReducer()
            }
        }
        static func makeAllReducers(state:inout TimerFeature.State,act:ControllType) -> [Effect<Action>]{
            Self.allCases.map { reducer in
                reducer.myReducer.makeReducer(state: &state, act: act)
            }
        }
    }
}

구현부 Controller

Trigger Tap시 처리하는 메서드만 표시함

프로토콜 Extension 기본 메서드를 통해 실제 구현부는 상위 통합 Controller에서 호출하는 makeReducer을 구현 사항을 알 필요가 없음

1. Haptic Controller

extension TimerFeature.ControllerReducers{
    struct HapticReducer: TimerControllerProtocol{
        @Dependency(\.haptic) var haptic
        ...
        func triggerTapped(state: inout TimerFeature.State) -> Effect<TimerFeature.Action> {
            let hapticEffect: Effect<TimerFeature.Action> = .run { send in
                await haptic.impact(style: .light)
            }
            return hapticEffect
        }
        ...
    }
}

2. Guide Controller

extension TimerFeature.ControllerReducers{
    struct GuideReducer: TimerControllerProtocol{
        @Dependency(\.guideDefaults) var guideDefaults      
        ...  
        func triggerTapped(state: inout TimerFeature.State) -> Effect<TimerFeature.Action> {
            if !state.guideInformation.onBoarding{
                state.guideInformation.onBoarding = true
                var guide = state.guideInformation
                guide.startGuide = true
                return .run {[guide] send in
                    try await Task.sleep(for: .seconds(3))
                    await send(.setGuideState(guide),animation: .easeInOut)
                }
            }
            return .none
        }
        ...
    }
}

3. ActionController

💡 Interaction에 따른 타이머 상태를 바꾸는 요청
extension TimerFeature.ControllerReducers{
    struct ActionReducer: TimerControllerProtocol{
        typealias Action = TimerFeature.Action
        ...
        func triggerTapped(state: inout TimerFeature.State) -> Effect<TimerFeature.Action> {
            switch state.timerStatus{
            case .standBy:
                guard state.count != 0 else {return .none}
                return .run { send in
                    await send(.setStatus(.focus,startDate: Date()))
                }
            case .focus: return .run { send in
                await send(.setStatus(.pause))
            }
            case .pause:
                return .run {[count = state.count] send in
                    await send(.setStatus(.focus,count: count))
                }
            case .completed: return .run{ send in
                await send(.setStatus(.standBy))
            }
            case .breakStandBy:
                return .run { send in
                    await send(.setStatus(.breakTime,startDate: Date()))
                }
            case .breakTime: return .run { await $0(.setStatus(.standBy)) }
            case .sleep: return .none
            }
        }
        ...
    }
}

 

댓글