[SwiftUI][TCA] TCA Case Studies - 01. Animations

별똥별·2025년 1월 24일

TCA

목록 보기
15/24
post-thumbnail

안녕하세요, 별똥별🌠입니다!
이번 글에서는 Composable Architecture (TCA)를 사용해 애니메이션과 상태를 관리하는 방법을 소개합니다.
드래그 제스처, 색상 변경, 스케일 효과 등 다양한 UI 동작을 TCA로 어떻게 제어할 수 있는지 살펴보겠습니다. 🚀


1️⃣ Reducer 정의

Animations Reducer는 사용자의 제스처와 애니메이션 상태를 관리하며, 색상 변경, 스케일 토글, 초기화 등의 기능을 구현합니다.

@Reducer
struct Animations {
    @ObservableState
    struct State: Equatable {
        @Presents var alert: AlertState<Action.Alert>?
        var circleCenter: CGPoint?
        var circleColor = Color.black
        var isCircleScaled = false
    }
    
    enum Action: Sendable {
        case alert(PresentationAction<Alert>)
        case circleScaleToggleChanged(Bool)
        case rainbowButtonTapped
        case resetButtonTapped
        case setColor(Color)
        case tapped(CGPoint)
        
        @CasePathable
        enum Alert: Sendable {
            case resetConfirmationButtonTapped
        }
    }
    
    @Dependency(\.continuousClock) var clock
    
    private enum CancelID { case rainbow }
    
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .alert(.presented(.resetConfirmationButtonTapped)):
                state = State()
                return .cancel(id: CancelID.rainbow)
                
            case .alert:
                return .none
                
            case let .circleScaleToggleChanged(isScaled):
                state.isCircleScaled = isScaled
                return .none
                
            case .rainbowButtonTapped:
                return .run { send in
                    for color in [Color.red, .blue, .green, .orange, .pink, .purple, .yellow, .black] {
                        await send(.setColor(color), animation: .linear)
                        try await clock.sleep(for: .seconds(1))
                    }
                }
                .cancellable(id: CancelID.rainbow)
                
            case .resetButtonTapped:
                state.alert = AlertState {
                    TextState("Reset state?")
                } actions: {
                    ButtonState(
                        role: .destructive,
                        action: .send(.resetConfirmationButtonTapped, animation: .default)) {
                            TextState("Reset")
                        }
                    ButtonState(role: .cancel) {
                        TextState("Cancel")
                    }
                }
                return .none
            case let.setColor(color):
                state.circleColor = color
                return .none
            case let .tapped(point):
                state.circleCenter = point
                return .none
            }
        }
        .ifLet(\.$alert, action: \.alert)
    }
}

주요 포인트

  • State 정의
    • circleCenter: 사용자가 화면을 터치한 위치를 저장합니다.
    • circleColor: 원의 색상을 나타냅니다.
    • isCircleScaled: 원의 크기를 제어합니다.
    • alert: 경고 창 상태를 관리합니다.

  • Action 정의
    • rainbowButtonTapped: 색상을 무지개 순서로 변경합니다.
    • resetButtonTapped: 초기화 경고창을 표시합니다.
    • tapped: 화면 터치 위치를 기록합니다.

  • Reducer 로직
    • rainbowButtonTapped:
      * 1초 간격으로 색상을 변경하며, CancelID.rainbow를 사용해 취소 가능합니다.
    • resetButtonTapped:
      * 초기화 경고창을 생성하며, 초기화 버튼 클릭 시 상태를 재설정합니다.

2️⃣ View 구현

AnimationsView는 Reducer의 상태와 액션을 기반으로 사용자 인터페이스를 구성합니다.

struct AnimationsView: View {
    @Bindable var store: StoreOf<Animations>
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(template: readMe, .body)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .padding()
                .gesture(
                    DragGesture(minimumDistance: 0).onChanged { gesture in
                        store.send(
                            .tapped(gesture.location),
                            animation: .interactiveSpring(response: 0.25, dampingFraction: 0.1)
                        )
                    }
                )
                .overlay {
                    GeometryReader { proxy in
                        Circle()
                            .fill(store.circleColor)
                            .colorInvert()
                            .blendMode(.difference)
                            .frame(width: 50, height: 50)
                            .scaleEffect(store.isCircleScaled ? 2 : 1)
                            .position(
                                x: store.circleCenter?.x ?? proxy.size.width / 2,
                                y: store.circleCenter?.y ?? proxy.size.height / 2
                            )
                            .offset(y: store.circleCenter == nil ? 0 : -44)
                    }
                    .allowsHitTesting(false)
                }
            
            Toggle("Big mode", isOn: $store.isCircleScaled.sending(\.circleScaleToggleChanged)
                .animation(.interactiveSpring(response: 0.25, dampingFraction: 0.1))
            )
            .padding()
            
            Button("Rainbow") { store.send(.rainbowButtonTapped, animation: .linear) }
                .padding([.horizontal, .bottom])
            
            Button("Reset") { store.send(.resetButtonTapped) }
                .padding([.horizontal, .bottom])
        }
        .alert($store.scope(state: \.alert, action: \.alert))
        .navigationBarTitleDisplayMode(.inline)
    }
}

주요 포인트

  • 드래그 제스처
    • 사용자가 화면을 터치하면 해당 위치를 기록하고 원의 중심을 이동시킵니다.

  • 애니메이션 효과
    • scaleEffect를 활용해 원의 크기를 조정하며, 스프링 애니메이션으로 부드럽게 전환됩니다.

  • 버튼 동작
    • "Rainbow" 버튼: 색상이 순차적으로 변경됩니다.
    • "Reset" 버튼: 초기화 경고창을 표시합니다.

3️⃣ Preview 구현

Preview를 통해 UI 동작을 테스트합니다.

#Preview {
    NavigationStack {
        AnimationsView(
            store: Store(initialState: Animations.State()) {
                Animations()
            }
        )
    }
    .environment(\.colorScheme, .dark)
}


🌟 핵심 포인트 정리

  • TCA로 애니메이션 상태 관리
    • Reducer에서 애니메이션 상태와 동작을 관리해 뷰 로직과 상태를 분리할 수 있습니다.

  • DragGesture 활용
    • 제스처 이벤트와 애니메이션을 결합해 역동적인 UI를 구현할 수 있습니다.

  • cancellable로 작업 취소
    • 긴 작업은 CancelID를 통해 안전하게 취소할 수 있습니다.

마무리

이번 글에서는 TCA를 활용해 애니메이션과 제스처를 관리하는 방법을 살펴봤습니다.
TCA의 강력한 상태 관리 기능과 SwiftUI의 유연한 애니메이션 API를 결합하면 더욱 직관적이고 역동적인 사용자 경험을 제공할 수 있습니다. 😊

다음 글에서는 또 다른 TCA 활용 사례로 찾아뵙겠습니다! 🌠

profile
밍밍

0개의 댓글