Skip to content
Merged
74 changes: 74 additions & 0 deletions DevLog/UI/Common/NavigationBarConfigurator.swift
Original file line number Diff line number Diff line change
@@ -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?
}
}
84 changes: 84 additions & 0 deletions DevLog/UI/Extension/View+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions DevLog/UI/Home/TodoDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
Expand Down
51 changes: 46 additions & 5 deletions DevLog/UI/Home/TodoListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -123,14 +125,23 @@ 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: {
TodoItemRow(todo)
}
.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 {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down
53 changes: 49 additions & 4 deletions DevLog/UI/PushNotification/PushNotificationListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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) }
Expand Down Expand Up @@ -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 {
Expand Down