diff --git a/DevLog/UI/Common/Component/WebItemRow.swift b/DevLog/UI/Common/Component/WebItemRow.swift index a2dc07d1..b855728d 100644 --- a/DevLog/UI/Common/Component/WebItemRow.swift +++ b/DevLog/UI/Common/Component/WebItemRow.swift @@ -26,7 +26,6 @@ struct WebItemRow: View { VStack(alignment: .leading) { Text(item.title) .foregroundStyle(Color.primary) - .bold() .multilineTextAlignment(.leading) .lineLimit(2) Text(item.displayURL) diff --git a/DevLog/UI/Common/NavigationBarConfigurator.swift b/DevLog/UI/Common/NavigationBarConfigurator.swift index 63fcd643..5831d208 100644 --- a/DevLog/UI/Common/NavigationBarConfigurator.swift +++ b/DevLog/UI/Common/NavigationBarConfigurator.swift @@ -7,11 +7,37 @@ import SwiftUI +/// NavigationBar의 배경색을 지정하고 shadowColor를 제거하는 구조체 +/// +/// 기본적으로 ``UIColor/systemBackground``를 배경색으로 사용하며, +/// 자체 `NavigationStack`을 가진 뷰에서는 `alwaysVisible`을 `true`로 설정하여 +/// 스크롤 위치와 관계없이 배경색이 항상 표시되도록 할 수 있다. struct NavigationBarConfigurator: UIViewControllerRepresentable { private let backgroundColor: UIColor + private let alwaysVisible: Bool + /// 지정된 배경색으로 Configurator를 생성한다. + /// + /// - Parameter backgroundColor: NavigationBar에 적용할 배경색. init(_ backgroundColor: UIColor = .systemBackground) { self.backgroundColor = backgroundColor + self.alwaysVisible = false + } + + /// 지정된 배경색과 상시 표시 옵션으로 Configurator를 생성한다. + /// + /// - Parameters: + /// - backgroundColor: NavigationBar에 적용할 배경색. + /// - alwaysVisible: `true`이면 스크롤 위치와 관계없이 배경색이 항상 표시된다. + /// 자체 `NavigationStack`을 가진 뷰에서 사용한다. + @available(iOS, deprecated: 18, message: "iOS 18 이상에서는 alwaysVisible 파라미터가 없는 생성자를 사용한다.") + init(_ backgroundColor: UIColor = .systemBackground, alwaysVisible: Bool) { + self.backgroundColor = backgroundColor + if #available(iOS 18.0, *) { + self.alwaysVisible = false + } else { + self.alwaysVisible = alwaysVisible + } } func makeCoordinator() -> Coordinator { @@ -31,6 +57,11 @@ struct NavigationBarConfigurator: UIViewControllerRepresentable { coordinator.originalShadowColor = navigationBar.standardAppearance.shadowColor coordinator.originalBackgroundColor = navigationBar.standardAppearance.backgroundColor } + if self.alwaysVisible, navigationBar.scrollEdgeAppearance == nil { + let appearance = UINavigationBarAppearance() + appearance.configureWithDefaultBackground() + navigationBar.scrollEdgeAppearance = appearance + } Self.applyAppearance( to: navigationBar, shadowColor: .clear, diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index 2342e289..d5af8965 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -215,7 +215,6 @@ struct HomeView: View { } VStack(alignment: .leading) { Text(todo.title) - .bold() .foregroundStyle(Color.primary) Text(todo.dueDate? .formatted(date: .abbreviated, time: .omitted) ?? "마감일 없음" @@ -265,7 +264,6 @@ struct HomeView: View { .foregroundStyle(Color.primary) .font(.title2.bold()) Spacer() - } .listRowInsets(EdgeInsets()) } diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index 3bf6049b..68921d1a 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -77,7 +77,6 @@ struct TodoListView: View { Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") } .navigationTitle(viewModel.state.kind.localizedName) - .navigationBarTitleDisplayMode(.large) .fullScreenCover(isPresented: Binding( get: { viewModel.state.showEditor }, set: { viewModel.send(.setShowEditor($0)) } diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index 44f33554..faf04b82 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -18,73 +18,23 @@ struct PushNotificationListView: View { var body: some View { NavigationStack(path: $router.path) { - List { - Group { - if viewModel.state.notifications.isEmpty { - HStack { - Spacer() - Text("받은 알림이 없습니다.") - .foregroundStyle(Color.gray) - Spacer() - } - .listRowSeparator(.hidden) - } else { - let notifications = viewModel.state.notifications - ForEach(Array(zip(notifications.indices, notifications)), id: \.1.id) { idx, notification in - Button { - viewModel.send(.tapNotification(notification)) - } label: { - notificationRow(notification) - .padding(.vertical, 8) - } - .buttonStyle(.plain) - .onAppear { - let lastID = viewModel.state.notifications.last?.id - if notification.id == lastID, viewModel.state.hasMore { - viewModel.send(.loadNextPage) - } - } - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .overlay(alignment: .top) { - if #available(iOS 26.0, *) { - if idx == 0 { - Divider() - .padding(.horizontal, -16) - } - } - } - } - } - } - .listSectionSeparator(.hidden, edges: .top) - .listRowBackground(Color.clear) - } + notificationList .listStyle(.plain) - .background(NavigationBarConfigurator(.secondarySystemBackground)) + .background(NavigationBarConfigurator(.secondarySystemBackground, alwaysVisible: true)) .onScrollOffsetChange { offset in guard isScrollTrackingEnabled else { return } headerOffset = max(0, -offset) } - .safeAreaInset(edge: .top) { - VStack(spacing: 4) { - headerView - .clipped() - if #unavailable(iOS 26) { - Divider() - .padding(.horizontal, -16) - } - } - .background { - if #available(iOS 26.0, *) { - Color.clear - } else { - Color(.secondarySystemBackground) - } + .safeAreaInset(edge: .top) { safeAreaHeader } + .background(Color(.secondarySystemBackground)) + .onAppear { + viewModel.send(.fetchNotifications) + headerOffset = 0 + isScrollTrackingEnabled = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isScrollTrackingEnabled = true } - .offset(y: headerOffset) } - .background(Color(.secondarySystemBackground)) - .onAppear { viewModel.send(.fetchNotifications) } .refreshable { viewModel.send(.fetchNotifications) } .navigationTitle("받은 푸시 알람") .alert( @@ -138,77 +88,143 @@ struct PushNotificationListView: View { } } - private var headerView: some View { - ScrollView(.horizontal) { - HStack(spacing: 8) { - if 0 < viewModel.appliedFilterCount { - Menu { - Text("\(viewModel.appliedFilterCount)개 필터가 적용됨") - Button(role: .destructive) { - viewModel.send(.resetFilters) + private var notificationList: some View { + List { + Group { + if viewModel.state.notifications.isEmpty { + HStack { + Spacer() + Text("받은 알림이 없습니다.") + .foregroundStyle(Color.gray) + Spacer() + } + .listRowSeparator(.hidden) + } else { + let notifications = viewModel.state.notifications + ForEach(Array(zip(notifications.indices, notifications)), id: \.1.id) { idx, notification in + Button { + viewModel.send(.tapNotification(notification)) } label: { - Text("모든 필터 지우기") + notificationRow(notification) + .padding(.vertical, 8) } - } label: { - HStack(spacing: 6) { - Image(systemName: "line.3.horizontal.decrease") - filterBadge + .buttonStyle(.plain) + .onAppear { + let lastID = viewModel.state.notifications.last?.id + if notification.id == lastID, viewModel.state.hasMore { + viewModel.send(.loadNextPage) + } + } + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .overlay(alignment: .top) { + if #available(iOS 26.0, *) { + if idx == 0 { + Divider() + .padding(.horizontal, -16) + } + } } - .adaptiveButtonStyle() } } + } + .listSectionSeparator(.hidden, edges: .top) + .listRowBackground(Color.clear) + } + } - Button { - viewModel.send(.toggleSortOption) - } label: { - let condition = viewModel.state.query.sortOrder == .oldest - Text("정렬: \(viewModel.state.query.sortOrder.title)") - .foregroundStyle(condition ? .white : Color(.label)) - .adaptiveButtonStyle(color: condition ? .blue : .clear) - } + private var safeAreaHeader: some View { + VStack(spacing: 4) { + headerView + .clipped() + if #unavailable(iOS 26) { + Divider() + .padding(.horizontal, -16) + } + } + .background { + if #available(iOS 26.0, *) { + Color.clear + } else { + Color(.secondarySystemBackground) + } + } + .offset(y: headerOffset) + } + private var headerView: some View { + Group { + if #available(iOS 18, *) { + ScrollView(.horizontal) { headerContent } + .scrollIndicators(.never) + .scrollDisabled(!isScrollTrackingEnabled) + .contentMargins(.leading, 16, for: .scrollContent) + } else { + headerContent + .padding(.leading, 16) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private var headerContent: some View { + HStack(spacing: 8) { + if 0 < viewModel.appliedFilterCount { Menu { - Picker(selection: Binding( - get: { viewModel.state.query.timeFilter }, - set: { viewModel.send(.setTimeFilter($0)) } - )) { - ForEach(PushNotificationQuery.TimeFilter.availableOptions, id: \.self) { option in - Text(option.title).tag(option) - } + Text("\(viewModel.appliedFilterCount)개 필터가 적용됨") + Button(role: .destructive) { + viewModel.send(.resetFilters) } label: { - Text("기간") + Text("모든 필터 지우기") } } label: { - let condition = viewModel.state.query.timeFilter == .none - HStack { - Text("기간") - Image(systemName: "chevron.down") + HStack(spacing: 6) { + Image(systemName: "line.3.horizontal.decrease") + filterBadge } - .foregroundStyle(condition ? Color(.label) : .white) - .adaptiveButtonStyle(color: condition ? .clear : .blue) + .adaptiveButtonStyle() } + } - Button { - viewModel.send(.toggleUnreadOnly) + Button { + viewModel.send(.toggleSortOption) + } label: { + let condition = viewModel.state.query.sortOrder == .oldest + Text("정렬: \(viewModel.state.query.sortOrder.title)") + .foregroundStyle(condition ? .white : Color(.label)) + .adaptiveButtonStyle(color: condition ? .blue : .clear) + } + + Menu { + Picker(selection: Binding( + get: { viewModel.state.query.timeFilter }, + set: { viewModel.send(.setTimeFilter($0)) } + )) { + ForEach(PushNotificationQuery.TimeFilter.availableOptions, id: \.self) { option in + Text(option.title).tag(option) + } } label: { - let condition = viewModel.state.query.unreadOnly - Text("읽지 않음") - .foregroundStyle(condition ? .white : Color(.label)) - .adaptiveButtonStyle(color: condition ? .blue : .clear) + Text("기간") } + } label: { + let condition = viewModel.state.query.timeFilter == .none + HStack { + Text("기간") + Image(systemName: "chevron.down") + } + .foregroundStyle(condition ? Color(.label) : .white) + .adaptiveButtonStyle(color: condition ? .clear : .blue) } - .frame(height: 36) - } - .scrollIndicators(.never) - .scrollDisabled(!isScrollTrackingEnabled) - .contentMargins(.leading, 16, for: .scrollContent) - .onAppear { - headerOffset = 0 - isScrollTrackingEnabled = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isScrollTrackingEnabled = true + + Button { + viewModel.send(.toggleUnreadOnly) + } label: { + let condition = viewModel.state.query.unreadOnly + Text("읽지 않음") + .foregroundStyle(condition ? .white : Color(.label)) + .adaptiveButtonStyle(color: condition ? .blue : .clear) } } + .frame(height: 36) } private var filterBadge: some View {