From bdfb0230198fbd993d5af4cc8ba508a73fc0f7a3 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 7 Mar 2026 01:19:10 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20screenWidth=EC=99=80=20safeAreaInset?= =?UTF-8?q?=EC=9D=84=20=ED=86=B5=ED=95=B4=20width=EB=A5=BC=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=ED=9B=84=20=EC=88=98=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=85=80=20=ED=81=AC=EA=B8=B0=EB=A5=BC=20=EC=A0=95=EC=9D=98?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Profile/ProfileCompletionDay.swift | 2 +- .../Profile/ProfileCompletionQuarter.swift | 4 +- .../ViewModel/ProfileViewModel.swift | 2 +- DevLog/UI/Profile/ProfileHeatmapView.swift | 117 +++++++++++++----- 4 files changed, 88 insertions(+), 37 deletions(-) diff --git a/DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift b/DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift index ee6bd8fa..f33f4b87 100644 --- a/DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift +++ b/DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift @@ -11,5 +11,5 @@ struct ProfileCompletionDay: Hashable { let date: Date let createdCount: Int let completedCount: Int - let isInMonth: Bool + let isVisible: Bool } diff --git a/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift b/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift index 3fdd4405..67f3db61 100644 --- a/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift +++ b/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift @@ -20,7 +20,7 @@ struct ProfileCompletionQuarter: Identifiable, Hashable { months .flatMap { $0.weeks } .flatMap { $0 } - .filter { $0.isInMonth } + .filter { $0.isVisible } .map { $0.createdCount + $0.completedCount } .max() ?? 0 } @@ -32,7 +32,7 @@ struct ProfileCompletionQuarter: Identifiable, Hashable { let days = months .flatMap(\.weeks) .flatMap { $0 } - .filter(\.isInMonth) + .filter(\.isVisible) let groupedByWeekStart = Dictionary(grouping: days) { day in calendar.dateInterval(of: .weekOfYear, for: day.date)?.start ?? calendar.startOfDay(for: day.date) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 6bba3662..8c40c819 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -440,7 +440,7 @@ private extension ProfileViewModel { date: normalizedDate, createdCount: createdCount, completedCount: completedCount, - isInMonth: isInMonth + isVisible: isInMonth ) ) guard let nextDay = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } diff --git a/DevLog/UI/Profile/ProfileHeatmapView.swift b/DevLog/UI/Profile/ProfileHeatmapView.swift index 2296eb70..b868612b 100644 --- a/DevLog/UI/Profile/ProfileHeatmapView.swift +++ b/DevLog/UI/Profile/ProfileHeatmapView.swift @@ -8,30 +8,35 @@ import SwiftUI struct ProfileHeatmapView: View { + @Environment(\.safeAreaInsets) private var safeAreaInsets + @Environment(\.sceneWidth) private var sceneWidth let quarter: ProfileCompletionQuarter let selectedActivityTypes: Set let selectedDay: ProfileCompletionDay? let onSelectDay: (ProfileCompletionDay) -> Void var body: some View { + let layout = ProfileHeatmapLayout( + availableWidth: availableWidth, + weekCounts: quarter.months.map(\.weeks.count) + ) + VStack(alignment: .leading, spacing: 10) { Text("활동 히트맵") .font(.subheadline) .bold() HStack(alignment: .top, spacing: 0) { - weekdayLabel - .padding(.trailing, 10) - let months = quarter.months - ForEach(Array(zip(months.indices, months)), id: \.1) { index, month in - MonthCompactHeatmapView( - month: month, - maxCount: quarter.maxCount, - selectedActivityTypes: selectedActivityTypes, - selectedDay: selectedDay, - onSelectDay: onSelectDay - ) - if index < months.count - 1 { - Spacer() + weekdayLabel(layout: layout) + HStack(alignment: .top, spacing: layout.monthSpacing) { + ForEach(quarter.months) { month in + MonthCompactHeatmapView( + month: month, + maxCount: quarter.maxCount, + layout: layout, + selectedActivityTypes: selectedActivityTypes, + selectedDay: selectedDay, + onSelectDay: onSelectDay + ) } } } @@ -40,31 +45,78 @@ struct ProfileHeatmapView: View { } @ViewBuilder - private var weekdayLabel: some View { + private func weekdayLabel(layout: ProfileHeatmapLayout) -> some View { let labels: [Int: String] = [ 2: "월", 4: "수", 6: "금" ] let orderedWeekdays = Array(1...7) - let cellSize: CGFloat = 16 - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: layout.cellSpacing) { ForEach(orderedWeekdays, id: \.self) { weekday in Group { if let label = labels[weekday] { Text(label) .font(.caption2) .foregroundStyle(.secondary) - .frame(width: cellSize, height: cellSize) + .frame( + width: layout.cellSize, + height: layout.cellSize, + alignment: .leading + ) } else { Color.clear - .frame(width: cellSize, height: cellSize) + .frame( + width: layout.cellSize, + height: layout.cellSize + ) } } } } - .padding(.top, 22) + .padding(.top, layout.weekdayTopPadding) + } + + private var availableWidth: CGFloat { + let horizontalPadding: CGFloat = 16 + 12 + return max( + 0, + sceneWidth + - safeAreaInsets.leading + - safeAreaInsets.trailing + - (horizontalPadding * 2) + ) + } +} + +private struct ProfileHeatmapLayout { + let cellSize: CGFloat + let cellSpacing: CGFloat = 4 + let monthSpacing: CGFloat = 12 + let monthTitleSpacing: CGFloat = 6 + + init(availableWidth: CGFloat, weekCounts: [Int]) { + let sanitizedWeekCounts = weekCounts.filter { 0 < $0 } + let totalColumns = max(sanitizedWeekCounts.reduce(0, +), 1) + let totalColumnSpacings = sanitizedWeekCounts.reduce(0) { partialResult, count in + partialResult + max(count - 1, 0) + } + let fixedWidth = monthSpacing * CGFloat(max(sanitizedWeekCounts.count - 1, 0)) + + cellSpacing * CGFloat(totalColumnSpacings) + cellSize = max(0, availableWidth - fixedWidth) / CGFloat(totalColumns + 1) + } + + var weekdayTopPadding: CGFloat { + cellSize + monthTitleSpacing + } + + var cellCornerRadius: CGFloat { + max(2, cellSize * 0.2) + } + + var innerSelectionLineWidth: CGFloat { + max(1.2, cellSize * 0.12) } } @@ -72,42 +124,41 @@ private struct MonthCompactHeatmapView: View { @Environment(\.colorScheme) private var colorScheme let month: ProfileCompletionMonth let maxCount: Int + let layout: ProfileHeatmapLayout let selectedActivityTypes: Set let selectedDay: ProfileCompletionDay? let onSelectDay: (ProfileCompletionDay) -> Void private let orderedWeekdays = Array(1...7) - private let cellSize: CGFloat = 16 - private let cellSpacing: CGFloat = 4 var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: layout.monthTitleSpacing) { Text(month.monthStart.formatted(.dateTime.month(.abbreviated))) - .frame(height: cellSize) + .frame(height: layout.cellSize) .font(.caption) .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: cellSpacing) { + VStack(alignment: .leading, spacing: layout.cellSpacing) { ForEach(orderedWeekdays, id: \.self) { weekday in - HStack(spacing: cellSpacing) { + HStack(spacing: layout.cellSpacing) { ForEach(month.weeks.indices, id: \.self) { weekIndex in let day = month.weeks[weekIndex].first { Calendar.current.component(.weekday, from: $0.date) == weekday } - RoundedRectangle(cornerRadius: 3) + RoundedRectangle(cornerRadius: layout.cellCornerRadius) .fill(fillColor(for: day, with: maxCount)) - .overlay( - RoundedRectangle(cornerRadius: 3) - .stroke(selectionInnerBorderColor(for: day), lineWidth: 2) + .stroke( + selectionInnerBorderColor(for: day), + lineWidth: layout.innerSelectionLineWidth ) .overlay( - RoundedRectangle(cornerRadius: 4) + RoundedRectangle(cornerRadius: layout.cellCornerRadius + 1) .stroke(selectionOuterBorderColor(for: day), lineWidth: 0.8) .padding(-1) ) - .frame(width: cellSize, height: cellSize) + .frame(width: layout.cellSize, height: layout.cellSize) .onTapGesture { - if let day, day.isInMonth { + if let day, day.isVisible { onSelectDay(day) } } @@ -135,7 +186,7 @@ private struct MonthCompactHeatmapView: View { } private func fillColor(for day: ProfileCompletionDay?, with maxCount: Int) -> Color { - guard let day, day.isInMonth else { return .clear } + guard let day, day.isVisible else { return .clear } let count = dayCount(for: day) if count == 0 { return Color(.systemGray5) From 2010f89f233e36d756ecbffff0954069935dcc02 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 7 Mar 2026 10:22:59 +0900 Subject: [PATCH 2/2] =?UTF-8?q?style:=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Profile/ProfileHeatmapView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/DevLog/UI/Profile/ProfileHeatmapView.swift b/DevLog/UI/Profile/ProfileHeatmapView.swift index b868612b..174d8021 100644 --- a/DevLog/UI/Profile/ProfileHeatmapView.swift +++ b/DevLog/UI/Profile/ProfileHeatmapView.swift @@ -79,6 +79,7 @@ struct ProfileHeatmapView: View { } private var availableWidth: CGFloat { + // ProfileView의 바깥 가로 패딩(16)과 히트맵 카드 내부 패딩(12)을 합한 값 let horizontalPadding: CGFloat = 16 + 12 return max( 0,