diff --git a/DevLog/UI/Common/NavigationBarConfigurator.swift b/DevLog/UI/Common/NavigationBarConfigurator.swift new file mode 100644 index 00000000..63fcd643 --- /dev/null +++ b/DevLog/UI/Common/NavigationBarConfigurator.swift @@ -0,0 +1,74 @@ +// +// NavigationBarConfigurator.swift +// DevLog +// +// Created by 최윤진 on 3/5/26. +// + +import SwiftUI + +struct NavigationBarConfigurator: UIViewControllerRepresentable { + private let backgroundColor: UIColor + + init(_ backgroundColor: UIColor = .systemBackground) { + self.backgroundColor = backgroundColor + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIViewController(context: Context) -> UIViewController { + UIViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + if #available(iOS 26, *) { return } + DispatchQueue.main.async { + guard let navigationBar = uiViewController.navigationController?.navigationBar else { return } + let coordinator = context.coordinator + if coordinator.originalShadowColor == nil { + coordinator.originalShadowColor = navigationBar.standardAppearance.shadowColor + coordinator.originalBackgroundColor = navigationBar.standardAppearance.backgroundColor + } + Self.applyAppearance( + to: navigationBar, + shadowColor: .clear, + backgroundColor: self.backgroundColor + ) + } + } + + static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) { + if #available(iOS 26, *) { return } + guard let navigationBar = uiViewController.navigationController?.navigationBar else { return } + applyAppearance( + to: navigationBar, + shadowColor: coordinator.originalShadowColor, + backgroundColor: coordinator.originalBackgroundColor + ) + } + + private static func applyAppearance( + to navigationBar: UINavigationBar, + shadowColor: UIColor?, + backgroundColor: UIColor? + ) { + let appearances = [ + navigationBar.standardAppearance, + navigationBar.scrollEdgeAppearance, + navigationBar.compactAppearance, + navigationBar.compactScrollEdgeAppearance + ] + + for appearance in appearances { + appearance?.shadowColor = shadowColor + appearance?.backgroundColor = backgroundColor + } + } + + class Coordinator { + var originalShadowColor: UIColor? + var originalBackgroundColor: UIColor? + } +} diff --git a/DevLog/UI/Extension/View+.swift b/DevLog/UI/Extension/View+.swift index f8d11298..fcbc0ec2 100644 --- a/DevLog/UI/Extension/View+.swift +++ b/DevLog/UI/Extension/View+.swift @@ -7,6 +7,90 @@ import SwiftUI +extension View { + @ViewBuilder + func onScrollOffsetChange(action: @escaping (CGFloat) -> Void) -> some View { + if #available(iOS 18, *) { + self.onScrollGeometryChange(for: CGFloat.self) { geo in + geo.contentOffset.y + geo.contentInsets.top + } action: { _, newOffset in + action(newOffset) + } + } else { + self.background(ScrollViewOffsetTracker(onChange: action)) + } + } +} + +private struct ScrollViewOffsetTracker: UIViewRepresentable { + var onChange: (CGFloat) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onChange: onChange) + } + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.isHidden = true + view.isUserInteractionEnabled = false + DispatchQueue.main.async { + guard let scrollView = Self.findScrollView(from: view) else { return } + context.coordinator.observe(scrollView) + } + return view + } + + func updateUIView(_ uiView: UIView, context: Context) {} + + private static func findScrollView(from view: UIView) -> UIScrollView? { + var current = view.superview + while let superview = current { + if let scrollView = superview as? UIScrollView { + return scrollView + } + for sibling in superview.subviews where sibling !== view { + if let scrollView = findScrollViewInSubviews(of: sibling) { + return scrollView + } + } + current = superview.superview + } + return nil + } + + private static func findScrollViewInSubviews(of view: UIView) -> UIScrollView? { + if let scrollView = view as? UIScrollView { + return scrollView + } + for subview in view.subviews { + if let scrollView = findScrollViewInSubviews(of: subview) { + return scrollView + } + } + return nil + } + + class Coordinator: NSObject { + private var onChange: (CGFloat) -> Void + private var observation: NSKeyValueObservation? + + init(onChange: @escaping (CGFloat) -> Void) { + self.onChange = onChange + } + + func observe(_ scrollView: UIScrollView) { + observation = scrollView.observe(\.contentOffset, options: [.new]) { [weak self] scrollView, _ in + let offset = scrollView.contentOffset.y + scrollView.adjustedContentInset.top + self?.onChange(offset) + } + } + + deinit { + observation?.invalidate() + } + } +} + extension View { @ViewBuilder func adaptiveButtonStyle( diff --git a/DevLog/UI/Home/TodoDetailView.swift b/DevLog/UI/Home/TodoDetailView.swift index 0ca66119..250ee917 100644 --- a/DevLog/UI/Home/TodoDetailView.swift +++ b/DevLog/UI/Home/TodoDetailView.swift @@ -23,6 +23,7 @@ struct TodoDetailView: View { } } .onAppear { viewModel.send(.onAppear) } + .navigationBarTitleDisplayMode(.inline) .sheet(isPresented: Binding( get: { viewModel.state.showInfo }, set: { viewModel.send(.setShowInfo($0)) } diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index 56f73a80..e501478b 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -12,6 +12,8 @@ struct TodoListView: View { @Environment(NavigationRouter.self) var router @Environment(\.diContainer) var container: DIContainer @Environment(\.colorScheme) private var colorScheme + @State private var headerOffset: CGFloat = 0 + @State private var isScrollTrackingEnabled = false var body: some View { Group { @@ -106,14 +108,14 @@ struct TodoListView: View { } } } - .toolbarBackground(.visible, for: .navigationBar) + .background(NavigationBarConfigurator()) .task { viewModel.send(.onAppear) } } private var todoListContent: some View { ZStack { List { - Section { + Group { if viewModel.state.todos.isEmpty, !viewModel.state.isLoading { HStack { Spacer() @@ -123,7 +125,8 @@ struct TodoListView: View { } .listRowSeparator(.hidden) } else { - ForEach(viewModel.state.todos) { todo in + let todos = viewModel.state.todos + ForEach(Array(zip(todos.indices, todos)), id: \.1.id) { idx, todo in Button { router.push(Path.detail(todo.id)) } label: { @@ -131,6 +134,14 @@ struct TodoListView: View { } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) .alignmentGuide(.listRowSeparatorLeading) { _ in return 0 } + .overlay(alignment: .top) { + if #available(iOS 26.0, *) { + if idx == 0 { + Divider() + .padding(.horizontal, -16) + } + } + } .onAppear { let lastID = viewModel.state.todos.last?.id if todo.id == lastID, viewModel.state.hasMore { @@ -160,12 +171,32 @@ struct TodoListView: View { } } } - } header: { - headerView } .listRowBackground(Color.clear) + .listSectionSeparator(.hidden, edges: .top) } .listStyle(.plain) + .onScrollOffsetChange { offset in + guard isScrollTrackingEnabled else { return } + headerOffset = max(0, -offset) + } + .safeAreaInset(edge: .top) { + VStack(spacing: 4) { + headerView + if #unavailable(iOS 26) { + Divider() + .padding(.horizontal, -16) + } + } + .background { + if #available(iOS 26.0, *) { + Color.clear + } else { + Color(.systemBackground) + } + } + .offset(y: headerOffset) + } .refreshable { viewModel.send(.refresh) } .scrollDisabled(viewModel.state.todos.isEmpty || viewModel.state.isLoading) @@ -266,6 +297,16 @@ struct TodoListView: View { } } .scrollIndicators(.never) + .scrollDisabled(!isScrollTrackingEnabled) + .contentMargins(.leading, 16, for: .scrollContent) + .frame(height: 36) + .onAppear { + headerOffset = 0 + isScrollTrackingEnabled = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isScrollTrackingEnabled = true + } + } } private var sortMenu: some View { diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index 5440b417..07ae6d03 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -13,11 +13,13 @@ struct PushNotificationListView: View { @Environment(\.sceneWidth) private var sceneWidth @Environment(\.colorScheme) private var colorScheme @Environment(\.diContainer) private var container: DIContainer + @State private var headerOffset: CGFloat = 0 + @State private var isScrollTrackingEnabled = false var body: some View { NavigationStack(path: $router.path) { List { - Section { + Group { if viewModel.state.notifications.isEmpty { HStack { Spacer() @@ -27,11 +29,13 @@ struct PushNotificationListView: View { } .listRowSeparator(.hidden) } else { - ForEach(viewModel.state.notifications, id: \.id) { notification in + 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 { @@ -40,14 +44,45 @@ struct PushNotificationListView: View { 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) + } + } + } } } - } header: { - headerView } + .listSectionSeparator(.hidden, edges: .top) .listRowBackground(Color.clear) } .listStyle(.plain) + .background(NavigationBarConfigurator(.secondarySystemBackground)) + .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) + } + } + .offset(y: headerOffset) + } .background(Color(.secondarySystemBackground)) .onAppear { viewModel.send(.fetchNotifications) } .refreshable { viewModel.send(.fetchNotifications) } @@ -167,8 +202,18 @@ struct PushNotificationListView: View { .adaptiveButtonStyle(color: condition ? .blue : .clear) } } + .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 + } + } } private var filterBadge: some View {