Swift에서 Navigation은 사용자가 앱 내에서 화면을 탐색하고 다양한 계층 구조를 통해 이동할 수 있도록 지원하는 중요한 요소이다.
애플의 SwiftUI 프레임워크에서는 NavigationStack과 NavigationLink를 사용하여 내비게이션을 구현할 수 있다.
앱 내에서 계층적 구조의 스택을 관리하는 컨테이너 역할을 수행한다. 화면을 스택에 push 하거나 pop하면서 이동 가능하며, 이러한 구조를 통해 이전 화면으로 돌아가는 기능을 제공한다.
NavigationStack {
// MARK: View 정의, NavigationLink를 통해 다음 뷰로 이동 가능
}
사용자가 특정 뷰를 탭하거나 선택 시, 다른 화면으로 이동하도록 연결해주는 역할을 수행한다.
NavigationLink(destination:)
을 사용하여 이동할 대상 뷰를 지정할 수 있다.
NavigationLink(destination: ExampleView()) {
Text("뷰 이동")
}
ComposableArchitecture 라이브러리는 NavigationStack 에 extension을 통해 새로운 생성자를 제공한다.
이 생성자는 아래와 같다.
public init<State, Action, Destination: View, R>(
path: Binding<Store<StackState<State>, StackAction<State, Action>>>,
root: () -> R,
@ViewBuilder destination: @escaping (Store<State, Action>) -> Destination,
fileID: StaticString = #fileID,
filePath: StaticString = #filePath,
line: UInt = #line,
column: UInt = #column
)
where
Data == StackState<State>.PathView,
Root == ModifiedContent<R, _NavigationDestinationViewModifier<State, Action, Destination>>
{
self.init(
path: path[
fileID: _HashableStaticString(rawValue: fileID),
filePath: _HashableStaticString(rawValue: filePath),
line: line,
column: column
]
) {
root()
.modifier(
_NavigationDestinationViewModifier(
store: path.wrappedValue,
destination: destination,
fileID: fileID,
filePath: filePath,
line: line,
column: column
)
)
}
}
일반적으로는 아래와 같이 쓰인다.
NavigationStack(path: self.$store.scope(state:\.path, action: \.path)) {
VStack {
}
.toolbar(.hidden, for: .navigationBar)
} destination: { store in
switch store.case {
case .login(let store) :
LoginView(store: store)
.toolbar(.hidden, for: .navigationBar)
case .profileSetting(let store):
ProfileSettingView(store: store)
.toolbar(.hidden, for: .navigationBar)
case .aljoTab(let store):
AppTabView(store: store)
.toolbar(.hidden, for: .navigationBar)
}
}
}
여기에서 store.path 가 뭐지? 라는 생각이 든다. 다음으로는 이 store.path에 대해 보도록 하자.
@Reducer
@ObservableState
struct State {
var path = StackState<Path.State>()
}
@CasePathable
enum Action: BindableAction {
case path(StackActionOf<Path>)
case binding(BindingAction<State>)
}
@Reducer
enum Path {
case login(LoginReducer)
case profileSetting(ProfileSettingReducer)
case appTab(AppTabReducer)
}
기존 Reducer에서 Scope()를 사용하여 여러 하위 Reducer를 연결할 때는 각 하위 Reducer에 대해 case를 명시적으로 정의하고, action을 해당 case와 연결해야 한다.
기존 Scope 방식 예시:
struct AppReducer: Reducer {
struct State: Equatable {
var loginState: LoginState?
var profileSettingState: ProfileSettingState?
}
enum Action: Equatable {
case login(LoginAction)
case profileSetting(ProfileSettingAction)
}
var body: some Reducer<State, Action> {
Scope(state: \State.loginState, action: /Action.login) {
LoginReducer()
}
Scope(state: \State.profileSettingState, action: /Action.profileSetting) {
ProfileSettingReducer()
}
}
}
Path 방식의 장점:
Path 방식을 사용하면 내비게이션 상태와 화면 전환을 일관되게 상태로 관리할 수 있으며, 상태 기반으로 동적으로 화면을 추가하거나 제거하는 것이 쉽다.
@Reducer
struct AppReducer {
struct State: Equatable {
var path: StackState<Path.State> = .init()
}
enum Action: Equatable {
case path(StackActionOf<Path>)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
return .none
}
.forEach(\.path, action: \.path) {
Path()
}
}
}
store의 Reduce 클로저에 .forEach(.path, action: .path) 를 붙여주는데, 이 코드가 없으면, 마치 Reducer body 내부에 BindingReducer()가 없는 것과 같이 바인딩이 제대로 이루어지지 않는다.