HIT해
[iOS/SwiftUI] TCA NavigationStack 개발하기 (1.12.1) 본문
본 포스팅은 The Composable Architecture 1.12.1 (포스팅 기준 최신버전) 을 기준으로 작성되었습니다.
현재 진행중인 프로젝트의 NavigationStack 구현을 TCA 로 구현해보고자 했다.
TCA GitHub의 CaseStudies NavigationStack 부분을 참고하며 공부했다.
https://github.com/pointfreeco/swift-composable-architecture
코드를 하나하나 살펴보자.
CaseStudies_NavigationStack
@Reducer
struct NavigationDemo {
@Reducer(state: .equatable)
enum Path {
case screenA(ScreenA)
case screenB(ScreenB)
case screenC(ScreenC)
}
@ObservableState
struct State: Equatable {
var path = StackState<Path.State>()
}
enum Action {
case goBackToScreen(id: StackElementID)
case goToABCButtonTapped
case path(StackActionOf<Path>)
case popToRoot
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case let .goBackToScreen(id):
state.path.pop(to: id)
return .none
case .goToABCButtonTapped:
state.path.append(.screenA(ScreenA.State()))
state.path.append(.screenB(ScreenB.State()))
state.path.append(.screenC(ScreenC.State()))
return .none
case let .path(action):
switch action {
case .element(id: _, action: .screenB(.screenAButtonTapped)):
state.path.append(.screenA(ScreenA.State()))
return .none
case .element(id: _, action: .screenB(.screenBButtonTapped)):
state.path.append(.screenB(ScreenB.State()))
return .none
case .element(id: _, action: .screenB(.screenCButtonTapped)):
state.path.append(.screenC(ScreenC.State()))
return .none
default:
return .none
}
case .popToRoot:
state.path.removeAll()
return .none
}
}
.forEach(\.path, action: \.path)
}
}
지금 보면 너무 간단하지만 처음 이 코드를 접했을대는 하나하나가 너무 어려웠다.
우선 Reducer의 기본 구성요소중 하나인 State를 보자
@ObservableState
struct State: Equatable {
var path = StackState<Path.State>()
}
NavigationStack path에 할당해줄 값이다.
뷰와 리듀서가 쌍으로 담긴 배열을 생성해준다.
다음으로 Path 열거형을 살펴보자.
@Reducer(state: .equatable)
enum Path {
case screenA(ScreenA)
case screenB(ScreenB)
case screenC(ScreenC)
}
뷰와 뷰에 해당하는 리듀서 타입을 선언해준다.
값 할당은 NavigationStack destination에서 일어나고
리듀서를 사용하지 않는 뷰라면 선언해주지 않아도 무관하다.
Action을 살펴보자
enum Action {
case goBackToScreen(id: StackElementID)
case goToABCButtonTapped
case path(StackActionOf<Path>)
case popToRoot
}
여기서 잘봐야하는 부분은 path(StackActionOf<Path>다.
저 액션을 switch문으로 패턴매칭 시켜줄 것인데.
해당 타입에는 id( 스크린 )와 action ( 해당 뷰의 리듀서 액션들 ) 이 담겨져 있다는 걸 알아야 이후 코드가 이해될 것이다.
Reduce
Reduce { state, action in
switch action {
case let .goBackToScreen(id):
state.path.pop(to: id)
return .none
case .goToABCButtonTapped:
state.path.append(.screenA(ScreenA.State()))
state.path.append(.screenB(ScreenB.State()))
state.path.append(.screenC(ScreenC.State()))
return .none
case let .path(action):
switch action {
case .element(id: _, action: .screenB(.screenAButtonTapped)):
state.path.append(.screenA(ScreenA.State()))
return .none
case .element(id: _, action: .screenB(.screenBButtonTapped)):
state.path.append(.screenB(ScreenB.State()))
return .none
case .element(id: _, action: .screenB(.screenCButtonTapped)):
state.path.append(.screenC(ScreenC.State()))
return .none
default:
return .none
}
case .popToRoot:
state.path.removeAll()
return .none
}
}
.forEach(\.path, action: \.path)
state.path.append 나 pop는 TCA를 사용하는 사람들이라면 설명하지않아도 이해할 것이라 생각한다.
잘 보아야하는 부분은 path case다.
case let .path(action):
switch action {
case .element(id: _, action: .screenB(.screenAButtonTapped)):
state.path.append(.screenA(ScreenA.State()))
return .none
case .element(id: _, action: .screenB(.screenBButtonTapped)):
state.path.append(.screenB(ScreenB.State()))
return .none
case .element(id: _, action: .screenB(.screenCButtonTapped)):
state.path.append(.screenC(ScreenC.State()))
return .none
default:
return .none
}
이걸 이해하려면 sceenB의 리듀서를 같이 보아야한다.
@Reducer
struct ScreenB {
@ObservableState
struct State: Equatable {}
enum Action {
case screenAButtonTapped
case screenBButtonTapped
case screenCButtonTapped
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .screenAButtonTapped:
return .none
case .screenBButtonTapped:
return .none
case .screenCButtonTapped:
return .none
}
}
}
}
B의 리듀서에는 위 패턴매칭 문에서 사용되고 있는 액션들이 정의되어있다.
하지만 B리듀서의 액션에는 아무런 구문이 정의되어 있지 않다.
path(StackActionOf)는 각 뷰의 리듀서 액션에 접근이 가능하고,
현재 네비게이션 스택이 보여주고 있는 뷰에서 자식 리듀서가 호출되면 부모인 NavigationDemo 리듀서에서 호출을 받아 상위에서 처리한다.
그렇다면 상위 리듀서를 불러와서 사용하면 되는 것을 어째서 귀찮게 자식 리듀서에서 호출하고 응답을 받아서 사용할까.
프로젝트에 적용하면서 그 이유를 알게 되었다.
ScreenB에서 페이지 이동 기능과 ScreenB 상태관리를 병행해서 하려면
Navigation 리듀서와 ScreenB 리듀서를 함께 호출해야한다.
ScreenB가 Navigation Stack 루트와 가깝다면 상위에서 선언된 리듀서를 넘겨주는 방식으로 해결되지만.
엄청 깊숙히 들어가게된다면 그 수만큼 리듀서를 넘겨주어야하고 새로 선언해서 사용하면 인스턴스를 다시 생성하는 것이기에 비효율적이게 된다.
또한 위의 방식으로 구현하면 ScreenB 리듀서만 호출해도 페이지를 이동할 수 있기 때문애 가독성 및 효율성 측면으로 우수하다.
기존 리듀서에 네비게이션 이동 액션을 넣고 싶지 않다면, 공통적으로 사용하는 이동 리듀서를 생성해서 사용해주어도 좋다.
예시 코드이다.
// MARK:공통 네비게이션 리듀서 생성
@Reducer
struct CommonFeature {
@ObservableState
struct State: Equatable {}
enum Action {
case goBack
case goRoot
case removeAll
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .goBack:
return .none
case .goRoot:
return .none
case .removeAll:
return .none
}
}
}
}
NavigationFeature
@Reducer(state: .equatable)
enum Path {
case A(CommonFeature)
case B(CommonFeature)
case C(CommonFeature)
}
enum Action {
case path(StackActionOf<Path>)
}
var body
...
case let .path(action):
switch action {
case .element(id: _, action: .A(.goBack)):
// A 스크린에서 goBack 이 호출되었을때
return .none
case .element(id: _, action: .B(.goBack)):
// B 스크린에서 goBack 이 호출되었을때
return .none
case .element(id: _, action: .C(.removeAll)), .element(id:_, action : .A(.goRoot)):
// C 스크린에서는 removeAll, A스크린에서는 goRoot가 호출되었을때
return .none
이런식으로 사용할 수 있다.
같은 뒤로 가기 액션이라도 NavigationFeature 정의 부분에서 다르게 행동할 수 있다는 것이다.
forEach 부분을 보자
.forEach(\.path, action: \.path)
이 부분은 NavigationStack( path : ) 부분에 할당해줄 때 scope로 path에 담겨있는 뷰와 액션들을 연결해주기 위함이다.
NavigationStack 선언 부분을 살펴보자.
@Bindable var store: StoreOf<NavigationDemo>
var body: some View {
NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
Form {
Section { Text(template: readMe) }
Section {
NavigationLink(
"Go to screen A",
state: NavigationDemo.Path.State.screenA(ScreenA.State())
)
NavigationLink(
"Go to screen B",
state: NavigationDemo.Path.State.screenB(ScreenB.State())
)
NavigationLink(
"Go to screen C",
state: NavigationDemo.Path.State.screenC(ScreenC.State())
)
}
Section {
Button("Go to A → B → C") {
store.send(.goToABCButtonTapped)
}
}
}
.navigationTitle("Root")
} destination: { store in
switch store.case {
case let .screenA(store):
ScreenAView(store: store)
case let .screenB(store):
ScreenBView(store: store)
case let .screenC(store):
ScreenCView(store: store)
}
}
NavigationDemo Store를 불러오고 scope로 리듀서에 정의한 Path 열거형들을 불러와준다.
그리고 destination 에서 패턴매칭을 해준다.
참고로 Path 열거형에 정의한 뷰들을 모두 선언해주어야하고 NavigationStack안에서는 선언한 뷰만 이동할 수 있다.
그럼 이제 사용예시를 살펴보자.
1. Store 접근 없이 이동하기
NavigationLink(
"Go to screen B",
state: NavigationDemo.Path.State.screenB(ScreenB.State())
)
store 의 액션을 통해 이동하지 않고 NavigationStack의 Path에 직접 접근해 Path에 정의해둔 케이스를 할당해주어서 이동하는 방식이다.
2. Store 활용해서 이동하기
Button("Pop to root") {
store.send(.popToRoot, animation: .default)
}
Button("Decoupled navigation to screen C") {
store.send(.screenCButtonTapped)
}
두번째 방법은 Button 의 액션에 클로저를 넣어 사용하는 방법이다.
두 방법 모두 좋지만 실제 프로젝트에서는 디자인적으로 제한적인 사항들이 많아 뒤로가기 버튼을 커스텀해야한다.
1의 방식으로 뒤로가는 기능을 모든 페이지에서 사용하려면
@Environment(\.presentationMode) var presentationMode
Button(action: {
presentationMode.wrappedValue.dismiss()
}) {
이런 방식으로 코드를 짜야하고
2의 방식으로는
Button(action: {
store.send(.goBack)
})
이렇게 하면 될 것이다.
마지막으로 유의할점
1. case (StackActionOf<Path>) 중복 사용
enum Action {
case path(StackActionOf<Path>)
case goBack(StackActionOf<Path>)
}
case let .path(action):
switch action {
case .element(id: _, action: .a(.DAction)):
state.path.append(.home(HomeFeature.State()))
return .none
default:
return .none
}
case let .goBack(action):
switch action {
case .element(id: _, action: .a(.BAction)):
state.path.append(.home(HomeFeature.State()))
return .none
이렇게 선언해두면 아무리 path 에 선언해두지 않은 액션이라도 goBack에서 실행되지 않는다.
호출되면 위에 있는 path 문에 먼저 할당되고 default none으로가서 호출이 바람과 같이 사라지게 된다...( 파스스... )
2. Floating Menu
나는 프로젝트에서 특정 화면 이후 공통적으로 화면에 보이게 하는 메뉴바를 만들어야했다.
네비게이션 전환이 되어도 흐려졌다가 선명해지는 애니메이션이 적용되지 않아 사용하고자 했다.
이때 유의할점이 있다.
1. Floating Menu에서는 NavigationLink 가 아니라 Button 을 사용해야한다.
전체 코드를 살펴보자
struct NavigationDemoView: View {
@Bindable var store: StoreOf<NavigationDemo>
var body: some View {
NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
Form {
Section { Text(template: readMe) }
Section {
NavigationLink(
"Go to screen A",
state: NavigationDemo.Path.State.screenA(ScreenA.State())
)
NavigationLink(
"Go to screen B",
state: NavigationDemo.Path.State.screenB(ScreenB.State())
)
NavigationLink(
"Go to screen C",
state: NavigationDemo.Path.State.screenC(ScreenC.State())
)
}
Section {
Button("Go to A → B → C") {
store.send(.goToABCButtonTapped)
}
}
}
.navigationTitle("Root")
} destination: { store in
switch store.case {
case let .screenA(store):
ScreenAView(store: store)
case let .screenB(store):
ScreenBView(store: store)
case let .screenC(store):
ScreenCView(store: store)
}
}
.safeAreaInset(edge: .bottom) {
FloatingMenuView(store: store)
}
.navigationTitle("Navigation Stack")
}
}
// MARK: - Floating menu
struct FloatingMenuView: View {
let store: StoreOf<NavigationDemo>
struct ViewState: Equatable {
struct Screen: Equatable, Identifiable {
let id: StackElementID
let name: String
}
var currentStack: [Screen]
var total: Int
init(state: NavigationDemo.State) {
self.total = 0
self.currentStack = []
for (id, element) in zip(state.path.ids, state.path) {
switch element {
case let .screenA(screenAState):
self.total += screenAState.count
self.currentStack.insert(Screen(id: id, name: "Screen A"), at: 0)
case .screenB:
self.currentStack.insert(Screen(id: id, name: "Screen B"), at: 0)
case let .screenC(screenBState):
self.total += screenBState.count
self.currentStack.insert(Screen(id: id, name: "Screen C"), at: 0)
}
}
}
}
var body: some View {
let viewState = ViewState(state: store.state)
if viewState.currentStack.count > 0 {
VStack(alignment: .center) {
Text("Total count: \(viewState.total)")
Button("Pop to root") {
store.send(.popToRoot, animation: .default)
}
Menu("Current stack") {
ForEach(viewState.currentStack) { screen in
Button("\(String(describing: screen.id))) \(screen.name)") {
store.send(.goBackToScreen(id: screen.id))
}
.disabled(screen == viewState.currentStack.first)
}
Button("Root") {
store.send(.popToRoot, animation: .default)
}
}
}
.padding()
.background(Color(.systemBackground))
.padding(.bottom, 1)
.transition(.opacity.animation(.default))
.clipped()
.shadow(color: .black.opacity(0.2), radius: 5, y: 5)
}
}
}
FloatingMenu는 NavigationStack 의 프로퍼티로 걸어두어야 화면이 바뀌어도 계속해서 떠잇을 수 있는데
이렇게 edgeInset 프로퍼티안에서 호출하는 것은 NavigationStack 안에 있다고 취급되지않아 NavigationLink를 사용할 수 없다.
3. Navigation Root
무슨 짓을 해도 절대로 바뀌지 않는다...
공식문서에서 Root View는 절대로 바뀌지 않는다 라고 말했지만 그래도 코드적으로 바꿀 수 있지 않을까 많은 시도를 했다.
적어도 내가 시도한 방법들로는 해결되지 않았다..
누군가 안다면 설명바란다..
4. append
로드에 오래 걸리는 뷰는 append를 할때마다 이전의 페이지를 보여주는게 아니라 새로 만들어서 보여준다.
배열의 순서를 바꾸면 해결될까 했지만 swapAt과 move를 지원하지 않았다.
누군가 할 수 있다면 설명바란다..
'Swift > Swift 개발 노트' 카테고리의 다른 글
[iOS/SceneKit] 3D 화면에 텍스트 출력하기 (0) | 2024.09.04 |
---|---|
[iOS/SceneKit] 특정 노드 액션 구현하기 (0) | 2024.09.03 |
[iOS/SceneKit] Blender hdri 불러와서 적용하기 (0) | 2024.08.30 |
[iOS/TCA] @CasePathable (0) | 2024.08.30 |
[iOS/SwiftUI] 스크린샷 감지 TCA (0) | 2024.08.30 |