오늘은 지금까지 수집한 데이터를 바탕으로 데이터 수집량에 관한 진행상황을 알 수 있는 주간 캘린더와 데이터를 시각화해 이해를 도울 수 있는 작업을 진행하였다.
먼저 캘린더 구현된 내용을 살펴보면 날짜를 1주일 단위로 보여주고 그 밑에 일자별로 데이터 수집량을 확인할 수 있도록 구현하였다.
컴포넌트화 하기 위해 수집량을 파악하는 Progress 뷰와 캘린더뷰를 별도로 구분했다.
CalendarView
먼저 캘린더뷰를 살펴보자
import SwiftUI
struct CalendarView: View {
@State private var selectedDate = Date()
@State private var showMonthPicker = false
private let calendar = Calendar.current
var body: some View {
VStack(alignment: .center, spacing: 20) {
monthView
ZStack {
dayView
blurView
}
.frame(height: 70)
}
}
private var monthView: some View {
HStack(spacing: 30) {
Button(
action: {
changeMonth(-1)
}, label: {
Image(systemName: "chevron.left")
.padding(.horizontal, 16)
.foregroundColor(.black)
}
)
Button(
action: {
showMonthPicker.toggle()
}, label: {
Text(monthTitle(from: selectedDate))
.font(.title3)
.foregroundColor(.black)
}
)
Button(
action: {
changeMonth(1)
}, label: {
Image(systemName: "chevron.right")
.padding(.horizontal, 16)
.foregroundColor(.black)
}
)
}
}
@ViewBuilder
private var dayView: some View {
let startDate = calendar.date(from: Calendar.current.dateComponents([.year, .month], from: selectedDate))!
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
let components = (
0..<calendar.range(of: .day, in: .month, for: startDate)!.count)
.map {
calendar.date(byAdding: .day, value: $0, to: startDate)!
}
ForEach(components, id: \\.self) { date in
VStack {
Text(day(from: date))
.font(.caption)
Text("\\(calendar.component(.day, from: date))")
.font(.subheadline)
CircularProgressView(progress: 0.4)
.frame(width: 30, height: 30)
}
.frame(width: 30, height: 70)
.padding(9)
.background(calendar.isDate(selectedDate, equalTo: date, toGranularity: .day) ? Color.green : Color.clear)
.cornerRadius(16)
.foregroundColor(calendar.isDate(selectedDate, equalTo: date, toGranularity: .day) ? .white : .black)
.onTapGesture {
selectedDate = date
}
}
}
}
}
private var blurView: some View {
HStack {
LinearGradient(
gradient: Gradient(
colors: [
Color(UIColor.systemGroupedBackground).opacity(1),
Color(UIColor.systemGroupedBackground).opacity(0)
]
),
startPoint: .leading,
endPoint: .trailing
)
.frame(width: 60)
.edgesIgnoringSafeArea(.leading)
Spacer()
LinearGradient(
gradient: Gradient(
colors: [
Color(UIColor.systemGroupedBackground).opacity(1),
Color(UIColor.systemGroupedBackground).opacity(0)
]
),
startPoint: .trailing,
endPoint: .leading
)
.frame(width: 60)
.edgesIgnoringSafeArea(.leading)
}
}
}
extension CalendarView {
func monthTitle(from date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.setLocalizedDateFormatFromTemplate("MMMM yyyy")
return dateFormatter.string(from: date)
}
func changeMonth(_ value: Int) {
guard let date = calendar.date(
byAdding: .month,
value: value,
to: selectedDate
) else {
return
}
selectedDate = date
}
func day(from date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.setLocalizedDateFormatFromTemplate("E")
return dateFormatter.string(from: date)
}
}
#Preview {
CalendarView()
}
캘린더 뷰 내 월을 담당하는 부분, 일을 담당하는 부분, 캘린더를 보다 자연스럽게 사용하기 위해 양 옆에 블러를 넣어 준 부분으로 나눠져 있다.
@State private var selectedDate = Date()
@State private var showMonthPicker = false
private let calendar = Calendar.current
프로퍼티를 설정한 내용은 현재 선택된 날짜를 저장하고, 월 선택을 위한 플래그를 넣어주었다. 그리고 날짜 계산에 사용하기 위한 Calendar.current를 넣어주었다.
private var monthView: some View {
HStack(spacing: 30) {
Button(action: { changeMonth(-1) }) {
Image(systemName: "chevron.left")
.padding(.horizontal, 16)
.foregroundColor(.black)
}
Button(action: { showMonthPicker.toggle() }) {
Text(monthTitle(from: selectedDate))
.font(.title3)
.foregroundColor(.black)
}
Button(action: { changeMonth(1) }) {
Image(systemName: "chevron.right")
.padding(.horizontal, 16)
.foregroundColor(.black)
}
}
}
Month 부분을 나타내는 부분을 살펴보면 먼저 버튼을 눌러 액션을 실행하는 changeMonth 부분이 정의가 되어야 하는데 가독성을 챙기기 위해 extension을 활용해 해당 부분은 구분해 주었다.
func changeMonth(_ value: Int) {
guard let date = calendar.date(
byAdding: .month,
value: value,
to: selectedDate
) else {
return
}
selectedDate = date
}
파라미터는 누른 횟수를 지정하고 버튼이 눌림에 따라 해당 Value가 변동되도록 만들었다.
func monthTitle(from date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.setLocalizedDateFormatFromTemplate("MMMM yyyy")
return dateFormatter.string(from: date)
}
그 다음 월을 나타내는 DateFormatter를 설정한 다음 해당 값을 현재 기준으로 예를들어 2024년 11월로 표기될 수 있도록 만들게 되면 캘린더의 상단 월을 나타내는 부분 구현이 완료 된다.
DayView
@ViewBuilder
private var dayView: some View {
let startDate = calendar.date(from: Calendar.current.dateComponents([.year, .month], from: selectedDate))!
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
let components = (0..<calendar.range(of: .day, in: .month, for: startDate)!.count)
.map { calendar.date(byAdding: .day, value: $0, to: startDate)! }
ForEach(components, id: \\.self) { date in
VStack {
Text(day(from: date))
.font(.caption)
Text("\\(calendar.component(.day, from: date))")
.font(.subheadline)
CircularProgressView(progress: 0.4) // 예시로 40% 설정
.frame(width: 30, height: 30)
}
.frame(width: 30, height: 70)
.padding(9)
.background(calendar.isDate(selectedDate, equalTo: date, toGranularity: .day) ? Color.green : Color.clear)
.cornerRadius(16)
.foregroundColor(calendar.isDate(selectedDate, equalTo: date, toGranularity: .day) ? .white : .black)
.onTapGesture {
selectedDate = date
}
}
}
}
}
다음엔 일자를 나타내는 부분을 살펴보자 먼저 가장 눈에 띄는 건 @ViewBuilder 가 보이는데 이 뷰 빌더를 넣어주는 이유는 SwiftUI의 View프로토콜과 관련이 있다.
ViewBuilder
SwiftUI에서 View는 단일 뷰를 반환해야한다. 하지만 @ViewBuilder 를 사용하게 되면 여러개의 뷰를 포함한 블록을 반환할 수 있게 된다. 이 경우 dayView 안에서 여러 VStack 을 조건에 따라 ForEach를 통해 반복적으로 렌더링하므로 뷰빌더가 필요하다!
또한 if문이나 ForEach문 등 특정 상황에 따라 뷰를 다르게 해야한다면, @ViewBuilder 가 없을 경우 컴파일 에러가 발생하게 된다. 예를 들어 날짜를 특정 조건에 따라 다르게 보여줄 때 @ViewBuilder가 없으면 여러 조건에서 반환 되는 뷰를 단일 뷰로 처리하지 못하게 된다는 것이다!
정리하자면 @ViewBuilder를 사용하면 SwiftUI에서 여러 개의 하위 뷰를 하나의 View로 간주하게 만들어줘서 SwiftUI의 조건부 렌더링과 반복문을 포함한 복잡한 뷰 계정 구조를 간단하게 작성할 수 있다. dayView에서 ForEach를 통해 여러 VStack을 반환하기 때문에 @ViewBuilder를 사용한 것!!!
let startDate = calendar.date(from: Calendar.current.dateComponents([.year, .month], from: selectedDate))!
다시 dayView로 넘어가서 날짜를 가로로 스크롤 할 수 있도록 selectedDate가 포함된 달의 첫번째 날짜를 의미하고 selectedDate 에서 연도와 월의 정보를 가져와 해당하는 월의 첫 번째 날짜를 계산한다.
calendar.range(of: .day, in: .month, for: startDate)!.count
components는 selectedDate의 월에 해당하는 날짜들을 배열로 생성한 것이다. 상기 코드로 현재 달의 일 수를 계산한다.
.map { calendar.date(byAdding: .day, value: $0, to: startDate)! }
상기 코드를 통해 선택된 값은 순차적으로 생성된다.
그 다음 ForEach를 활용해서 각 날짜를 순회해 날짜별 뷰를 생성한다. 각 날짜는 VStack을 사용해 요일과 일자를 표시한다.
이렇게 월, 일을 구현하게 되었고 그 다음 하단에 수집 데이터량을 확인할 수 있는 ProgressView를 만들어주면
import SwiftUI
struct CircularProgressView: View {
var progress: Double
var color: Color = .blue
var body: some View {
ZStack {
Circle()
.stroke(Color.gray.opacity(0.3), lineWidth: 4)
Circle()
.trim(from: 0, to: CGFloat(progress))
.stroke(color, lineWidth: 4)
.rotationEffect(.degrees(90))
}
}
}
이렇게 Circle을 활용해 안채워진 부분을 회색으로 처리하고 그 다음 채워지는 부분을 Progress 값을 바탕으로 그 값 만큼 색이 들어가 추가될 수 있도록 ZStack을 활용해 겹쳐서 표현한다.
이렇게 구현한 캘린더는 이와 같이 보여진다 🙂
상당히 완성도 있게 구현된 것 같아 아주 만족스럽다!!! 이 캘린더를 구현하기 위해 참고한 블로그는 하단에 남겨두기로 하고 다음 이시간에는 차트 구현한 내용에 대해서 다뤄보도록 하자!
* 참조 : https://green1229.tistory.com/369
오늘은 여기까지!!
'◽️ Programming > T I L' 카테고리의 다른 글
Let’s Swift 2024 방문 후기 🙂 (2) | 2024.11.26 |
---|---|
Device 잠금 상태 추적해 백그라운드 데이터 업로드 하기 (0) | 2024.11.07 |
Firebase RealtimeDatabase 내 데이터 저장하기 (0) | 2024.10.25 |
Equatable를 사용하면서 Extension 해야하는 상황 (0) | 2024.09.25 |
[Project 일지] Running App (1) (0) | 2024.08.30 |