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,120 @@ 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+ let horizontalPadding : CGFloat = 16 + 12
83+ return max (
84+ 0 ,
85+ sceneWidth
86+ - safeAreaInsets. leading
87+ - safeAreaInsets. trailing
88+ - ( horizontalPadding * 2 )
89+ )
90+ }
91+ }
92+
93+ private struct ProfileHeatmapLayout {
94+ let cellSize : CGFloat
95+ let cellSpacing : CGFloat = 4
96+ let monthSpacing : CGFloat = 12
97+ let monthTitleSpacing : CGFloat = 6
98+
99+ init ( availableWidth: CGFloat , weekCounts: [ Int ] ) {
100+ let sanitizedWeekCounts = weekCounts. filter { 0 < $0 }
101+ let totalColumns = max ( sanitizedWeekCounts. reduce ( 0 , + ) , 1 )
102+ let totalColumnSpacings = sanitizedWeekCounts. reduce ( 0 ) { partialResult, count in
103+ partialResult + max( count - 1 , 0 )
104+ }
105+ let fixedWidth = monthSpacing * CGFloat( max ( sanitizedWeekCounts. count - 1 , 0 ) )
106+ + cellSpacing * CGFloat( totalColumnSpacings)
107+ cellSize = max ( 0 , availableWidth - fixedWidth) / CGFloat( totalColumns + 1 )
108+ }
109+
110+ var weekdayTopPadding : CGFloat {
111+ cellSize + monthTitleSpacing
112+ }
113+
114+ var cellCornerRadius : CGFloat {
115+ max ( 2 , cellSize * 0.2 )
116+ }
117+
118+ var innerSelectionLineWidth : CGFloat {
119+ max ( 1.2 , cellSize * 0.12 )
68120 }
69121}
70122
71123private struct MonthCompactHeatmapView : View {
72124 @Environment ( \. colorScheme) private var colorScheme
73125 let month : ProfileCompletionMonth
74126 let maxCount : Int
127+ let layout : ProfileHeatmapLayout
75128 let selectedActivityTypes : Set < ProfileActivityType >
76129 let selectedDay : ProfileCompletionDay ?
77130 let onSelectDay : ( ProfileCompletionDay ) -> Void
78131 private let orderedWeekdays = Array ( 1 ... 7 )
79- private let cellSize : CGFloat = 16
80- private let cellSpacing : CGFloat = 4
81132
82133 var body : some View {
83- VStack ( alignment: . leading, spacing: 6 ) {
134+ VStack ( alignment: . leading, spacing: layout . monthTitleSpacing ) {
84135 Text ( month. monthStart. formatted ( . dateTime. month ( . abbreviated) ) )
85- . frame ( height: cellSize)
136+ . frame ( height: layout . cellSize)
86137 . font ( . caption)
87138 . foregroundStyle ( . secondary)
88139
89- VStack ( alignment: . leading, spacing: cellSpacing) {
140+ VStack ( alignment: . leading, spacing: layout . cellSpacing) {
90141 ForEach ( orderedWeekdays, id: \. self) { weekday in
91- HStack ( spacing: cellSpacing) {
142+ HStack ( spacing: layout . cellSpacing) {
92143 ForEach ( month. weeks. indices, id: \. self) { weekIndex in
93144 let day = month. weeks [ weekIndex] . first {
94145 Calendar . current. component ( . weekday, from: $0. date) == weekday
95146 }
96147
97- RoundedRectangle ( cornerRadius: 3 )
148+ RoundedRectangle ( cornerRadius: layout . cellCornerRadius )
98149 . fill ( fillColor ( for: day, with: maxCount) )
99- . overlay (
100- RoundedRectangle ( cornerRadius : 3 )
101- . stroke ( selectionInnerBorderColor ( for : day ) , lineWidth: 2 )
150+ . stroke (
151+ selectionInnerBorderColor ( for : day ) ,
152+ lineWidth: layout . innerSelectionLineWidth
102153 )
103154 . overlay (
104- RoundedRectangle ( cornerRadius: 4 )
155+ RoundedRectangle ( cornerRadius: layout . cellCornerRadius + 1 )
105156 . stroke ( selectionOuterBorderColor ( for: day) , lineWidth: 0.8 )
106157 . padding ( - 1 )
107158 )
108- . frame ( width: cellSize, height: cellSize)
159+ . frame ( width: layout . cellSize, height: layout . cellSize)
109160 . onTapGesture {
110- if let day, day. isInMonth {
161+ if let day, day. isVisible {
111162 onSelectDay ( day)
112163 }
113164 }
@@ -135,7 +186,7 @@ private struct MonthCompactHeatmapView: View {
135186 }
136187
137188 private func fillColor( for day: ProfileCompletionDay ? , with maxCount: Int ) -> Color {
138- guard let day, day. isInMonth else { return . clear }
189+ guard let day, day. isVisible else { return . clear }
139190 let count = dayCount ( for: day)
140191 if count == 0 {
141192 return Color ( . systemGray5)
0 commit comments