lgvv98

[TCA] HigherOrderReducers #2 (ReusableFavoriting) 본문

apple/TCA

[TCA] HigherOrderReducers #2 (ReusableFavoriting)

lgvv 2023. 10. 29. 13:59

[TCA] HigherOrderReducers (ReusableFavoriting)

 

 

목차

 - 개념설명

 - 예제코드



 

# 개념설명

 

이번에는 재사용 가능한 하나의 컴포넌트를 도메인, 로직, 뷰에 이르기까지 어떻게 처리하는지를 소개.  

이번 기능은 "좋아요" 기능을 가지고 있으며, 버튼 액션을 통해 자신의 상태도 변경 가능하다.

해당 버튼을 누르면 UI에 즉시 반영되고 DB를 변경하거나, API 요청을 하는 등 필요한 작업을 수행하기 위한 효과가 발생.

실행에 1초가 걸리고 25% 확률로 실패하도록 예제를 만들었으며, 실패할 경우 상태가 롤백되고 경고 알럿이 나타남.

 

# 예제코드

// MARK: - Reusable favorite component

// 🟠 1. 재사용 가능한 FavoriteState를 선언
struct FavoritingState<ID: Hashable & Sendable>: Equatable {
    @PresentationState var alert: AlertState<FavoritingAction.Alert>?
    let id: ID // 🟠 2. 아이디 값을 가지고 처리할 것이므로 선언
    var isFavorite: Bool
}

// 🟠 3. 재사용 가능한 FavoritingAction을 선언
enum FavoritingAction: Equatable {
    case alert(PresentationAction<Alert>)
    case buttonTapped
    case response(TaskResult<Bool>)
    
    enum Alert: Equatable {}
}

// 🟠 4. 재사용 가능한 Reducer선언
struct Favoriting<ID: Hashable & Sendable>: Reducer {
    let favorite: @Sendable (ID, Bool) async throws -> Bool
    
    private struct CancelID: Hashable {
        let id: AnyHashable
    }
    
    func reduce(
        into state: inout FavoritingState<ID>, action: FavoritingAction
    ) -> Effect<FavoritingAction> {
        switch action {
        case .alert(.dismiss):
            state.alert = nil
            state.isFavorite.toggle()
            return .none
            
        case .buttonTapped:
            state.isFavorite.toggle()
            
            return .run { [id = state.id, isFavorite = state.isFavorite, favorite] send in
                await send(.response(TaskResult { try await favorite(id, isFavorite) }))
            }
            .cancellable(id: CancelID(id: state.id), cancelInFlight: true) // 🟠 5. cancelInFlight는 새로운 거 시작하기 전에 해당 ID를 취소할지 말지를 결정
            
        case let .response(.failure(error)):
            state.alert = AlertState { TextState(error.localizedDescription) }
            return .none
            
        case let .response(.success(isFavorite)):
            state.isFavorite = isFavorite
            return .none
        }
    }
}

// 🟠 6. 재사용 가능한 버튼
struct FavoriteButton<ID: Hashable & Sendable>: View {
    let store: Store<FavoritingState<ID>, FavoritingAction>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            Button {
                viewStore.send(.buttonTapped)
            } label: {
                Image(systemName: "heart")
                    .symbolVariant(viewStore.isFavorite ? .fill : .none)
            }
            .alert(store: self.store.scope(state: \.$alert, action: { .alert($0) })) // 🟠 7. 알럿 액션 연결
        }
    }
}

// MARK: - Feature domain

// 🟠 8. 메인 도메인 피쳐의 리듀서 연결
struct Episode: Reducer {
    struct State: Equatable, Identifiable {
        var alert: AlertState<FavoritingAction.Alert>?
        let id: UUID
        var isFavorite: Bool
        let title: String
        
        // 🟠 9. 요 부분 주목!! 액션과 스테이트를 이렇게 연결
        var favorite: FavoritingState<ID> {
            get { .init(alert: self.alert, id: self.id, isFavorite: self.isFavorite) }
            set { (self.alert, self.isFavorite) = (newValue.alert, newValue.isFavorite) }
        }
    }
    enum Action: Equatable {
        case favorite(FavoritingAction)
    }
    let favorite: @Sendable (UUID, Bool) async throws -> Bool
    
    var body: some Reducer<State, Action> {
        // 🟠 10. 단일 부분을 이렇게 연결
        Scope(state: \.favorite, action: /Action.favorite) {
            Favoriting(favorite: self.favorite)
        }
    }
}

// MARK: - Feature view

struct EpisodeView: View {
    let store: StoreOf<Episode>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            HStack(alignment: .firstTextBaseline) {
                Text(viewStore.title)
                
                Spacer()
                
                // ✅ 6. FavoriteButton은 배열이어서 ForEach 형태로 View에서 Reduce에서 담당하여 이렇게 처리도 가능.
                
                FavoriteButton(
                    store: self.store.scope(
                        state: \.favorite,
                        action: Episode.Action.favorite
                    )
                )
            }
        }
    }
}

struct Episodes: Reducer {
    struct State: Equatable {
        var episodes: IdentifiedArrayOf<Episode.State> = [] // ✅ 1. 에피소드의 State를 넣어준다.
    }
    enum Action: Equatable {
        case episode(id: Episode.State.ID, action: Episode.Action) // ✅ 2. 액션과 id를 넣어준다.
    }
    
    let favorite: @Sendable (UUID, Bool) async throws -> Bool // ✅ 3. Denpendency 형태로 값을 클로저로 받는 형태
    
    var body: some Reducer<State, Action> {
        Reduce { state, action in
                .none // ✅ 4. 따로 할건 없으므로 none
        }
        .forEach(\.episodes, action: /Action.episode) {
            Episode(favorite: self.favorite) // ✅ 5. 배열을 forEach를 통해 각각 결합. -> 외부 디펜던시 받아서 처리. 클로저 형태.
        }
    }
}

struct EpisodesView: View {
    let store: StoreOf<Episodes>
    
    var body: some View {
        Form {
            Section {
                AboutView(readMe: readMe)
            }
            ForEachStore(
                self.store.scope(
                    state: \.episodes,
                    action: Episodes.Action.episode(id:action:)
                )
            ) { rowStore in
                EpisodeView(store: rowStore)
            }
            .buttonStyle(.borderless)
        }
        .navigationTitle("Favoriting")
    }
}

// MARK: - SwiftUI previews

struct EpisodesView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            EpisodesView(
                store: Store(
                    initialState: Episodes.State(
                        episodes: .mocks
                    )
                ) {
                    Episodes(favorite: favorite(id:isFavorite:))
                }
            )
        }
    }
}

struct FavoriteError: LocalizedError, Equatable {
    var errorDescription: String? {
        "Favoriting failed."
    }
}

@Sendable func favorite<ID>(id: ID, isFavorite: Bool) async throws -> Bool {
    try await Task.sleep(nanoseconds: NSEC_PER_SEC)
    if .random(in: 0...1) > 0.25 {
        return isFavorite
    } else {
        throw FavoriteError()
    }
}

extension IdentifiedArray where ID == Episode.State.ID, Element == Episode.State {
    static let mocks: Self = [
        Episode.State(id: UUID(), isFavorite: false, title: "Functions"),
        Episode.State(id: UUID(), isFavorite: false, title: "Side Effects"),
        Episode.State(id: UUID(), isFavorite: false, title: "Algebraic Data Types"),
        Episode.State(id: UUID(), isFavorite: false, title: "DSLs"),
        Episode.State(id: UUID(), isFavorite: false, title: "Parsers"),
        Episode.State(id: UUID(), isFavorite: false, title: "Composable Architecture"),
    ]
}

 

 

이건 좀 어려우면서 다른 방법으로 더 나은 구조로 해결할 수 있다고 생각했다.

 

이 구조는 @Dependency나 concentrate 등을 통해 해결할 수 있을거 같다. 이런 방법을 알아두는 것도 문제를 해결하는데 하나의 방법이라고 생각한다.

'apple > TCA' 카테고리의 다른 글

[TCA] HigherOrderReducers #1 (Recursion)  (0) 2023.10.11
[TCA] Navigation (화면전환 총 정리)  (0) 2023.10.09
[TCA] Effect #6 (WebSocket)  (0) 2023.10.08
[TCA] Effect #5 (Timers)  (0) 2023.10.07
[TCA] Effect #4 (Refreshable)  (0) 2023.10.07
Comments