88import SwiftUI
99
1010struct ProfileHeatmapView : View {
11+ @Environment ( \. safeAreaInsets) private var safeAreaInsets
12+ @Environment ( \. sceneWidth) private var sceneWidth
1113 let quarter : ProfileCompletionQuarter
1214 let selectedActivityTypes : Set < ProfileActivityType >
1315 let selectedDay : ProfileCompletionDay ?
1416 let onSelectDay : ( ProfileCompletionDay ) -> Void
1517
1618 var body : some View {
19+ let layout = ProfileHeatmapLayout (
20+ availableWidth: availableWidth,
21+ weekCounts: quarter. months. map ( \. weeks. count)
22+ )
23+
1724 VStack ( alignment: . leading, spacing: 10 ) {
1825 Text ( " 활동 히트맵 " )
1926 . font ( . subheadline)
2027 . bold ( )
2128 HStack ( alignment: . top, spacing: 0 ) {
22- weekdayLabel
23- . padding ( . trailing, 10 )
24- let months = quarter. months
25- ForEach ( Array ( zip ( months. indices, months) ) , id: \. 1 ) { index, month in
26- MonthCompactHeatmapView (
27- month: month,
28- maxCount: quarter. maxCount,
29- selectedActivityTypes: selectedActivityTypes,
30- selectedDay: selectedDay,
31- onSelectDay: onSelectDay
32- )
33- if index < months. count - 1 {
34- Spacer ( )
29+ weekdayLabel ( layout: layout)
30+ HStack ( alignment: . top, spacing: layout. monthSpacing) {
31+ ForEach ( quarter. months) { month in
32+ MonthCompactHeatmapView (
33+ month: month,
34+ maxCount: quarter. maxCount,
35+ layout: layout,
36+ selectedActivityTypes: selectedActivityTypes,
37+ selectedDay: selectedDay,
38+ onSelectDay: onSelectDay
39+ )
3540 }
3641 }
3742 }
@@ -40,74 +45,121 @@ struct ProfileHeatmapView: View {
4045 }
4146
4247 @ViewBuilder
43- private var weekdayLabel : some View {
48+ private func weekdayLabel( layout : ProfileHeatmapLayout ) -> some View {
4449 let labels : [ Int : String ] = [
4550 2 : " 월 " ,
4651 4 : " 수 " ,
4752 6 : " 금 "
4853 ]
4954 let orderedWeekdays = Array ( 1 ... 7 )
50- let cellSize : CGFloat = 16
5155
52- VStack ( alignment: . leading, spacing: 4 ) {
56+ VStack ( alignment: . leading, spacing: layout . cellSpacing ) {
5357 ForEach ( orderedWeekdays, id: \. self) { weekday in
5458 Group {
5559 if let label = labels [ weekday] {
5660 Text ( label)
5761 . font ( . caption2)
5862 . foregroundStyle ( . secondary)
59- . frame ( width: cellSize, height: cellSize)
63+ . frame (
64+ width: layout. cellSize,
65+ height: layout. cellSize,
66+ alignment: . leading
67+ )
6068 } else {
6169 Color . clear
62- . frame ( width: cellSize, height: cellSize)
70+ . frame (
71+ width: layout. cellSize,
72+ height: layout. cellSize
73+ )
6374 }
6475 }
6576 }
6677 }
67- . padding ( . top, 22 )
78+ . padding ( . top, layout. weekdayTopPadding)
79+ }
80+
81+ private var availableWidth : CGFloat {
82+ // ProfileView의 바깥 가로 패딩(16)과 히트맵 카드 내부 패딩(12)을 합한 값
83+ let horizontalPadding : CGFloat = 16 + 12
84+ return max (
85+ 0 ,
86+ sceneWidth
87+ - safeAreaInsets. leading
88+ - safeAreaInsets. trailing
89+ - ( horizontalPadding * 2 )
90+ )
91+ }
92+ }
93+
94+ private struct ProfileHeatmapLayout {
95+ let cellSize : CGFloat
96+ let cellSpacing : CGFloat = 4
97+ let monthSpacing : CGFloat = 12
98+ let monthTitleSpacing : CGFloat = 6
99+
100+ init ( availableWidth: CGFloat , weekCounts: [ Int ] ) {
101+ let sanitizedWeekCounts = weekCounts. filter { 0 < $0 }
102+ let totalColumns = max ( sanitizedWeekCounts. reduce ( 0 , + ) , 1 )
103+ let totalColumnSpacings = sanitizedWeekCounts. reduce ( 0 ) { partialResult, count in
104+ partialResult + max( count - 1 , 0 )
105+ }
106+ let fixedWidth = monthSpacing * CGFloat( max ( sanitizedWeekCounts. count - 1 , 0 ) )
107+ + cellSpacing * CGFloat( totalColumnSpacings)
108+ cellSize = max ( 0 , availableWidth - fixedWidth) / CGFloat( totalColumns + 1 )
109+ }
110+
111+ var weekdayTopPadding : CGFloat {
112+ cellSize + monthTitleSpacing
113+ }
114+
115+ var cellCornerRadius : CGFloat {
116+ max ( 2 , cellSize * 0.2 )
117+ }
118+
119+ var innerSelectionLineWidth : CGFloat {
120+ max ( 1.2 , cellSize * 0.12 )
68121 }
69122}
70123
71124private struct MonthCompactHeatmapView : View {
72125 @Environment ( \. colorScheme) private var colorScheme
73126 let month : ProfileCompletionMonth
74127 let maxCount : Int
128+ let layout : ProfileHeatmapLayout
75129 let selectedActivityTypes : Set < ProfileActivityType >
76130 let selectedDay : ProfileCompletionDay ?
77131 let onSelectDay : ( ProfileCompletionDay ) -> Void
78132 private let orderedWeekdays = Array ( 1 ... 7 )
79- private let cellSize : CGFloat = 16
80- private let cellSpacing : CGFloat = 4
81133
82134 var body : some View {
83- VStack ( alignment: . leading, spacing: 6 ) {
135+ VStack ( alignment: . leading, spacing: layout . monthTitleSpacing ) {
84136 Text ( month. monthStart. formatted ( . dateTime. month ( . abbreviated) ) )
85- . frame ( height: cellSize)
137+ . frame ( height: layout . cellSize)
86138 . font ( . caption)
87139 . foregroundStyle ( . secondary)
88140
89- VStack ( alignment: . leading, spacing: cellSpacing) {
141+ VStack ( alignment: . leading, spacing: layout . cellSpacing) {
90142 ForEach ( orderedWeekdays, id: \. self) { weekday in
91- HStack ( spacing: cellSpacing) {
143+ HStack ( spacing: layout . cellSpacing) {
92144 ForEach ( month. weeks. indices, id: \. self) { weekIndex in
93145 let day = month. weeks [ weekIndex] . first {
94146 Calendar . current. component ( . weekday, from: $0. date) == weekday
95147 }
96148
97- RoundedRectangle ( cornerRadius: 3 )
149+ RoundedRectangle ( cornerRadius: layout . cellCornerRadius )
98150 . fill ( fillColor ( for: day, with: maxCount) )
99- . overlay (
100- RoundedRectangle ( cornerRadius : 3 )
101- . stroke ( selectionInnerBorderColor ( for : day ) , lineWidth: 2 )
151+ . stroke (
152+ selectionInnerBorderColor ( for : day ) ,
153+ lineWidth: layout . innerSelectionLineWidth
102154 )
103155 . overlay (
104- RoundedRectangle ( cornerRadius: 4 )
156+ RoundedRectangle ( cornerRadius: layout . cellCornerRadius + 1 )
105157 . stroke ( selectionOuterBorderColor ( for: day) , lineWidth: 0.8 )
106158 . padding ( - 1 )
107159 )
108- . frame ( width: cellSize, height: cellSize)
160+ . frame ( width: layout . cellSize, height: layout . cellSize)
109161 . onTapGesture {
110- if let day, day. isInMonth {
162+ if let day, day. isVisible {
111163 onSelectDay ( day)
112164 }
113165 }
@@ -135,7 +187,7 @@ private struct MonthCompactHeatmapView: View {
135187 }
136188
137189 private func fillColor( for day: ProfileCompletionDay ? , with maxCount: Int ) -> Color {
138- guard let day, day. isInMonth else { return . clear }
190+ guard let day, day. isVisible else { return . clear }
139191 let count = dayCount ( for: day)
140192 if count == 0 {
141193 return Color ( . systemGray5)
0 commit comments