From 34d52d254aef5e305fccf2a93632079f06bc9548 Mon Sep 17 00:00:00 2001 From: kangddong Date: Fri, 13 Mar 2026 19:30:33 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1차 QA 반영 --- Projects/App/Sources/DoriApp.swift | 1 + .../Sources/DoriNavigationBar.swift | 340 ++++++++++++++++++ .../Feature/AddDori/Sources/AddDoriView.swift | 16 +- .../Calendar/Sources/CalendarView.swift | 1 + .../Sources/DoriList/DoriListView.swift | 3 +- .../PartnerDoriDetail/EditDoriView.swift | 19 +- .../PartnerDoriDetailView.swift | 30 +- .../PartnerDoriHistoryView.swift | 21 +- .../MyPage/Sources/CommonWebView.swift | 7 +- .../Feature/MyPage/Sources/MyPageView.swift | 21 +- 10 files changed, 404 insertions(+), 55 deletions(-) create mode 100644 Projects/Core/DoriDesignSystem/Sources/DoriNavigationBar.swift diff --git a/Projects/App/Sources/DoriApp.swift b/Projects/App/Sources/DoriApp.swift index 0f2fa87..fe232cb 100644 --- a/Projects/App/Sources/DoriApp.swift +++ b/Projects/App/Sources/DoriApp.swift @@ -64,6 +64,7 @@ struct DoriApp: App { var body: some Scene { WindowGroup { AppView(store: store) + .preferredColorScheme(.light) // 다크모드 비활성화 .onOpenURL { url in _ = KakaoSDKHandler.handleOpenURL(url) } diff --git a/Projects/Core/DoriDesignSystem/Sources/DoriNavigationBar.swift b/Projects/Core/DoriDesignSystem/Sources/DoriNavigationBar.swift new file mode 100644 index 0000000..11e42d0 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/DoriNavigationBar.swift @@ -0,0 +1,340 @@ +// +// DoriNavigationBar.swift +// Dori-iOS +// +// Created by 강동영 on 2/26/26. +// + +import SwiftUI + +// MARK: - Configuration + +public struct DoriNavigationBarConfig { + public let leading: LeadingItem + public let center: CenterItem + public let trailing: [TrailingItem] + + public init( + leading: LeadingItem, + center: CenterItem, + trailing: [TrailingItem] + ) { + self.leading = leading + self.center = center + self.trailing = trailing + } + + public enum LeadingItem { + case none + case backButton(action: @MainActor () -> Void) + } + + public enum CenterItem { + case none + case title(String) + case searchField(text: Binding, placeholder: String) + } + + public enum TrailingItem { + case iconButton(image: Image, action: @MainActor () -> Void) + } + + // MARK: - Factory Methods + + /// Case 0: leading X, title X + public static var empty: DoriNavigationBarConfig { + DoriNavigationBarConfig( + leading: .none, + center: .none, + trailing: [] + ) + } + + /// Case 1: back + title + public static func backWithTitle( + _ title: String, + onBack: @escaping @MainActor () -> Void + ) -> DoriNavigationBarConfig { + DoriNavigationBarConfig( + leading: .backButton(action: onBack), + center: .title(title), + trailing: [] + ) + } + + /// Case 2: back + title + trailing items (최대 2개) + public static func backWithTitleAndActions( + _ title: String, + onBack: @escaping @MainActor () -> Void, + trailing: [TrailingItem] + ) -> DoriNavigationBarConfig { + DoriNavigationBarConfig( + leading: .backButton(action: onBack), + center: .title(title), + trailing: Array(trailing.prefix(2)) // 최대 2개 + ) + } + + /// Case 4: back + search TextField + public static func backWithSearch( + text: Binding, + placeholder: String, + onBack: @escaping @MainActor () -> Void + ) -> DoriNavigationBarConfig { + DoriNavigationBarConfig( + leading: .backButton(action: onBack), + center: .searchField(text: text, placeholder: placeholder), + trailing: [] + ) + } + + /// Case 5: title + trailing (back 없음) + public static func titleWithActions( + _ title: String, + trailing: [TrailingItem] = [] + ) -> DoriNavigationBarConfig { + DoriNavigationBarConfig( + leading: .none, + center: .title(title), + trailing: Array(trailing.prefix(2)) // 최대 2개 + ) + } +} + +// MARK: - DoriNavigationBar View + +public struct DoriNavigationBar: View { + private let config: DoriNavigationBarConfig + + public init(config: DoriNavigationBarConfig) { + self.config = config + } + + public var body: some View { + HStack(spacing: 0) { + // Leading + leadingView + + Spacer(minLength: 0) + + // Trailing + trailingView + } + .padding(.leading, 8) + .padding(.trailing, 16) + .frame(height: 44) + .frame(maxWidth: .infinity) + .overlay { + // Center: 패딩 포함 전체 너비 기준 정중앙 + centerView + } + .background(.doriWhite) + } + + // MARK: - Leading + + @ViewBuilder + private var leadingView: some View { + switch config.leading { + case .none: + EmptyView() + + case .backButton(let action): + Button(action: action) { + Image(systemName: "chevron.left") + .foregroundStyle(.doriBlack) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + } + } + + // MARK: - Center + + @ViewBuilder + private var centerView: some View { + switch config.center { + case .none: + EmptyView() + + case .title(let title): + Text(title) + .pretendard(.headline(.h1)) + .foregroundStyle(.doriBlack) + .frame(maxWidth: .infinity) + + case .searchField(let text, let placeholder): + TextField(placeholder, text: text) + .pretendard(.body(.r3)) + .padding(.horizontal, 12) + .frame(height: 36) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(.grey100) + ) + .padding(.horizontal, 8) + } + } + + // MARK: - Trailing + + @ViewBuilder + private var trailingView: some View { + HStack(spacing: 0) { + ForEach(config.trailing.indices, id: \.self) { index in + let item = config.trailing[index] + switch item { + case .iconButton(let image, let action): + Button(action: action) { + image + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + } + } + } + } + +} + +// MARK: - Swipe Back Gesture Helper + +private struct NavigationControllerConfigurator: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + UIViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + DispatchQueue.main.async { + if let navigationController = uiViewController.navigationController { + navigationController.interactivePopGestureRecognizer?.isEnabled = true + navigationController.interactivePopGestureRecognizer?.delegate = context.coordinator + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: NSObject, UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + // UILayoutContainerView에서 상위 responder chain을 탐색하여 UINavigationController 찾기 + var responder: UIResponder? = gestureRecognizer.view + while let current = responder { + if let navigationController = current as? UINavigationController { + return navigationController.viewControllers.count > 1 + } + responder = current.next + } + + // 찾지 못한 경우 기본적으로 허용 (SwiftUI NavigationStack은 항상 허용) + return true + } + + // ScrollView와 동시에 인식되도록 허용 + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + return true + } + + // Pop gesture가 다른 제스처(ScrollView 등)보다 우선하도록 설정 + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + // ScrollView의 pan gesture가 pop gesture에게 우선권 양보 + return otherGestureRecognizer is UIPanGestureRecognizer + } + } +} + +// MARK: - ViewModifier + +public struct DoriNavigationBarModifier: ViewModifier { + let config: DoriNavigationBarConfig + + public init(config: DoriNavigationBarConfig) { + self.config = config + } + + public func body(content: Content) -> some View { + content + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + .safeAreaInset(edge: .top, spacing: 0) { + DoriNavigationBar(config: config) + } + .background( + NavigationControllerConfigurator() + .frame(width: 0, height: 0) + ) + } +} + +public extension View { + func doriNavigationBar(_ config: DoriNavigationBarConfig) -> some View { + modifier(DoriNavigationBarModifier(config: config)) + } +} + +// MARK: - Preview + +#Preview("Case 0: Empty") { + NavigationStack { + UIAsset.Colors.grey100.color + .ignoresSafeArea() + .doriNavigationBar(DoriNavigationBarConfig.empty) + } +} + +#Preview("Case 1: Back + Title") { + NavigationStack { + UIAsset.Colors.grey100.color + .ignoresSafeArea() + .doriNavigationBar(DoriNavigationBarConfig.backWithTitle("내역 추가", onBack: {})) + } +} + +#Preview("Case 2: Back + Title + 2 Actions") { + NavigationStack { + UIAsset.Colors.grey100.color + .ignoresSafeArea() + .doriNavigationBar( + DoriNavigationBarConfig.backWithTitleAndActions( + "김철수", + onBack: {}, + trailing: [ + .iconButton(image: Image(systemName: "pencil"), action: {}), + .iconButton(image: Image(systemName: "trash"), action: {}) + ] + ) + ) + } +} + +#Preview("Case 4: Back + Search") { + @Previewable @State var searchText = "" + + NavigationStack { + UIAsset.Colors.grey100.color + .ignoresSafeArea() + .doriNavigationBar( + DoriNavigationBarConfig.backWithSearch( + text: $searchText, + placeholder: "이름 검색", + onBack: {} + ) + ) + } +} + +#Preview("Case 5: Title Only") { + NavigationStack { + UIAsset.Colors.grey100.color + .ignoresSafeArea() + .doriNavigationBar(DoriNavigationBarConfig.titleWithActions("캘린더")) + } +} diff --git a/Projects/Feature/AddDori/Sources/AddDoriView.swift b/Projects/Feature/AddDori/Sources/AddDoriView.swift index 69adce5..b5c696c 100644 --- a/Projects/Feature/AddDori/Sources/AddDoriView.swift +++ b/Projects/Feature/AddDori/Sources/AddDoriView.swift @@ -11,15 +11,16 @@ import DoriDesignSystem public struct AddDoriView: View { @Bindable var store: StoreOf - + @Environment(\.dismiss) private var dismiss + public init(store: StoreOf) { self.store = store } - + public var body: some View { VStack(spacing: 32) { pageIndicator - + pageContent .animation( .easeInOut(duration: 0.3), @@ -27,8 +28,13 @@ public struct AddDoriView: View { ) } .background(.doriWhite) - .navigationTitle("내역 추가") - .navigationBarTitleDisplayMode(.inline) + .doriKeyboardDismissable() + .doriNavigationBar( + DoriNavigationBarConfig.backWithTitle( + "내역 추가", + onBack: { dismiss() } + ) + ) } private var pageIndicator: some View { diff --git a/Projects/Feature/Calendar/Sources/CalendarView.swift b/Projects/Feature/Calendar/Sources/CalendarView.swift index a75165b..7681a86 100644 --- a/Projects/Feature/Calendar/Sources/CalendarView.swift +++ b/Projects/Feature/Calendar/Sources/CalendarView.swift @@ -68,6 +68,7 @@ public struct CalendarView: View { } .navigationTitle("캘린더") .toolbarTitleDisplayMode(.inline) + .doriNavigationBar(DoriNavigationBarConfig.titleWithActions("캘린더")) .onAppear { store.send(.onAppear) } .overlay(alignment: .bottomTrailing) { FloatingActionButton { diff --git a/Projects/Feature/History/Sources/DoriList/DoriListView.swift b/Projects/Feature/History/Sources/DoriList/DoriListView.swift index 787784d..39ba4cc 100644 --- a/Projects/Feature/History/Sources/DoriList/DoriListView.swift +++ b/Projects/Feature/History/Sources/DoriList/DoriListView.swift @@ -42,8 +42,7 @@ public struct DoriListView: View { .padding(20) } .background(.grey100) - .navigationTitle("내역") - .navigationBarTitleDisplayMode(.inline) + .doriNavigationBar(.titleWithActions("내역")) .refreshable { store.send(.refresh) } diff --git a/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift b/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift index 0e11d53..915b936 100644 --- a/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift +++ b/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift @@ -12,6 +12,7 @@ import DoriCore public struct EditDoriView: View { @Bindable var store: StoreOf + @Environment(\.dismiss) private var dismiss private let options2x2: [DoriSegmentOption] = [ .init(id: .judori, title: TransactionType.judori.displayName, role: .normal), @@ -43,16 +44,16 @@ public struct EditDoriView: View { ) .keyboardType(.numberPad) .pretendard(.body(.sb3)) - + Spacer() - + Text("원") .pretendard(.semiBold(.sb15)) } .roundedStyle() - + } - + // 내역 구분 VStack(alignment: .leading, spacing: 10) { Text("내역 구분") @@ -136,8 +137,14 @@ public struct EditDoriView: View { .padding(.horizontal, 16) .padding(.bottom, 20) } - .navigationTitle(store.dori.partnerName) - .navigationBarTitleDisplayMode(.inline) + .scrollDismissesKeyboard(.interactively) + .doriKeyboardDismissable() + .doriNavigationBar( + DoriNavigationBarConfig.backWithTitle( + store.dori.partnerName, + onBack: { dismiss() } + ) + ) .overlay { if store.isDatePickerVisible { Color.black.opacity(0.4) diff --git a/Projects/Feature/History/Sources/PartnerDoriDetail/PartnerDoriDetailView.swift b/Projects/Feature/History/Sources/PartnerDoriDetail/PartnerDoriDetailView.swift index adbdcbb..ee2e29f 100644 --- a/Projects/Feature/History/Sources/PartnerDoriDetail/PartnerDoriDetailView.swift +++ b/Projects/Feature/History/Sources/PartnerDoriDetail/PartnerDoriDetailView.swift @@ -12,6 +12,7 @@ import DoriCore public struct PartnerDoriDetailView: View { @Bindable var store: StoreOf + @Environment(\.dismiss) private var dismiss public init(store: StoreOf) { self.store = store @@ -50,25 +51,16 @@ public struct PartnerDoriDetailView: View { } } .background(.doriWhite) - .navigationTitle(store.doriDetail?.partnerName ?? "") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: 16) { - Button { - store.send(.editTapped) - } label: { - Image(.iconEdit) - } - - Button { - store.send(.deleteTapped) - } label: { - Image(.iconDelete) - } - } - } - } + .doriNavigationBar( + DoriNavigationBarConfig.backWithTitleAndActions( + store.doriDetail?.partnerName ?? "", + onBack: { dismiss() }, + trailing: [ + .iconButton(image: Image(.iconEdit), action: { store.send(.editTapped) }), + .iconButton(image: Image(.iconDelete), action: { store.send(.deleteTapped) }) + ] + ) + ) .overlay { if store.showDeleteAlert { DoriCommonAlert( diff --git a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift index 5c6bab7..e1ff437 100644 --- a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift +++ b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift @@ -11,6 +11,7 @@ import DoriDesignSystem public struct PartnerDoriHistoryView: View { @Bindable var store: StoreOf + @Environment(\.dismiss) private var dismiss public init(store: StoreOf) { self.store = store @@ -89,17 +90,15 @@ public struct PartnerDoriHistoryView: View { } } .background(.doriWhite) - .navigationTitle(store.partnerName) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - store.send(.bulkDeleteTapped) - } label: { - Image(.iconDelete) - } - } - } + .doriNavigationBar( + DoriNavigationBarConfig.backWithTitleAndActions( + store.partnerName, + onBack: { dismiss() }, + trailing: [ + .iconButton(image: Image(.iconDelete), action: { store.send(.bulkDeleteTapped) }) + ] + ) + ) .sheet( isPresented: Binding( get: { store.showFilterSheet }, diff --git a/Projects/Feature/MyPage/Sources/CommonWebView.swift b/Projects/Feature/MyPage/Sources/CommonWebView.swift index ea25999..ef7e749 100644 --- a/Projects/Feature/MyPage/Sources/CommonWebView.swift +++ b/Projects/Feature/MyPage/Sources/CommonWebView.swift @@ -5,6 +5,7 @@ // Created by 강동영 on 2/5/26. // +import DoriDesignSystem import SwiftUI import WebKit @@ -180,7 +181,8 @@ struct WKWebViewRepresentable: UIViewRepresentable { struct CommonWebView: View { let navigationTitle: String let url: URL - + + @Environment(\.dismiss) private var dismiss @State private var isLoading = false @State private var progress: Double = 0 @State private var canGoBack = false @@ -228,8 +230,7 @@ struct CommonWebView: View { // 네비게이션 툴바 toolbarView } - .navigationTitle(navigationTitle) - .navigationBarTitleDisplayMode(.inline) + .doriNavigationBar(.backWithTitle(navigationTitle, onBack: { dismiss() })) } private var toolbarView: some View { diff --git a/Projects/Feature/MyPage/Sources/MyPageView.swift b/Projects/Feature/MyPage/Sources/MyPageView.swift index 9982726..db4839f 100644 --- a/Projects/Feature/MyPage/Sources/MyPageView.swift +++ b/Projects/Feature/MyPage/Sources/MyPageView.swift @@ -24,16 +24,19 @@ public struct MyPageView: View { public var body: some View { NavigationStack(path: navigationPathBinding) { - VStack(alignment: .leading, spacing: 24) { - settingInfoView - accountInfoView - - Spacer() + ZStack { + UIAsset.Colors.doriWhite.color + .ignoresSafeArea() + + VStack(alignment: .leading, spacing: 24) { + settingInfoView + accountInfoView + + Spacer() + } + .padding(.horizontal, 16) } - .padding(.horizontal, 16) - .background(.doriWhite) - .navigationTitle("마이페이지") - .toolbarTitleDisplayMode(.inline) + .doriNavigationBar(.titleWithActions("마이페이지")) .overlay { if store.isLoading { ProgressView() From f121c883b0c9e147e80cc59f7125170c382b041c Mon Sep 17 00:00:00 2001 From: kangddong Date: Fri, 13 Mar 2026 21:56:18 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20AddDoriView=EC=97=90=EC=84=9C=20dori?= =?UTF-8?q?KeyboardDismissable=20=EB=AA=A8=EB=94=94=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DoriKeyboardDismissModifier가 feature/31 브랜치 범위 밖이므로 제거 Co-Authored-By: Claude Sonnet 4.6 --- Projects/Feature/AddDori/Sources/AddDoriView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Projects/Feature/AddDori/Sources/AddDoriView.swift b/Projects/Feature/AddDori/Sources/AddDoriView.swift index b5c696c..3151277 100644 --- a/Projects/Feature/AddDori/Sources/AddDoriView.swift +++ b/Projects/Feature/AddDori/Sources/AddDoriView.swift @@ -28,7 +28,6 @@ public struct AddDoriView: View { ) } .background(.doriWhite) - .doriKeyboardDismissable() .doriNavigationBar( DoriNavigationBarConfig.backWithTitle( "내역 추가", From c681ac011aff8e0afee62172385ef935008cc591 Mon Sep 17 00:00:00 2001 From: kangddong Date: Fri, 13 Mar 2026 23:19:40 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20EditDoriView=EC=97=90=EC=84=9C=20dor?= =?UTF-8?q?iKeyboardDismissable=20=EB=AA=A8=EB=94=94=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DoriKeyboardDismissModifier가 feature/31 브랜치 범위 밖이므로 제거 Co-Authored-By: Claude Sonnet 4.6 --- .../Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift b/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift index 915b936..4d06279 100644 --- a/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift +++ b/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift @@ -138,7 +138,6 @@ public struct EditDoriView: View { .padding(.bottom, 20) } .scrollDismissesKeyboard(.interactively) - .doriKeyboardDismissable() .doriNavigationBar( DoriNavigationBarConfig.backWithTitle( store.dori.partnerName,