SwiftUI์์ TCA์ ํจ๊ป ๋ค์ด์ผ๋ก๊ทธ ์ปดํฌ๋ํธ ์ฌ๊ตฌ์ฑํ๊ธฐ
- #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 ๋ฐฉ์์ฒ๋ผ ๋์ํ ์ ์๊ฒ ๋ ์ ์ด ์ข์์ต๋๋ค.
์ ์ ๊ฒฝํ์ด ๋์์ด ๋์๊ธฐ๋ฅผ ๋ฐ๋๋๋ค. ๋๊น์ง ์ฝ์ด์ฃผ์ ์ ๊ฐ์ฌํฉ๋๋ค. โโกโ