Skip to content

Commit 417afa9

Browse files
authored
[#185] TodoDetailView에서 내비게이션타이틀의 라지 사이즈만큼 영역을 먹고 있는 현상을 해결한다 (#189)
* fix: 네비게이션 타이틀을 인라인 모드로 고정함으로서 여백 제거 * feat: iOS 18+에서 safeAreaInset을 활용한 헤더로 개선 * ui: 디폴트 파라미터 추가, .systemBackground로 변경 * fix: 내비게이션을 통해 기존 뷰로 돌아갔을 때 설정이 원복되지 않는 현상 해결 * feat: iOS 17에서도 스크롤 트래킹이 가능하도록 구현 * ui: PushNotificationListView에 safeAreaInset 버전 헤더 적용 * ui: LoadingView 오버레이 원복 * refactor: 동일 코드 메서드화
1 parent 3ac3c7a commit 417afa9

5 files changed

Lines changed: 254 additions & 9 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//
2+
// NavigationBarConfigurator.swift
3+
// DevLog
4+
//
5+
// Created by 최윤진 on 3/5/26.
6+
//
7+
8+
import SwiftUI
9+
10+
struct NavigationBarConfigurator: UIViewControllerRepresentable {
11+
private let backgroundColor: UIColor
12+
13+
init(_ backgroundColor: UIColor = .systemBackground) {
14+
self.backgroundColor = backgroundColor
15+
}
16+
17+
func makeCoordinator() -> Coordinator {
18+
Coordinator()
19+
}
20+
21+
func makeUIViewController(context: Context) -> UIViewController {
22+
UIViewController()
23+
}
24+
25+
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
26+
if #available(iOS 26, *) { return }
27+
DispatchQueue.main.async {
28+
guard let navigationBar = uiViewController.navigationController?.navigationBar else { return }
29+
let coordinator = context.coordinator
30+
if coordinator.originalShadowColor == nil {
31+
coordinator.originalShadowColor = navigationBar.standardAppearance.shadowColor
32+
coordinator.originalBackgroundColor = navigationBar.standardAppearance.backgroundColor
33+
}
34+
Self.applyAppearance(
35+
to: navigationBar,
36+
shadowColor: .clear,
37+
backgroundColor: self.backgroundColor
38+
)
39+
}
40+
}
41+
42+
static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) {
43+
if #available(iOS 26, *) { return }
44+
guard let navigationBar = uiViewController.navigationController?.navigationBar else { return }
45+
applyAppearance(
46+
to: navigationBar,
47+
shadowColor: coordinator.originalShadowColor,
48+
backgroundColor: coordinator.originalBackgroundColor
49+
)
50+
}
51+
52+
private static func applyAppearance(
53+
to navigationBar: UINavigationBar,
54+
shadowColor: UIColor?,
55+
backgroundColor: UIColor?
56+
) {
57+
let appearances = [
58+
navigationBar.standardAppearance,
59+
navigationBar.scrollEdgeAppearance,
60+
navigationBar.compactAppearance,
61+
navigationBar.compactScrollEdgeAppearance
62+
]
63+
64+
for appearance in appearances {
65+
appearance?.shadowColor = shadowColor
66+
appearance?.backgroundColor = backgroundColor
67+
}
68+
}
69+
70+
class Coordinator {
71+
var originalShadowColor: UIColor?
72+
var originalBackgroundColor: UIColor?
73+
}
74+
}

DevLog/UI/Extension/View+.swift

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,90 @@
77

88
import SwiftUI
99

10+
extension View {
11+
@ViewBuilder
12+
func onScrollOffsetChange(action: @escaping (CGFloat) -> Void) -> some View {
13+
if #available(iOS 18, *) {
14+
self.onScrollGeometryChange(for: CGFloat.self) { geo in
15+
geo.contentOffset.y + geo.contentInsets.top
16+
} action: { _, newOffset in
17+
action(newOffset)
18+
}
19+
} else {
20+
self.background(ScrollViewOffsetTracker(onChange: action))
21+
}
22+
}
23+
}
24+
25+
private struct ScrollViewOffsetTracker: UIViewRepresentable {
26+
var onChange: (CGFloat) -> Void
27+
28+
func makeCoordinator() -> Coordinator {
29+
Coordinator(onChange: onChange)
30+
}
31+
32+
func makeUIView(context: Context) -> UIView {
33+
let view = UIView()
34+
view.isHidden = true
35+
view.isUserInteractionEnabled = false
36+
DispatchQueue.main.async {
37+
guard let scrollView = Self.findScrollView(from: view) else { return }
38+
context.coordinator.observe(scrollView)
39+
}
40+
return view
41+
}
42+
43+
func updateUIView(_ uiView: UIView, context: Context) {}
44+
45+
private static func findScrollView(from view: UIView) -> UIScrollView? {
46+
var current = view.superview
47+
while let superview = current {
48+
if let scrollView = superview as? UIScrollView {
49+
return scrollView
50+
}
51+
for sibling in superview.subviews where sibling !== view {
52+
if let scrollView = findScrollViewInSubviews(of: sibling) {
53+
return scrollView
54+
}
55+
}
56+
current = superview.superview
57+
}
58+
return nil
59+
}
60+
61+
private static func findScrollViewInSubviews(of view: UIView) -> UIScrollView? {
62+
if let scrollView = view as? UIScrollView {
63+
return scrollView
64+
}
65+
for subview in view.subviews {
66+
if let scrollView = findScrollViewInSubviews(of: subview) {
67+
return scrollView
68+
}
69+
}
70+
return nil
71+
}
72+
73+
class Coordinator: NSObject {
74+
private var onChange: (CGFloat) -> Void
75+
private var observation: NSKeyValueObservation?
76+
77+
init(onChange: @escaping (CGFloat) -> Void) {
78+
self.onChange = onChange
79+
}
80+
81+
func observe(_ scrollView: UIScrollView) {
82+
observation = scrollView.observe(\.contentOffset, options: [.new]) { [weak self] scrollView, _ in
83+
let offset = scrollView.contentOffset.y + scrollView.adjustedContentInset.top
84+
self?.onChange(offset)
85+
}
86+
}
87+
88+
deinit {
89+
observation?.invalidate()
90+
}
91+
}
92+
}
93+
1094
extension View {
1195
@ViewBuilder
1296
func adaptiveButtonStyle(

DevLog/UI/Home/TodoDetailView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ struct TodoDetailView: View {
2323
}
2424
}
2525
.onAppear { viewModel.send(.onAppear) }
26+
.navigationBarTitleDisplayMode(.inline)
2627
.sheet(isPresented: Binding(
2728
get: { viewModel.state.showInfo },
2829
set: { viewModel.send(.setShowInfo($0)) }

DevLog/UI/Home/TodoListView.swift

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ struct TodoListView: View {
1212
@Environment(NavigationRouter.self) var router
1313
@Environment(\.diContainer) var container: DIContainer
1414
@Environment(\.colorScheme) private var colorScheme
15+
@State private var headerOffset: CGFloat = 0
16+
@State private var isScrollTrackingEnabled = false
1517

1618
var body: some View {
1719
Group {
@@ -106,14 +108,14 @@ struct TodoListView: View {
106108
}
107109
}
108110
}
109-
.toolbarBackground(.visible, for: .navigationBar)
111+
.background(NavigationBarConfigurator())
110112
.task { viewModel.send(.onAppear) }
111113
}
112114

113115
private var todoListContent: some View {
114116
ZStack {
115117
List {
116-
Section {
118+
Group {
117119
if viewModel.state.todos.isEmpty, !viewModel.state.isLoading {
118120
HStack {
119121
Spacer()
@@ -123,14 +125,23 @@ struct TodoListView: View {
123125
}
124126
.listRowSeparator(.hidden)
125127
} else {
126-
ForEach(viewModel.state.todos) { todo in
128+
let todos = viewModel.state.todos
129+
ForEach(Array(zip(todos.indices, todos)), id: \.1.id) { idx, todo in
127130
Button {
128131
router.push(Path.detail(todo.id))
129132
} label: {
130133
TodoItemRow(todo)
131134
}
132135
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
133136
.alignmentGuide(.listRowSeparatorLeading) { _ in return 0 }
137+
.overlay(alignment: .top) {
138+
if #available(iOS 26.0, *) {
139+
if idx == 0 {
140+
Divider()
141+
.padding(.horizontal, -16)
142+
}
143+
}
144+
}
134145
.onAppear {
135146
let lastID = viewModel.state.todos.last?.id
136147
if todo.id == lastID, viewModel.state.hasMore {
@@ -160,12 +171,32 @@ struct TodoListView: View {
160171
}
161172
}
162173
}
163-
} header: {
164-
headerView
165174
}
166175
.listRowBackground(Color.clear)
176+
.listSectionSeparator(.hidden, edges: .top)
167177
}
168178
.listStyle(.plain)
179+
.onScrollOffsetChange { offset in
180+
guard isScrollTrackingEnabled else { return }
181+
headerOffset = max(0, -offset)
182+
}
183+
.safeAreaInset(edge: .top) {
184+
VStack(spacing: 4) {
185+
headerView
186+
if #unavailable(iOS 26) {
187+
Divider()
188+
.padding(.horizontal, -16)
189+
}
190+
}
191+
.background {
192+
if #available(iOS 26.0, *) {
193+
Color.clear
194+
} else {
195+
Color(.systemBackground)
196+
}
197+
}
198+
.offset(y: headerOffset)
199+
}
169200
.refreshable { viewModel.send(.refresh) }
170201
.scrollDisabled(viewModel.state.todos.isEmpty || viewModel.state.isLoading)
171202

@@ -266,6 +297,16 @@ struct TodoListView: View {
266297
}
267298
}
268299
.scrollIndicators(.never)
300+
.scrollDisabled(!isScrollTrackingEnabled)
301+
.contentMargins(.leading, 16, for: .scrollContent)
302+
.frame(height: 36)
303+
.onAppear {
304+
headerOffset = 0
305+
isScrollTrackingEnabled = false
306+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
307+
isScrollTrackingEnabled = true
308+
}
309+
}
269310
}
270311

271312
private var sortMenu: some View {

DevLog/UI/PushNotification/PushNotificationListView.swift

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ struct PushNotificationListView: View {
1313
@Environment(\.sceneWidth) private var sceneWidth
1414
@Environment(\.colorScheme) private var colorScheme
1515
@Environment(\.diContainer) private var container: DIContainer
16+
@State private var headerOffset: CGFloat = 0
17+
@State private var isScrollTrackingEnabled = false
1618

1719
var body: some View {
1820
NavigationStack(path: $router.path) {
1921
List {
20-
Section {
22+
Group {
2123
if viewModel.state.notifications.isEmpty {
2224
HStack {
2325
Spacer()
@@ -27,11 +29,13 @@ struct PushNotificationListView: View {
2729
}
2830
.listRowSeparator(.hidden)
2931
} else {
30-
ForEach(viewModel.state.notifications, id: \.id) { notification in
32+
let notifications = viewModel.state.notifications
33+
ForEach(Array(zip(notifications.indices, notifications)), id: \.1.id) { idx, notification in
3134
Button {
3235
viewModel.send(.tapNotification(notification))
3336
} label: {
3437
notificationRow(notification)
38+
.padding(.vertical, 8)
3539
}
3640
.buttonStyle(.plain)
3741
.onAppear {
@@ -40,14 +44,45 @@ struct PushNotificationListView: View {
4044
viewModel.send(.loadNextPage)
4145
}
4246
}
47+
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
48+
.overlay(alignment: .top) {
49+
if #available(iOS 26.0, *) {
50+
if idx == 0 {
51+
Divider()
52+
.padding(.horizontal, -16)
53+
}
54+
}
55+
}
4356
}
4457
}
45-
} header: {
46-
headerView
4758
}
59+
.listSectionSeparator(.hidden, edges: .top)
4860
.listRowBackground(Color.clear)
4961
}
5062
.listStyle(.plain)
63+
.background(NavigationBarConfigurator(.secondarySystemBackground))
64+
.onScrollOffsetChange { offset in
65+
guard isScrollTrackingEnabled else { return }
66+
headerOffset = max(0, -offset)
67+
}
68+
.safeAreaInset(edge: .top) {
69+
VStack(spacing: 4) {
70+
headerView
71+
.clipped()
72+
if #unavailable(iOS 26) {
73+
Divider()
74+
.padding(.horizontal, -16)
75+
}
76+
}
77+
.background {
78+
if #available(iOS 26.0, *) {
79+
Color.clear
80+
} else {
81+
Color(.secondarySystemBackground)
82+
}
83+
}
84+
.offset(y: headerOffset)
85+
}
5186
.background(Color(.secondarySystemBackground))
5287
.onAppear { viewModel.send(.fetchNotifications) }
5388
.refreshable { viewModel.send(.fetchNotifications) }
@@ -167,8 +202,18 @@ struct PushNotificationListView: View {
167202
.adaptiveButtonStyle(color: condition ? .blue : .clear)
168203
}
169204
}
205+
.frame(height: 36)
170206
}
171207
.scrollIndicators(.never)
208+
.scrollDisabled(!isScrollTrackingEnabled)
209+
.contentMargins(.leading, 16, for: .scrollContent)
210+
.onAppear {
211+
headerOffset = 0
212+
isScrollTrackingEnabled = false
213+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
214+
isScrollTrackingEnabled = true
215+
}
216+
}
172217
}
173218

174219
private var filterBadge: some View {

0 commit comments

Comments
 (0)