범위

이번 정리에서 볼 흐름

Action
-> Reducer
-> AlertState 설정
-> View
-> Alert 표시
-> dismiss
-> AlertState nil

SwiftUI에서는 보통 showAlert 같은 Bool 값을 둔다

그리고 title, message, alert type을 따로 관리한다

TCA에서는 Alert도 State로 둔다

이번 세션에서는 Login 화면의 실패 Alert를 AlertState로 구성했다

이번 글에서 볼 것은 세 가지

@Presents var alert
PresentationAction
.alert($store.scope(...))

기존 방식

기존 방식은 Alert 표시 여부와 Alert 내용을 분리해서 가진다

struct State: Equatable {
    var showAlert = false
    var alertTitle = ""
    var alertMessage = ""
}

이 구조에서는 Alert를 띄울 때 여러 값을 같이 맞춰야 한다

showAlert = true
alertTitle = ...
alertMessage = ...

닫을 때도 같이 정리해야 한다

showAlert = false
alertTitle = ""
alertMessage = ""

즉 Alert 하나를 표현하는 값이 여러 State로 흩어진다

그래서 dismiss 흐름이 생기면 정리 로직도 따로 필요하다

AlertState 방식

AlertState를 쓰면 Alert 표시 여부와 내용을 하나의 State로 둔다

@ObservableState
struct State: Equatable {
    @Presents var alert: AlertState<Never>?
    var isLoading = false
}

alert == nil이면 Alert가 닫힌 상태다

alert != nil이면 Alert가 열린 상태다

정리하면 이렇게 볼 수 있다

alert == nil
-> Alert 닫힘

alert != nil
-> Alert 열림

Alert 버튼에서 별도 Action을 보내지 않는다면 AlertState<Never>로 둘 수 있다

버튼에서 Action을 보내야 한다면 Never 대신 Action 타입을 둔다

Alert도 Navigation인가

@Presents는 Navigation 글에서 먼저 봤다

그래서 NavigationStack으로 화면을 push할 때만 쓰는 값처럼 보일 수 있다

하지만 TCA 문서에서는 내비게이션을 더 넓게 본다

drill-down뿐만 아니라 sheet, popover, cover, alert, dialog도 앱의 mode change라서 모두 내비게이션에 포함된다

그리고 optional State로 이 mode의 존재 여부를 표현하는 방식을 tree-based navigation이라고 부른다

Navigation destination
-> drill-down navigation

AlertState
-> alert navigation

ConfirmationDialogState
-> dialog navigation

그래서 @Presents는 Alert에도 사용할 수 있다

TCA 예제에서도 Alert와 ConfirmationDialog를 이렇게 둔다

@ObservableState
struct State: Equatable {
    @Presents var alert: AlertState<Action.Alert>?
    @Presents var confirmationDialog: ConfirmationDialogState<Action.ConfirmationDialog>?
}

정리하면 @Presents는 NavigationStack push 전용 문법이 아니다

optional navigation state를 TCA의 presentation 흐름으로 연결하기 위한 문법이다

AlertState도 optional State로 mode 존재 여부를 표현하므로 tree-based navigation 흐름에 들어간다

Action

부모 Action에는 Alert 표시 흐름도 들어간다

enum Action {
    case alert(PresentationAction<Never>)
    case signInFailed(AlertType)
}

signInFailed는 로그인 실패 결과다

Reducer는 이 Action을 받아 Alert State를 만든다

case .signInFailed(let alertType):
    state.isLoading = false
    state.alert = alertState(for: alertType)
    return .none

View가 직접 Alert 값을 조립하지 않는다

실패라는 결과를 Reducer가 받고, Reducer가 Alert 표시 여부와 내용을 결정한다

AlertState 만들기

AlertState는 title, message, button을 가진다

private func alertState(for alertType: AlertType) -> AlertState<Never> {
    AlertState {
        TextState(alertType.title)
    } actions: {
        ButtonState(role: .cancel) {
            TextState("확인")
        }
    } message: {
        TextState(alertType.message)
    }
}

이제 Alert에 필요한 값은 alert 하나에 모인다

title
message
button
표시 여부

Alert 내용이 바뀌어도 View의 Alert 코드는 바뀌지 않는다

Reducer가 어떤 AlertState를 넣는지만 바뀐다

View에서 Alert 연결하기

View는 Store를 bindable하게 가진다

struct LoginView: View {
    @Bindable var store: StoreOf<LoginFeature>
}

그리고 .alert modifier에서 Alert State를 연결한다

.alert($store.scope(state: \.alert, action: \.alert))

여기서 scope는 Alert에 필요한 State와 Action만 꺼낸다

LoginFeature.State.alert
LoginFeature.Action.alert

Alert가 닫히면 TCA가 dismiss Action을 보낸다

Alert dismiss
-> .alert(.dismiss)
-> alert State 정리

그래서 별도의 showAlert = false 처리를 View에 둘 필요가 줄어든다

반영 여부에 따른 장단점

구분 장점 단점 맞는 상황
AlertState 반영 Alert 표시 여부와 내용을 하나의 State로 관리할 수 있음 @Presents, PresentationAction, scope 흐름을 알아야 함 Alert가 Feature Action의 결과일 때
AlertState 반영 Navigation, Alert, Dialog를 같은 tree-based navigation 흐름으로 이해할 수 있음 처음에는 @Presents가 push 전용처럼 보여 헷갈릴 수 있음 TCA presentation 흐름을 일관되게 가져가고 싶을 때
AlertState 반영 dismiss 흐름이 TCA presentation 흐름에 들어옴 단순 Alert 하나에는 코드가 더 커 보일 수 있음 Alert 닫힘까지 테스트에서 검증하고 싶을 때
AlertState 반영 showAlert, alertType, title, message 동기화 문제가 줄어듦 Alert 버튼에서 Action이 필요하면 Action 타입 설계가 필요함 Alert 종류가 늘어나거나 실패 케이스가 여러 개일 때
AlertState 미반영 SwiftUI의 alert(isPresented:) 흐름이 익숙함 Alert 관련 State가 여러 값으로 흩어질 수 있음 정말 View 안에서만 끝나는 단순 Alert일 때
AlertState 미반영 TCA presentation 문법을 몰라도 구현 가능함 dismiss 시점의 cleanup 로직을 직접 맞춰야 함 Feature로 올릴 필요가 없는 임시 UI일 때
AlertState 미반영 작은 예제에서는 빠르게 작성할 수 있음 테스트에서 표시 여부와 내용을 따로 검증해야 함 Alert 내용이 고정이고 상태 동기화 위험이 낮을 때

테스트 관점

AlertState를 쓰면 테스트도 Alert State를 직접 비교한다

await store.send(.signInFailed(.apple)) {
    $0.isLoading = false
    $0.alert = AlertState {
        TextState("로그인 실패")
    } actions: {
        ButtonState(role: .cancel) {
            TextState("확인")
        }
    } message: {
        TextState("다시 시도해주세요")
    }
}

닫힘도 Action으로 볼 수 있다

await store.send(.alert(.dismiss)) {
    $0.alert = nil
}

기존 방식에서는 여러 값을 같이 확인해야 한다

showAlert == false
alertTitle == ""
alertMessage == ""

AlertState 방식에서는 핵심이 하나로 줄어든다

alert == nil

현재 기준

AlertState를 반영하는 쪽이 맞는 경우

Alert가 Feature Action의 결과다
Alert 내용이 State에 따라 바뀐다
dismiss도 테스트하고 싶다
showAlert와 alertType 동기화 코드가 생긴다

반대로 반영하지 않아도 되는 경우

Alert가 View 내부에서만 끝난다
Alert 내용이 고정이다
테스트 대상이 아니다
TCA Feature로 올릴 필요가 없다

정리

현재 이해한 AlertState

AlertState = Alert 표시 여부와 내용을 함께 가진 presentation State
@Presents var alert = optional Alert State
alert == nil = 닫힘
alert != nil = 열림

View는 Alert를 직접 조립하지 않는다

View는 Store의 Alert State를 연결한다

Reducer는 Action 결과에 따라 Alert State를 넣거나 비운다

View
-> Action
-> Reducer
-> AlertState
-> View Alert

참고