From 34183a15e99fa7bc6bf8d82bdd7111e9e5431f78 Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 25 Feb 2026 20:46:35 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20History=20=EB=84=A4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98=EC=9D=84=20Stack-based=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=ED=95=98=EC=97=AC=20Swipe-back=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PartnerDoriHistory → PartnerDoriDetail 화면에서 swipe-back 제스처 시 DoriList로 건너뛰는 문제를 해결했습니다. 변경 사항: - HistoryFeature에 Path reducer enum 추가 (partnerHistory, partnerDoriDetail, addDori, editDori) - @Presents 네비게이션을 StackState로 변경 - PartnerDoriHistoryFeature에서 @Presents doriDetail 제거 - 모든 push 목적지를 단일 navigation path로 평탄화 이제 SwiftUI가 단일 navigation path로 관리하여 swipe-back이 정상 동작합니다. Co-Authored-By: Claude Sonnet 4.5 --- .../History/Sources/HistoryFeature.swift | 137 ++++++++++-------- .../PartnerDoriHistoryFeature.swift | 26 +--- .../PartnerDoriHistoryView.swift | 5 - 3 files changed, 82 insertions(+), 86 deletions(-) diff --git a/Projects/Feature/History/Sources/HistoryFeature.swift b/Projects/Feature/History/Sources/HistoryFeature.swift index 45a343b..85a201c 100644 --- a/Projects/Feature/History/Sources/HistoryFeature.swift +++ b/Projects/Feature/History/Sources/HistoryFeature.swift @@ -17,14 +17,22 @@ import FeatureAddDori public struct HistoryFeature { public init() {} + // MARK: - Path Reducer + + @Reducer + public enum Path { + case partnerHistory(PartnerDoriHistoryFeature) + case partnerDoriDetail(PartnerDoriDetailFeature) + case addDori(AddDoriFeature) + case editDori(EditDoriFeature) + } + // MARK: - State @ObservableState public struct State { public var doriList: DoriListFeature.State = .init() - @Presents public var partnerHistory: PartnerDoriHistoryFeature.State? - @Presents public var addDori: AddDoriFeature.State? - @Presents public var editDori: EditDoriFeature.State? + public var path = StackState() public init() {} } @@ -32,9 +40,12 @@ public struct HistoryFeature { public enum Action { case doriList(DoriListFeature.Action) - case partnerHistory(PresentationAction) - case addDori(PresentationAction) - case editDori(PresentationAction) + case path(StackActionOf) + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case setTabBarVisible(Bool) + } } // MARK: - Reducer @@ -50,66 +61,79 @@ public struct HistoryFeature { // MARK: DoriList delegate case .doriList(.delegate(.partnerTapped(let partner))): - state.partnerHistory = PartnerDoriHistoryFeature.State( - partnerId: partner.partnerId, - partnerName: partner.partnerName, - relationship: partner.relationship + print("push dori before path count result: \(state.path.count)") + state.path.append( + .partnerHistory( + PartnerDoriHistoryFeature.State( + partnerId: partner.partnerId, + partnerName: partner.partnerName, + relationship: partner.relationship + ) + ) ) - return .none + return .send(.delegate(.setTabBarVisible(false))) case .doriList(.delegate(.fabTapped)): - state.addDori = AddDoriFeature.State() - return .none + state.path.append(.addDori(AddDoriFeature.State())) + return .send(.delegate(.setTabBarVisible(false))) case .doriList: return .none - // MARK: PartnerDoriHistory delegate + // MARK: Path delegate 처리 - case .partnerHistory(.presented(.delegate(.allDoriDeleted))): - state.partnerHistory = nil + // PartnerDoriHistory에서 doriTapped → Detail push + case .path(.element(id: _, action: .partnerHistory(.doriTapped(let dori)))): + state.path.append(.partnerDoriDetail(PartnerDoriDetailFeature.State(dori: dori))) + return .none + + // PartnerDoriHistory에서 전체 삭제 + case .path(.element(id: _, action: .partnerHistory(.delegate(.allDoriDeleted)))): + state.path.removeAll() return .send(.doriList(.refresh)) - case .partnerHistory(.presented(.delegate(.editTapped(let dori)))): - state.editDori = EditDoriFeature.State(dori: dori) + // PartnerDoriDetail에서 editTapped → Edit push + case .path(.element(id: _, action: .partnerDoriDetail(.delegate(.editTapped(let dori))))): + state.path.append(.editDori(EditDoriFeature.State(dori: dori))) return .none - case .partnerHistory: + // PartnerDoriDetail에서 단건 삭제 → History로 복귀 + case .path(.element(id: _, action: .partnerDoriDetail(.delegate(.doriDeleted)))): + state.path.removeLast() return .none - // MARK: AddDori - - case .addDori(.presented(.delegate(.doriCreated(_)))): - state.addDori = nil + // AddDori 완료 → 리스트로 복귀 + case .path(.element(id: _, action: .addDori(.delegate(.doriCreated(_))))): + state.path.removeAll() return .send(.doriList(.refresh)) - case .addDori(.presented(.delegate(.dismissed))): - state.addDori = nil + // AddDori dismiss + case .path(.element(id: _, action: .addDori(.delegate(.dismissed)))): + state.path.removeAll() return .none - case .addDori: - return .none + // EditDori 완료 → 리스트로 복귀 + case .path(.element(id: _, action: .editDori(.delegate(.doriUpdated(_))))): + state.path.removeAll() + return .send(.doriList(.refresh)) - // MARK: EditDori + // Path pop 감지 → TabBar 표시 여부 + case .path(.popFrom(id: _)): + print("pop result path count: \(state.path.count)") + // popFrom은 pop 시작 시점이므로 count == 1이면 root로 돌아감 + if state.path.count == 1 { + return .send(.delegate(.setTabBarVisible(true))) + } + return .none - case .editDori(.presented(.delegate(.doriUpdated(_)))): - state.editDori = nil - state.partnerHistory = nil - return .send(.doriList(.refresh)) + case .path: + return .none - case .editDori: + case .delegate: return .none } } - .ifLet(\.$partnerHistory, action: \.partnerHistory) { - PartnerDoriHistoryFeature() - } - .ifLet(\.$addDori, action: \.addDori) { - AddDoriFeature() - } - .ifLet(\.$editDori, action: \.editDori) { - EditDoriFeature() - } + .forEach(\.path, action: \.path) } } @@ -123,27 +147,26 @@ public struct HistoryView: View { } public var body: some View { - NavigationStack { + NavigationStack( + path: $store.scope(state: \.path, action: \.path) + ) { DoriListView( store: store.scope(state: \.doriList, action: \.doriList) ) - .navigationDestination( - item: $store.scope(state: \.partnerHistory, action: \.partnerHistory) - ) { historyStore in + } destination: { store in + switch store.case { + case .partnerHistory(let historyStore): PartnerDoriHistoryView(store: historyStore) - } - .navigationDestination( - item: $store.scope(state: \.addDori, action: \.addDori) - ) { addDoriStore in + case .partnerDoriDetail(let detailStore): + PartnerDoriDetailView(store: detailStore) + case .addDori(let addDoriStore): AddDoriView(store: addDoriStore) + case .editDori(let editDoriStore): + EditDoriView(store: editDoriStore) } - .navigationDestination( - item: $store.scope(state: \.editDori, action: \.editDori) - ) { editDoriStore in - NavigationStack { - EditDoriView(store: editDoriStore) - } - } + } + .task { + print("HistoryView showed: \(store.state.path.count)") } } } diff --git a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryFeature.swift b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryFeature.swift index ded4339..bbbfe9d 100644 --- a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryFeature.swift +++ b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryFeature.swift @@ -36,7 +36,6 @@ public struct PartnerDoriHistoryFeature { public var showDeleteAlert: Bool = false public var showFilterSheet: Bool = false public var toast: DoriToast? = nil - @Presents public var doriDetail: PartnerDoriDetailFeature.State? public var doriByDate: [(date: String, doris: [Dori])] { let filtered: [Dori] @@ -76,12 +75,10 @@ public struct PartnerDoriHistoryFeature { case doriTapped(Dori) case toastDismissed case setFilterSheet(Bool) - case doriDetail(PresentationAction) case delegate(Delegate) public enum Delegate: Equatable, Sendable { case allDoriDeleted - case editTapped(Dori) } } @@ -163,8 +160,8 @@ public struct PartnerDoriHistoryFeature { case .bulkDeleteResponse(.failure): return .none - case let .doriTapped(dori): - state.doriDetail = PartnerDoriDetailFeature.State(dori: dori) + case .doriTapped: + // 부모가 가로채서 push 처리 return .none case .toastDismissed: @@ -175,28 +172,9 @@ public struct PartnerDoriHistoryFeature { state.showFilterSheet = value return .none - // doriDetail 위임 처리 - case .doriDetail(.presented(.delegate(.editTapped(let dori)))): - return .send(.delegate(.editTapped(dori))) - - case .doriDetail(.presented(.delegate(.doriDeleted))): - let removedId = state.doriDetail?.doriId - state.doriDetail = nil - if let id = removedId { - state.inDoriList.removeAll { $0.doriId == id } - state.outDoriList.removeAll { $0.doriId == id } - } - return .none - - case .doriDetail: - return .none - case .delegate: return .none } } - .ifLet(\.$doriDetail, action: \.doriDetail) { - PartnerDoriDetailFeature() - } } } diff --git a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift index b12372e..5c6bab7 100644 --- a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift +++ b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift @@ -100,11 +100,6 @@ public struct PartnerDoriHistoryView: View { } } } - .navigationDestination( - item: $store.scope(state: \.doriDetail, action: \.doriDetail) - ) { detailStore in - PartnerDoriDetailView(store: detailStore) - } .sheet( isPresented: Binding( get: { store.showFilterSheet }, From 2b9c943b98c41e47bdfe3bfb54192d16a42a21c2 Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 25 Feb 2026 23:56:54 +0900 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20StackAction.popFrom=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=20=EC=9D=B4=EC=8A=88=EB=A5=BC=20lessons-lear?= =?UTF-8?q?ned=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .popFrom은 pop 시작 시점에 호출되므로 isEmpty 대신 count == 1로 root 복귀를 감지해야 합니다. Co-Authored-By: Claude Sonnet 4.5 --- .claude/rules/lessons-learned.md | 70 +++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/.claude/rules/lessons-learned.md b/.claude/rules/lessons-learned.md index c906737..a578627 100644 --- a/.claude/rules/lessons-learned.md +++ b/.claude/rules/lessons-learned.md @@ -196,5 +196,73 @@ MyPage: AuthInterceptor.retry → RefreshCoordinator (기다림) --- +## 6. TCA StackAction.popFrom 타이밍 (2026-02-25) + +### ❌ 잘못된 접근: isEmpty로 root 복귀 체크 + +```swift +// 하지 말 것! +case .path(.popFrom(id: _)): + if state.path.isEmpty { // pop 완료 전 시점이므로 항상 false + return .send(.delegate(.showTabBar)) + } + return .none +``` + +**문제점**: +- `.popFrom`은 pop **시작** 시점에 호출됨 (pop 완료 전) +- 1depth → root로 pop 시: + - `.popFrom` 호출 시점: `path.count = 1` (아직 pop 전) + - `isEmpty` 체크 → `false` ❌ + - pop 완료 후: `path.count = 0` +- 결과: root로 돌아가도 TabBar가 표시되지 않음 + +### ✅ 올바른 접근: count == 1로 root 복귀 체크 + +```swift +case .path(.popFrom(id: _)): + // popFrom은 pop 시작 시점이므로 count == 1이면 root로 돌아감 + if state.path.count == 1 { + return .send(.delegate(.showTabBar)) + } + return .none +``` + +**타임라인**: +``` +1depth → root로 pop: + 1. .popFrom 호출 → path.count = 1 ✅ + 2. count == 1 체크 → true + 3. TabBar 표시 액션 전송 + 4. pop 완료 → path.count = 0 + +2depth → 1depth로 pop: + 1. .popFrom 호출 → path.count = 2 + 2. count == 1 체크 → false + 3. TabBar 숨김 유지 + 4. pop 완료 → path.count = 1 +``` + +**핵심**: +- **`.popFrom`은 pop 시작 시점, pop 완료 전** +- `path.count == 1`로 체크해야 root 복귀 감지 가능 +- `isEmpty`는 절대 true가 될 수 없음 (pop 전이므로) + +**참고**: TCA Navigation 문서 - StackAction lifecycle + +--- + +## 요약 + +| 항목 | ❌ 하지 말 것 | ✅ 해야 할 것 | +|------|-------------|-------------| +| **토큰 검증** | 클라이언트에서 만료 시간 체크 | 서버 401 응답에 반응 | +| **Splash** | refresh 시도 | 토큰 존재만 체크 | +| **AuthInterceptor** | 메인 Session 사용 | 별도 Session 사용 | +| **복잡도** | 과도한 최적화 | 단순하고 검증된 방법 | +| **StackAction.popFrom** | isEmpty로 체크 | count == 1로 체크 | + +--- + **업데이트 일자**: 2026-02-25 -**관련 이슈**: 자동 로그인 기능 구현 +**관련 이슈**: 자동 로그인 기능 구현, History Stack 네비게이션 전환 From d1811ff375bc8c1df5a3fb3b37e68d62088194cb Mon Sep 17 00:00:00 2001 From: kangddong Date: Thu, 26 Feb 2026 00:54:12 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20CustomTabBar=20UI=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CustomTabBar 및 TabBarItem 컴포넌트 생성 - 탭바 아이콘 리소스 추가 (캘린더, 내역, 마이페이지) - 선택 상태에 따른 아이콘 및 텍스트 스타일 변경 - shadow 및 spacing 디자인 적용 Co-Authored-By: Claude Sonnet 4.5 --- Projects/App/Sources/CustomTabBar.swift | 86 +++++++++++++++++++ .../Icons.xcassets/Tabbar/Contents.json | 6 ++ .../tabbar_calendar.imageset/Contents.json | 21 +++++ .../tabbar_calendar.svg | 14 +++ .../Contents.json | 21 +++++ .../tabbar_calendar_fill.svg | 10 +++ .../tabbar_history.imageset/Contents.json | 21 +++++ .../tabbar_history.svg | 4 + .../Contents.json | 21 +++++ .../tabbar_history_fill.svg | 3 + .../tabbar_mypage.imageset/Contents.json | 21 +++++ .../tabbar_mypage.imageset/tabbar_mypage.svg | 12 +++ .../tabbar_mypage_fill.imageset/Contents.json | 21 +++++ .../tabbar_mypage_fill.svg | 10 +++ 14 files changed, 271 insertions(+) create mode 100644 Projects/App/Sources/CustomTabBar.swift create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/Contents.json create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar.imageset/Contents.json create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar.imageset/tabbar_calendar.svg create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar_fill.imageset/Contents.json create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar_fill.imageset/tabbar_calendar_fill.svg create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history.imageset/Contents.json create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history.imageset/tabbar_history.svg create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history_fill.imageset/Contents.json create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history_fill.imageset/tabbar_history_fill.svg create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage.imageset/Contents.json create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage.imageset/tabbar_mypage.svg create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage_fill.imageset/Contents.json create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage_fill.imageset/tabbar_mypage_fill.svg diff --git a/Projects/App/Sources/CustomTabBar.swift b/Projects/App/Sources/CustomTabBar.swift new file mode 100644 index 0000000..f2d9eb6 --- /dev/null +++ b/Projects/App/Sources/CustomTabBar.swift @@ -0,0 +1,86 @@ +// +// CustomTabBar.swift +// DoriApp +// +// Created by 강동영 on 2/25/26. +// Copyright © 2026 com.arex. All rights reserved. +// + +import SwiftUI +import DoriDesignSystem + +// MARK: - Custom TabBar + +struct CustomTabBar: View { + @Binding var selectedTab: MainTabFeature.State.Tab + + var body: some View { + HStack(spacing: 0) { + TabBarItem( + icon: selectedTab == .calendar ? Image(.tabbarCalendarFill) : Image(.tabbarCalendar), + title: "캘린더", + isSelected: selectedTab == .calendar + ) { + selectedTab = .calendar + } + + TabBarItem( + icon: selectedTab == .history ? Image(.tabbarHistoryFill) : Image(.tabbarHistory), + title: "내역", + isSelected: selectedTab == .history + ) { + selectedTab = .history + } + + TabBarItem( + icon: selectedTab == .myPage ? Image(.tabbarMypageFill) : Image(.tabbarMypage), + title: "마이페이지", + isSelected: selectedTab == .myPage + ) { + selectedTab = .myPage + } + } + .background(.doriWhite) + .shadow( + color: .black.opacity(0.05), + radius: 8, + x: 0, + y: -2 + ) + } +} + +// MARK: - TabBarItem + +private struct TabBarItem: View { + let icon: Image + let title: String + let isSelected: Bool + let action: () -> Void + + var font: TypoToken { + isSelected ? .bold(.b11) : .regular(.r11) + } + + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + icon + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .foregroundStyle(isSelected ? .main : .grey600) + + Text(title) + .pretendard(font) + .foregroundStyle(isSelected ? .main : .grey600) + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + } + .padding(.horizontal, 20) + .padding(.top, 16) + .buttonStyle(.plain) + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar.imageset/Contents.json new file mode 100644 index 0000000..489ddd4 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "tabbar_calendar.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar.imageset/tabbar_calendar.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar.imageset/tabbar_calendar.svg new file mode 100644 index 0000000..a160245 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar.imageset/tabbar_calendar.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar_fill.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar_fill.imageset/Contents.json new file mode 100644 index 0000000..ec85d0a --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar_fill.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "tabbar_calendar_fill.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar_fill.imageset/tabbar_calendar_fill.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar_fill.imageset/tabbar_calendar_fill.svg new file mode 100644 index 0000000..183230b --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_calendar_fill.imageset/tabbar_calendar_fill.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history.imageset/Contents.json new file mode 100644 index 0000000..6a5c1cf --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "tabbar_history.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history.imageset/tabbar_history.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history.imageset/tabbar_history.svg new file mode 100644 index 0000000..5d8315d --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history.imageset/tabbar_history.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history_fill.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history_fill.imageset/Contents.json new file mode 100644 index 0000000..1ef2cf2 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history_fill.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "tabbar_history_fill.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history_fill.imageset/tabbar_history_fill.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history_fill.imageset/tabbar_history_fill.svg new file mode 100644 index 0000000..40db687 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_history_fill.imageset/tabbar_history_fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage.imageset/Contents.json new file mode 100644 index 0000000..31cc7e6 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "tabbar_mypage.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage.imageset/tabbar_mypage.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage.imageset/tabbar_mypage.svg new file mode 100644 index 0000000..b56672a --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage.imageset/tabbar_mypage.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage_fill.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage_fill.imageset/Contents.json new file mode 100644 index 0000000..27c3c19 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage_fill.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "tabbar_mypage_fill.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage_fill.imageset/tabbar_mypage_fill.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage_fill.imageset/tabbar_mypage_fill.svg new file mode 100644 index 0000000..cd31b9d --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/Tabbar/tabbar_mypage_fill.imageset/tabbar_mypage_fill.svg @@ -0,0 +1,10 @@ + + + + + + + + + + From fbda4670952c2d9e584dea3492022baf5edfeb65 Mon Sep 17 00:00:00 2001 From: kangddong Date: Thu, 26 Feb 2026 00:54:28 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20TabBar=20visibility=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=B1=85=EC=9E=84=EC=9D=84=20MainTabFeature?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainTabView를 VStack 기반 레이아웃으로 변경하고, TabBar visibility 관리 책임을 분리했습니다. **변경 사항**: - MainTabView: ZStack → VStack(spacing: 0) 구조로 변경 - Content와 TabBar가 명시적으로 수직 배치 - Content가 TabBar에 가려지지 않음 - TabBar visibility 관리 책임 분리: - Before: 각 Feature에서 화면 전환마다 상위 리듀서에 delegate 전송 - After: MainTabFeature가 child state를 관찰하여 자동 계산 (파생 상태) - 역할 분리: - Feature: path/navigation 관리에만 집중 - MainTabFeature: isTabBarVisible 계산 및 TabBar 표시 관리 - 각 Feature에서 불필요한 delegate 액션 제거: - HistoryFeature: showTabBar/hideTabBar delegate 제거 - CalendarFeature: 기존 로직 유지 (addDori 상태 기반) - DoriListView: TabBar로 인한 중복 padding 제거 **기술적 개선**: - 책임 과다 해소: Feature가 TabBar 상태를 신경 쓰지 않음 - 단방향 데이터 플로우 강화: 파생 상태로 자동 계산 - 코드 중복 제거: 각 Feature에서 반복되던 delegate 제거 Co-Authored-By: Claude Sonnet 4.5 --- Projects/App/Sources/MainTabView.swift | 41 +++++++++++++------ .../Calendar/Sources/CalendarFeature.swift | 10 +++-- .../Calendar/Sources/CalendarView.swift | 13 +++++- .../Sources/Components/CalendarGridView.swift | 2 - .../Sources/DoriList/DoriListView.swift | 7 ---- .../History/Sources/HistoryFeature.swift | 25 +---------- 6 files changed, 50 insertions(+), 48 deletions(-) diff --git a/Projects/App/Sources/MainTabView.swift b/Projects/App/Sources/MainTabView.swift index 9b8150f..ec8bd06 100644 --- a/Projects/App/Sources/MainTabView.swift +++ b/Projects/App/Sources/MainTabView.swift @@ -23,6 +23,13 @@ struct MainTabFeature { enum Tab: Equatable { case calendar, history, myPage } + + // 파생 상태: 모든 탭이 root depth면 TabBar 표시 + var isTabBarVisible: Bool { + history.path.isEmpty && + calendar.addDori == nil && + myPage.navigationPath.isEmpty + } } enum Action { @@ -31,7 +38,7 @@ struct MainTabFeature { case history(HistoryFeature.Action) case myPage(MyPageFeature.Action) case delegate(Delegate) - + enum Delegate: Equatable { case needsAuthentication } @@ -73,19 +80,29 @@ struct MainTabView: View { @Bindable var store: StoreOf var body: some View { - TabView(selection: $store.selectedTab.sending(\.tabSelected)) { - CalendarView(store: store.scope(state: \.calendar, action: \.calendar)) - .tag(MainTabFeature.State.Tab.calendar) - .tabItem { Label("캘린더", systemImage: "calendar") } - - HistoryView(store: store.scope(state: \.history, action: \.history)) - .tag(MainTabFeature.State.Tab.history) - .tabItem { Label("내역", systemImage: "list.bullet.rectangle") } + VStack(spacing: 0) { + // Content + Group { + switch store.selectedTab { + case .calendar: + CalendarView(store: store.scope(state: \.calendar, action: \.calendar)) + case .history: + HistoryView(store: store.scope(state: \.history, action: \.history)) + case .myPage: + MyPageView(store: store.scope(state: \.myPage, action: \.myPage)) + } + } - MyPageView(store: store.scope(state: \.myPage, action: \.myPage)) - .tag(MainTabFeature.State.Tab.myPage) - .tabItem { Label("마이페이지", systemImage: "person.circle") } + // TabBar + if store.isTabBarVisible { + CustomTabBar( + selectedTab: $store.selectedTab.sending(\.tabSelected) + ) + .transition(.move(edge: .bottom)) + } } + .ignoresSafeArea(.keyboard) + .animation(.easeInOut(duration: 0.2), value: store.isTabBarVisible) } } diff --git a/Projects/Feature/Calendar/Sources/CalendarFeature.swift b/Projects/Feature/Calendar/Sources/CalendarFeature.swift index e5aa911..a373c34 100644 --- a/Projects/Feature/Calendar/Sources/CalendarFeature.swift +++ b/Projects/Feature/Calendar/Sources/CalendarFeature.swift @@ -66,15 +66,19 @@ public struct CalendarFeature { case .fabTapped: state.addDori = AddDoriFeature.State() return .none - + case .addDori(.presented(.delegate(.doriCreated))): state.addDori = nil return .none - + case .addDori(.presented(.delegate(.dismissed))): state.addDori = nil return .none - + + case .addDori(.dismiss): + state.addDori = nil + return .none + case .addDori: return .none diff --git a/Projects/Feature/Calendar/Sources/CalendarView.swift b/Projects/Feature/Calendar/Sources/CalendarView.swift index 23a0644..a75165b 100644 --- a/Projects/Feature/Calendar/Sources/CalendarView.swift +++ b/Projects/Feature/Calendar/Sources/CalendarView.swift @@ -8,7 +8,7 @@ import SwiftUI import ComposableArchitecture import DoriDesignSystem - +import FeatureAddDori public struct CalendarView: View { @Bindable var store: StoreOf @@ -69,6 +69,17 @@ public struct CalendarView: View { .navigationTitle("캘린더") .toolbarTitleDisplayMode(.inline) .onAppear { store.send(.onAppear) } + .overlay(alignment: .bottomTrailing) { + FloatingActionButton { + store.send(.fabTapped) + } + .padding(20) + } + .navigationDestination( + item: $store.scope(state: \.addDori, action: \.addDori) + ) { addDoriStore in + AddDoriView(store: addDoriStore) + } .sheet( isPresented: Binding( get: { store.selectedDay != nil }, diff --git a/Projects/Feature/Calendar/Sources/Components/CalendarGridView.swift b/Projects/Feature/Calendar/Sources/Components/CalendarGridView.swift index 1f4a045..adcfe4d 100644 --- a/Projects/Feature/Calendar/Sources/Components/CalendarGridView.swift +++ b/Projects/Feature/Calendar/Sources/Components/CalendarGridView.swift @@ -52,9 +52,7 @@ public struct CalendarGridView: View { } } } - .padding() .background(.doriWhite) - .cornerRadius(10) } } diff --git a/Projects/Feature/History/Sources/DoriList/DoriListView.swift b/Projects/Feature/History/Sources/DoriList/DoriListView.swift index 841154c..787784d 100644 --- a/Projects/Feature/History/Sources/DoriList/DoriListView.swift +++ b/Projects/Feature/History/Sources/DoriList/DoriListView.swift @@ -44,13 +44,6 @@ public struct DoriListView: View { .background(.grey100) .navigationTitle("내역") .navigationBarTitleDisplayMode(.inline) - .searchable( - text: Binding( - get: { store.searchText }, - set: { store.send(.searchTextChanged($0)) } - ), - prompt: "이름 또는 관계 검색" - ) .refreshable { store.send(.refresh) } diff --git a/Projects/Feature/History/Sources/HistoryFeature.swift b/Projects/Feature/History/Sources/HistoryFeature.swift index 85a201c..0e82d82 100644 --- a/Projects/Feature/History/Sources/HistoryFeature.swift +++ b/Projects/Feature/History/Sources/HistoryFeature.swift @@ -41,11 +41,6 @@ public struct HistoryFeature { public enum Action { case doriList(DoriListFeature.Action) case path(StackActionOf) - case delegate(Delegate) - - public enum Delegate: Equatable, Sendable { - case setTabBarVisible(Bool) - } } // MARK: - Reducer @@ -61,7 +56,6 @@ public struct HistoryFeature { // MARK: DoriList delegate case .doriList(.delegate(.partnerTapped(let partner))): - print("push dori before path count result: \(state.path.count)") state.path.append( .partnerHistory( PartnerDoriHistoryFeature.State( @@ -71,11 +65,11 @@ public struct HistoryFeature { ) ) ) - return .send(.delegate(.setTabBarVisible(false))) + return .none case .doriList(.delegate(.fabTapped)): state.path.append(.addDori(AddDoriFeature.State())) - return .send(.delegate(.setTabBarVisible(false))) + return .none case .doriList: return .none @@ -117,20 +111,8 @@ public struct HistoryFeature { state.path.removeAll() return .send(.doriList(.refresh)) - // Path pop 감지 → TabBar 표시 여부 - case .path(.popFrom(id: _)): - print("pop result path count: \(state.path.count)") - // popFrom은 pop 시작 시점이므로 count == 1이면 root로 돌아감 - if state.path.count == 1 { - return .send(.delegate(.setTabBarVisible(true))) - } - return .none - case .path: return .none - - case .delegate: - return .none } } .forEach(\.path, action: \.path) @@ -165,9 +147,6 @@ public struct HistoryView: View { EditDoriView(store: editDoriStore) } } - .task { - print("HistoryView showed: \(store.state.path.count)") - } } }