Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion DevLog/UI/Common/Component/WebItemRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ struct WebItemRow: View {
VStack(alignment: .leading) {
Text(item.title)
.foregroundStyle(Color.primary)
.bold()
.multilineTextAlignment(.leading)
.lineLimit(2)
Text(item.displayURL)
Expand Down
31 changes: 31 additions & 0 deletions DevLog/UI/Common/NavigationBarConfigurator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions DevLog/UI/Home/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? "마감일 없음"
Expand Down Expand Up @@ -265,7 +264,6 @@ struct HomeView: View {
.foregroundStyle(Color.primary)
.font(.title2.bold())
Spacer()

}
.listRowInsets(EdgeInsets())
}
Expand Down
1 change: 0 additions & 1 deletion DevLog/UI/Home/TodoListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
Expand Down
240 changes: 128 additions & 112 deletions DevLog/UI/PushNotification/PushNotificationListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +34 to 36
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

0.3초 지연은 뷰가 나타날 때 초기 스크롤 위치가 잘못 계산되는 것을 막기 위한 것으로 보입니다. 이는 SwiftUI에서 종종 사용되는 해결책이지만, 특정 기기나 상황에서는 여전히 문제가 발생할 수 있습니다. 왜 이 지연이 필요한지에 대한 주석을 추가하여 코드의 의도를 명확히 하고, 향후 다른 개발자가 이 코드를 이해하는 데 도움을 주는 것이 좋겠습니다. 예를 들어, // 뷰 초기화 시 발생하는 불필요한 스크롤 이벤트 방지와 같은 주석을 추가할 수 있습니다.

.offset(y: headerOffset)
}
.background(Color(.secondarySystemBackground))
.onAppear { viewModel.send(.fetchNotifications) }
.refreshable { viewModel.send(.fetchNotifications) }
.navigationTitle("받은 푸시 알람")
.alert(
Expand Down Expand Up @@ -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)
Comment on lines +162 to +164
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

iOS 18 미만 버전에서 headerContentScrollView로 감싸여 있지 않아, 필터 버튼들이 많아질 경우 화면을 벗어나 보이지 않게 될 수 있습니다. 이는 이전 버전의 동작에서 벗어난 회귀(regression)로 보입니다. ScrollView로 감싸서 수평 스크롤을 지원해야 합니다.

                ScrollView(.horizontal) {
                    headerContent
                        .padding(.leading, 16)
                }
                .scrollIndicators(.never)
                .scrollDisabled(!isScrollTrackingEnabled)

}
}
}

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 {
Expand Down