SwiftUI์—์„œ TCA์™€ ํ•จ๊ป˜ ๋‹ค์ด์–ผ๋กœ๊ทธ ์ปดํฌ๋„ŒํŠธ ์žฌ๊ตฌ์„ฑํ•˜๊ธฐ

hayeon
  • #SwiftUI
  • #TCA
  • #Swift
  • #Dialog
  • #DialogComponent
  • #Component
  • #ios

๋“ค์–ด๊ฐ€๋ฉฐ

์•ˆ๋…•ํ•˜์„ธ์š”. ์ €๋Š” ํ˜„์žฌ โ€˜frommโ€™ ์•ฑ์—์„œ iOS ๊ฐœ๋ฐœ์„ ํ•˜๊ณ  ์žˆ๋Š” ์ดํ•˜์—ฐ์ž…๋‹ˆ๋‹ค.

์ตœ๊ทผ ํ”„๋กœ์ ํŠธ์—์„œ ๋ผ์ด๋ธŒ ์ŠคํŠธ๋ฆฌ๋ฐ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜๋ฉด์„œ SwiftUI์™€ TCA(Composable Architecture) ์กฐํ•ฉ์„ ์ฒ˜์Œ ์‚ฌ์šฉํ•ด๋ณด์•˜์Šต๋‹ˆ๋‹ค. ์ด๋ฒˆ ๊ธ€์—์„œ๋Š” SwiftUI๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ Dialog ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ œ์ž‘ํ•œ ๊ณผ์ •๊ณผ, ์ด๋ฅผ TCA์—์„œ ํ™œ์šฉํ•˜๊ธฐ ์œ„ํ•ด AlertState๋ฅผ ์ฐธ๊ณ ํ•˜์—ฌ DialogState๋ฅผ ๊ตฌํ˜„ํ•œ ๊ฒฝํ—˜์„ ๊ณต์œ ํ•˜๊ณ ์ž ํ•ฉ๋‹ˆ๋‹ค.


SwiftUI์—์„œ์˜ Dialog Component ๊ตฌํ˜„

๋จผ์ € SwiftUI์—์„œ Dialog Component๋ฅผ ๋งŒ๋“ค์—ˆ๋˜ ๊ณผ์ •์„ ๋ง์”€๋“œ๋ฆฌ๊ฒ ์Šต๋‹ˆ๋‹ค.

Dialog Component๋ฅผ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•ด ๊ฐ„๋‹จํ•œ SwiftUI View๋ฅผ ๋งŒ๋“ค์—ˆ๊ณ  ํ…์ŠคํŠธ, ๋ฒ„ํŠผ, ๊ทธ๋ฆฌ๊ณ  ๊ธฐํƒ€ ์‚ฌ์šฉ์ž ์ธํ„ฐํŽ˜์ด์Šค ์š”์†Œ๋“ค์„ ํฌํ•จํ•  ์ˆ˜ ์žˆ๋„๋ก ์„ค๊ณ„ํ•˜์˜€์Šต๋‹ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ๋‚ด๋ถ€์™€ ์™ธ๋ถ€์—์„œ ๋‹ค์ด์–ผ๋กœ๊ทธ์˜ ํ‘œ์‹œ ์—ฌ๋ถ€ ์ œ์–ดํ•˜๊ธฐ ์œ„ํ•ด @Binding์„ ํ™œ์šฉํ•œ isPresented ๋ณ€์ˆ˜๋ฅผ ๋„์ž…ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

import SwiftUI

struct CustomDialog: View {

    // MARK: - Properties
        
    @Binding private var isPresented: Bool
    private let title: String?
    private let message: String
    private let highlightMessage: [HighlightMessage]
    private let primaryButton: CustomDialog.ButtonConfig
    private let secondaryButton: CustomDialog.ButtonConfig?

    // MARK: - Initializer

    init(
        isPresented: Binding<Bool>,
        title: String? = nil,
        message: String,
        highlightMessage: [HighlightMessage] = [],
        primaryButton: CustomDialog.ButtonConfig,
        secondaryButton: CustomDialog.ButtonConfig? = nil
    ) { ... }

    // MARK: - Body

    var body: some View {
        let dimBackground: Color = .dim_70

        ZStack {
            dimBackground

            VStack(spacing: 24) {
                ContentView
                    .padding(.top, 32)
                    .padding([.horizontal], 16)

                HStack(spacing: 0) {
                    ButtonView(button: primaryButton)
                    if let secondaryButton {
                        ButtonView(button: secondaryButton)
                    }
                }
                .frame(height: 48)
                .padding([.horizontal, .bottom], 16)
            }
            .background(Color.surface_modal_01)
            .cornerRadius(12.0)
            .padding(.horizontal, 48)
        }
        .ignoresSafeArea()
    }

    private var ContentView: some View { ... }

    private func ButtonView(button: CustomDialog.ButtonConfig) -> some View { ... }

    public struct ButtonConfig { ... }
}

๊ทธ ๋‹ค์Œ์œผ๋กœ ์ด CustomDialog Component๋ฅผ ๊ฐ„ํŽธํ•˜๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก SwiftUI modifier๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ View์— ํ™•์žฅ ๋ฉ”์„œ๋“œ๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด fdsDialog ๋ฉ”์„œ๋“œ๋ฅผ ๋ทฐ์˜ ์ •์˜๋ถ€ ์•„๋ž˜์—์„œ ์†์‰ฝ๊ฒŒ ์ ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

extension View {
    func fdsDialog(
        isPresented: Binding<Bool>,
        title: String? = nil,
        message: String,
        highlightMessage: [HighlightMessage] = [],
        primaryButton: CustomDialog.ButtonConfig,
        secondaryButton: CustomDialog.ButtonConfig? = nil
    ) -> some View {
        let dialog = CustomDialog(
            isPresented: isPresented,
            title: title,
            message: message,
            highlightMessage: highlightMessage,
            primaryButton: primaryButton,
            secondaryButton: secondaryButton)
        return modifier(FDSModalModifier(isPresented: isPresented, modalView: dialog))
    }
}

Button("Show Dialog") {
    isPresented.toggle()
}
.fdsDialog(
    isPresented: $isPresented,
    title: "๋ฐฐ๊ฒฝํ™”๋ฉด ๋ณ€๊ฒฝ",
    message: "๋ฐฐ๊ฒฝํ™”๋ฉด ์ƒ‰์ƒ์„ ๋ณ€๊ฒฝํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n๋ณ€๊ฒฝ์„ ์›ํ•˜์‹œ๋ฉด ์ ์šฉ ๋ฒ„ํŠผ ํด๋ฆญํ•ด์ฃผ์„ธ์š”",
    highlightMessage: [(text: "์ƒ‰์ƒ", color: .blue)],
    primaryButton: .mono(label: "๋‹ซ๊ธฐ"),
    secondaryButton: .key(label: "์ ์šฉ") { ... }
)

์‹ค์ œ๋กœ ์‹คํ–‰ํ•ด๋ณด๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋ชจ์Šต์ž…๋‹ˆ๋‹ค. showDialog ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜๋ฉด ๋ฐฐ๊ฒฝํ™”๋ฉด ๋ณ€๊ฒฝ ํŒ์—…์ด ๋…ธ์ถœ๋ฉ๋‹ˆ๋‹ค. ์ด ํŒ์—…์€ ๋‹ซ๊ธฐ, ์ ์šฉ ๋‘๊ฐ€์ง€ ๋ฒ„ํŠผ์ด ์กด์žฌํ•˜๋Š”๋ฐ์š”. ๋‹ซ๊ธฐ๋ฅผ ํด๋ฆญํ–ˆ์„ ๋•Œ๋Š” ๋ฐฐ๊ฒฝํ™”๋ฉด ์ƒ‰์ƒ์ด ๊ทธ๋Œ€๋กœ ์œ ์ง€๋˜๋ฉฐ, ์ ์šฉ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜๋ฉด ๋žœ๋ค์œผ๋กœ ๋ฐฐ๊ฒฝํ™”๋ฉด ์ƒ‰์ƒ์ด ๋ณ€๊ฒฝ๋˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.




TCA์—์„œ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ DialogState๋กœ ๋ณ€ํ™˜

๊ธฐ์กด์˜ SwiftUI ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌํ˜„๋œ Dialog Component๋ฅผ TCA์—์„œ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด, TCA์˜ AlertState์™€ ์œ ์‚ฌํ•œ ํ˜•ํƒœ๋กœ DialogState๋ฅผ ์ •์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. ๋จผ์ € TCA์—์„œ fromm์˜ ๋””์ž์ธ ์‹œ์Šคํ…œ์— ๋งž๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ์ •์˜๋œ DialogState, DialogButtonState, DialogTextState ๋“ฑ์„ ๊ตฌํ˜„ํ•œ ์ฝ”๋“œ๋ฅผ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

๐ŸŒ€ DialogState ์ •์˜

DialogState๋Š” TCA์—์„œ ๋‹ค์ด์–ผ๋กœ๊ทธ์˜ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๊ธฐ ์œ„ํ•ด ์ •์˜๋œ ๊ตฌ์กฐ์ฒด๋กœ ๋‹ค์ด์–ผ๋กœ๊ทธ์˜ ID, ๋ฒ„ํŠผ, ๋ฉ”์‹œ์ง€, ์ œ๋ชฉ ๋“ฑ์„ ํฌํ•จํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

public struct DialogState<Action>: Identifiable {

    public let id: String
    public var buttons: [DialogButtonState<Action>]
    public var message: DialogTextState?
    public var title: String?

    public init(
        id: String = UUID().uuidString,
        buttons: [DialogButtonState<Action>],
        message: DialogTextState? = nil,
        title: String? = nil
    ) {
        self.id = id
        self.buttons = buttons
        self.message = message
        self.title = title
    }

}

๐ŸŒ€ DialogButtonState ์ •์˜

๋‹ค์ด์–ผ๋กœ๊ทธ ๋‚ด์˜ ๊ฐ ๋ฒ„ํŠผ์ด ์ˆ˜ํ–‰ํ•  ์ž‘์—…์„ ์œ„ํ•ด ๋ฒ„ํŠผ์˜ ID, ์•ก์…˜, ํƒ€์ดํ‹€, ํ™œ์„ฑํ™” ์—ฌ๋ถ€, ์—ญํ•  ๋“ฑ์„ ์ •์˜ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

public struct DialogButtonState<Action>: Identifiable {

    public let id: UUID
    public let action: Action
    public let title: String
    public let enabled: Bool
    public let role: DialogButtonStateRole

    public init(
        id: UUID = .init(),
        action: Action,
        title: String,
        enabled: Bool = true,
        role: DialogButtonStateRole = .none
    ) {
        self.id = id
        self.action = action
        self.title = title
        self.enabled = enabled
        self.role = role
    }

}

public enum DialogButtonStateRole: Equatable, Sendable, Hashable {

    case none
    case primary

}

๐ŸŒ€ DialogTextState ์ •์˜

๋ฉ”์‹œ์ง€ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๊ธฐ ์œ„ํ•ด ๋ฉ”์‹œ์ง€ ํ…์ŠคํŠธ์™€ ํ•˜์ด๋ผ์ดํŠธ ๋Œ€์ƒ์„ ์ •์˜ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

public struct DialogTextState {

    public let text: String
    public let highlights: [HighlightMessage]

    public init(text: String, highlights: [HighlightMessage] = []) {
        self.text = text
        self.highlights = highlights
    }

}

๐ŸŒ€ CustomDialogView์˜ ์ •์˜ ๋ฐ modifier

์•ž์„œ ์ •์˜ํ•œ ์ƒํƒœ๋“ค์„ ํ†ตํ•ด UI ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ๋„๋ก CustomDialogView ์ปดํฌ๋„ŒํŠธ๋ฅผ ์•„๋ž˜์™€ ๊ฐ™์ด ์žฌ๊ตฌํ˜„ํ•˜์˜€์Šต๋‹ˆ๋‹ค.

๋‹ค์ด์–ผ๋กœ๊ทธ์˜ ์ƒํƒœ๋ฅผ DialogState<Action> ํƒ€์ž…์œผ๋กœ ๊ด€๋ฆฌํ•˜๊ณ , DialogButtonState<Action>์„ ๋ฐ›์•„ ํ•ด๋‹น ๋ฒ„ํŠผ์— ๋Œ€์‘ํ•˜๋Š” ์•ก์…˜์„ ์ˆ˜ํ–‰ํ•˜๋„๋ก actionHandler๋ฅผ ์ •์˜ํ•˜๊ณ , ContentView๋ฅผ ์ œ๋„ค๋ฆญ์œผ๋กœ ๋ฐ›์•„ ์™ธ๋ถ€์—์„œ ์ƒํ™ฉ์— ์•Œ๋งž๋Š” ๋ทฐ๋ฅผ ์ „๋‹ฌ ๋ฐ›์„ ์ˆ˜ ์žˆ๋„๋ก ๊ตฌ์„ฑํ•˜์˜€์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ Design ์—ด๊ฑฐํ˜•์—์„œ ์Šคํƒ€์ผ๊ณผ ๋ ˆ์ด์•„์›ƒ ๊ฐ’์„ ์ •์˜ํ•˜์—ฌ ํ•œ ๊ณณ์—์„œ ๊ด€๋ฆฌ๋˜๋„๋ก ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

public struct CustomDialogView<ContentView, Action>: View where ContentView: View {

    // MARK: - Properties

    @Binding private var isPresented: Bool
    private let state: DialogState<Action>
    private let actionHandler: (DialogButtonState<Action>) -> Void
    private let contentView: () -> ContentView

    // MARK: - Initializer

    public init(
        isPresented: Binding<Bool>,
        state: DialogState<Action>,
        actionHandler: @escaping (DialogButtonState<Action>) -> Void,
        contentView: @escaping () -> ContentView
    ) {
        self._isPresented = isPresented
        self.state = state
        self.actionHandler = actionHandler
        self.contentView = contentView
    }

    // MARK: - Body
    
    public var body: some View {
        VStack(spacing: Design.containerVerticalSpacing) {
            headerView
                .padding(.top, Design.headerTopMargin)
                .padding([.horizontal], Design.headerHorizontalMargin)
            
            contentView()
            
            buttonView
                .frame(height: Design.buttonFrameHeight)
                .padding([.horizontal, .bottom], Design.buttonFrameMargin)
        }
        
        .frame(maxWidth: .infinity)
        .background(Design.containerBackgroundColor)
        .cornerRadius(Design.containerCornerRadius)
        .padding(.horizontal, Design.containerHorizontalMargin)
    }
    
    private var headerView: some View { ... }
    
    private var buttonView: some View { ... }
    
}

private enum Design { ... }

๋ณ€๊ฒฝ๋œ fdsDialog ๋ฉ”์„œ๋“œ๋Š” TCA์˜ Store๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋‹ค์ด์–ผ๋กœ๊ทธ ์ƒํƒœ๋ฅผ ๋ฐ”์ธ๋”ฉํ•˜๋ฉฐ, presentation ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ํ˜„์žฌ ์ƒํƒœ์— ๋”ฐ๋ผ ๋‹ค์ด์–ผ๋กœ๊ทธ์˜ ํ‘œ์‹œ ์—ฌ๋ถ€๋ฅผ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค. ๋˜ํ•œ, @ViewBuilder๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋‹ค์ด์–ผ๋กœ๊ทธ ๋‚ด๋ถ€์˜ ์ฝ˜ํ…์ธ ๋ฅผ ์œ ์—ฐํ•˜๊ฒŒ ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

import ComposableArchitecture
import SwiftUI


extension View {

    public func fdsDialog<Action, Content>(
        store: Store<PresentationState<DialogState<Action>>, PresentationAction<Action>>,
        @ViewBuilder content: @escaping (String?) -> Content = { _ in EmptyView() }
    ) -> some View where Content: View {
        fdsDialog(store: store, state: { $0 }, action: { $0 }, content: content)
    }

    public func fdsDialog<State, Action, ButtonAction, Content>(
        store: Store<PresentationState<State>, PresentationAction<Action>>,
        state toDestinationState: @escaping (State) -> DialogState<ButtonAction>?,
        action fromDestinationAction: @escaping (ButtonAction) -> Action,
        @ViewBuilder content: @escaping (String?) -> Content
    ) -> some View where Content: View {
        presentation(store: store, state: toDestinationState, action: fromDestinationAction) { `self`, $isPresented, _ in
            let dialogState = store.withState { $0.wrappedValue.flatMap(toDestinationState) }
            let dialogContent: () -> Content = {
                content(dialogState?.id)
            }
            self.modifier(DialogModifier(
                isPresented: $isPresented,
                presenting: dialogState,
                content: { state in
                    CustomDialogView<Content, ButtonAction>.init(
                        isPresented: $isPresented,
                        state: state,
                        actionHandler: { (buttonState) in
                            store.send(.presented(fromDestinationAction(buttonState.action)))
                        },
                        contentView: dialogContent
                    )
                }
            ))

        }
    }

}

์ง€๊ธˆ๊นŒ์ง€ CustomDialogView ์ปดํฌ๋„ŒํŠธ๋ฅผ ๊ตฌํ˜„ํ•˜๊ณ , modifier๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋‹ค์ด์–ผ๋กœ๊ทธ์˜ ํ‘œ์‹œ ์—ฌ๋ถ€์™€ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๋ฉฐ ๋ฒ„ํŠผ์˜ ์•ก์…˜์„ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๊ณต์œ ๋“œ๋ ธ์Šต๋‹ˆ๋‹ค. ๋งˆ์ง€๋ง‰์œผ๋กœ TCA์™€ SwiftUI ์กฐํ•ฉ์—์„œ ์ด ๋‹ค์ด์–ผ๋กœ๊ทธ๋ฅผ ์–ด๋–ป๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š”์ง€ ๊ฐ„๋žตํžˆ ์„ค๋ช…ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.


Dialog Component ์‚ฌ์šฉ

์ฒซ๋ฒˆ์งธ๋กœ, AlertState์™€ ๋™์ผํ•˜๊ฒŒ DialogState๋กœ ์ƒํƒœ๋ฅผ ์ •์˜ํ•ด์ค๋‹ˆ๋‹ค

struct MyFeatureState: Equatable {
    @PresentationState var dialog: DialogState<MyFeatureAction.Dialog>?
}

๋‘๋ฒˆ์งธ, ์ €๋Š” ๋ฉ”์‹œ์ง€ ์‚ญ์ œ ํŒ์—…์„ ์˜ˆ์‹œ๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋ฐฉ์‹์œผ๋กœ ์•ก์…˜์„ ์„ค์ •ํ•ด๋ดค์Šต๋‹ˆ๋‹ค. ( ์ด๋•Œ Action์— view, dialog๋ฅผ ๊ฐ๊ฐ ์ •์˜ํ•˜๋Š” ๋ถ€๋ถ„์ด ๊ถ๊ธˆํ•  ์ˆ˜ ์žˆ๋Š”๋ฐ์š”. ์ €ํฌ iOSํŒ€์€ view, delegate, inner, response, scope ๋“ฑ ์•ก์…˜์˜ ์—ญํ• ์„ ๋ช…ํ™•ํžˆ ๊ตฌ๋ถ„ํ•˜์—ฌ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. )

@CasePathable
enum MyFeatureAction {
    case view(ViewAction)
    case dialog(PresentationAction<Dialog>)
    
    enum ViewAction { 
        case deleteButtonTapped
    }
    
    @CasePathable
    public enum Dialog: Equatable {
        case didTapCancel
        case didTapDelete(LiveMessage)
    }
}

์„ธ๋ฒˆ์งธ๋กœ, ์•ก์…˜์— ๋Œ€ํ•œ ๊ฐ ๋ฆฌ๋“€์„œ(View, Dialog)๋ฅผ ์ž‘์„ฑํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค. ( ์ด๋•Œ ์—ญ์‹œ Action์—์„œ ๊ฐ ์—ญํ• ์„ ๋ถ„๋ฆฌํ–ˆ๊ธฐ ๋•Œ๋ฌธ์— ๋ฆฌ๋“€์„œ๋„ viewReducer์™€ dialogReducer ์ฒ˜๋Ÿผ ์—ญํ• ๋ณ„๋กœ ๋ถ„๋ฆฌํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ๋•๋ถ„์— ๊ฐ€๋…์„ฑ์ด ํ–ฅ์ƒ๋˜๊ณ , ๊ฐ ๊ธฐ๋Šฅ์˜ ์ฑ…์ž„์ด ๋ช…ํ™•ํ•ด์ ธ ์ฝ”๋“œ ๊ด€๋ฆฌ๊ฐ€ ํ›จ์”ฌ ์ˆ˜์›”ํ•ด์กŒ์Šต๋‹ˆ๋‹ค. )

import ComposableArchitecture

public struct MyFeature: Reducer {

    public var body: some ReducerOf<Self> {
        
        CombineReducers {
                ...
            viewReducer
            dialogReducer
            ...
        }
        .ifLet(\.$dialog, action: \.dialog)
        
    }

    private var viewReducer: some Reducer<State, Action> {
        Reduce { state, action in
            guard case let .view(viewAction) = action else {
                return .none
            }
            
            switch viewAction {
            case .deleteButtonTapped:
                state.dialog = .init(
                    buttons: [
                        .init(action: .didTapCancel, title: "์ทจ์†Œ"),
                        .init(action: .didTapDelete(message), title: "์‚ญ์ œ", role: .primary)
                    ],
                    message: .init(text: "๋ฉ”์‹œ์ง€๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?"),
                    title: "๋ฉ”์‹œ์ง€ ์‚ญ์ œ"
                )
                return .none
            }
        }
    }

    private var dialogReducer: some Reducer<State, Action> {
        Reduce { state, action in
            guard case let .dialog(.presented(dialogAction)) = action else {
                return .none
            }
            
            switch dialogAction {
                
            case let .didTapDelete(message):
                return .send(
                    .inner(.deleteMessage(message.id))
                )
                
            case .didTapCancel:
                return .none
                
            }
        }
    }
}

๋งˆ์ง€๋ง‰์œผ๋กœ SwiftUI ๋ทฐ์—์„œ ViewStore๋ฅผ ์ด์šฉํ•˜์—ฌ ๋‹ค์ด์–ผ๋กœ๊ทธ๋ฅผ ํ‘œ์‹œํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค!

import ComposableArchitecture
import SwiftUI

struct DialogTestView: View {
    @Bindable var store: StoreOf<MyFeature>

    var body: some View {
        ZStack {
           ...
           Button {
               store.send(.deleteButtonTapped)
             } label: {
                   Text("์‚ญ์ œ")
                 }
                 ...
        }
        .dialog(store: store.scope(state: \.$dialog, action: \.dialog))
    }
}

์‹ค์ œ ๋ผ์ด๋ธŒ ์ŠคํŠธ๋ฆฌ๋ฐ ํ™”๋ฉด์—์„œ ์‹คํ–‰ํ•œ ๊ฒฐ๊ณผ๋กœ ๋ณด๋ฉด ์ž˜ ์ ์šฉ๋˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.



๋งˆ์น˜๋ฉฐ

์ด๋ฒˆ ๊ธ€์—์„œ๋Š” ๋ผ์ด๋ธŒ ์ŠคํŠธ๋ฆฌ๋ฐ์„ ํ†ตํ•ด SwiftUI์™€ TCA๋ฅผ ์ฒ˜์Œ ๊ฒฝํ—˜ํ•˜๋ฉด์„œ, Dialog Component๋ฅผ ์žฌ๊ตฌ์„ฑํ•˜๋Š” ๊ณผ์ •์„ ๊ณต์œ ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ฒ˜์Œ SwiftUI ์ „์šฉ์œผ๋กœ ์ œ์ž‘ํ•œ Dialog ์ปดํฌ๋„ŒํŠธ๋ฅผ TCA์— ๋งž์ถฐ AlertState๋ฅผ ์ฐธ๊ณ ํ•˜์—ฌ ์ €ํฌ ํ”„๋กฌ์•ฑ์— ์ ํ•ฉํ•œ DialogState๋กœ ๋ฐœ์ „์‹œํ‚ฌ ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ์ด ๊ณผ์ •์„ ํ†ตํ•ด TCA์˜ ์ƒํƒœ ๊ด€๋ฆฌ์™€ SwiftUI์˜ UI ๊ตฌํ˜„์ด ๋”์šฑ ์›ํ™œํ•˜๊ฒŒ ์—ฐ๊ณ„๋˜์—ˆ๊ณ , ๊ธฐ์กด์˜ AlertState ๋ฐฉ์‹์ฒ˜๋Ÿผ ๋™์ž‘ํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋œ ์ ์ด ์ข‹์•˜์Šต๋‹ˆ๋‹ค.

์ €์˜ ๊ฒฝํ—˜์ด ๋„์›€์ด ๋˜์—ˆ๊ธฐ๋ฅผ ๋ฐ”๋ž๋‹ˆ๋‹ค. ๋๊นŒ์ง€ ์ฝ์–ด์ฃผ์…”์„œ ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค. โ€™โ—กโ€™

โ† ๋ชฉ๋ก์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ

Art Changes Life

๋…ธ๋จธ์Šค์™€ ํ•จ๊ป˜ ์—”ํ„ฐํ…Œํฌ ์‚ฐ์—…์„ ํ˜์‹ ํ•ด๋‚˜๊ฐˆ ๋ฉค๋ฒ„๋ฅผ ์ฐพ์Šต๋‹ˆ๋‹ค.

์ฑ„์šฉ ์ค‘์ธ ๊ณต๊ณ  ๋ณด๊ธฐ