ジャーナルアプリのカレンダーを自作してみた

最近追加されたiOSの純正アプリを触ってると、いい感じのカレンダーがあったんですが、標準で用意されていないようなので再現を試みました。

# 作りたいもの
![ジャーナル_カレンダー.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643790/65ad10cc-d26e-32e2-31c1-ae380a686732.gif)

## 要件

1. 月選択ができる
2. 矢印で月を切り替えられる
3. 「今日」の色が変わっている
4. 指定の日の色を変更できる
（ジャーナルアプリでいうと日記を書いた日）

1〜3をだけでよければDatePickerでも良さそうです。

```swift
DatePicker("dateSelect", selection: $date)
     .datePickerStyle(.graphical)
```

https://developer.apple.com/documentation/swiftui/datepicker#Styling-date-pickers

ただしこれでは4が満たせません。

頑張って自作します。


`2024/1/18 追記`
コメントにてご指摘いただいたのですが、UIKitに用意されていました :bow_tone1:

https://developer.apple.com/documentation/uikit/uicalendarview


# 作ったもの
![](https://storage.googleapis.com/zenn-user-upload/e492d422c830-20250116.gif)
# コード全体

```swift
import Foundation
import SwiftUI

class DateData {
    let date: Date
    let isEnableColor: Bool
    
    init(date: Date, isEnableColor: Bool) {
        self.date = date
        self.isEnableColor = isEnableColor
    }
}

struct CustomCalendarView: View {
    @State private var currentDate = Date()
    @State private var days: [Date] = []
    @State private var isMonthSelecting: Bool = false
    let height: CGFloat = 320
    var body: some View {
        VStack {
            CustomCalendarHeaderView(
                isMonthSelecting: $isMonthSelecting,
                date: $currentDate
            )
            ZStack {
            // Viewを重ねてFlagで透明度を管理することで
            // どちらか一方だけが表示されるようにします
                CustomCalendarGridView(date: $currentDate, days: $days)
                    .frame(height: height)
                    .opacity(isMonthSelecting ? 0 : 1)
                UIKitDatePicker(date: $currentDate)
                    .frame(height: height, alignment: .center)
                    .opacity(isMonthSelecting ? 1 : 0)
            }
        }
        .padding()
        .onAppear {
            days = currentDate.calendarDisplayDays
        }
        .onChange(of: currentDate) {
            days = currentDate.calendarDisplayDays
        }
    }
}

private struct CustomCalendarHeaderView: View {
    @Binding var isMonthSelecting: Bool
    @Binding var date: Date
    
    var body: some View {
        HStack {
            Button {
                withAnimation {
                    isMonthSelecting.toggle()
                }
            } label: {
                HStack {
                    Text("\(date.toStringyyyyM())")
                        .bold()
                        .foregroundStyle(isMonthSelecting ? .blue : Color.black)
                    Image(systemName: "chevron.right")
                        .frame(width: 8, height: 8)
                        .rotationEffect(.degrees(isMonthSelecting ? 90 : 0))
                        .animation(.easeInOut, value: isMonthSelecting)
                        .bold()
                }
            }
            Spacer()
            Button {
                withAnimation {
                    date = Calendar.current.date(byAdding: .month, value: -1, to: date) ?? date
                }
            } label: {
                Image(systemName: "chevron.left")
                    .bold()
                    .padding(8)
            }
            Button {
                withAnimation {
                    date = Calendar.current.date(byAdding: .month, value: 1, to: date) ?? date
                }
            } label: {
                Image(systemName: "chevron.right")
                    .bold()
                    .padding(8)
            }
        }
    }
}
private struct CustomCalendarGridView: View {
    @Binding var date: Date
    @Binding var days: [Date]
    // サンプルデータ
    let dateDatas = [
        DateData(
            date: Date(timeIntervalSinceNow: -60 * 60 * 24),
            isEnableColor: true
        ),
        DateData(
            date: Date(timeIntervalSinceNow: -60 * 60 * 24 * 2),
            isEnableColor: true
        ),
        DateData(
            date: Date(timeIntervalSinceNow: -60 * 60 * 24 * 3),
            isEnableColor: true
        ),
        DateData(
            date: Date(timeIntervalSinceNow: -60 * 60 * 24 * 4),
            isEnableColor: true
        ),
        DateData(
            date: Date(timeIntervalSinceNow: -60 * 60 * 24 * 6),
            isEnableColor: true
        ),
        DateData(
            date: Date(timeIntervalSinceNow: -60 * 60 * 24 * 7),
            isEnableColor: true
        ),
        DateData(
            date: Date(timeIntervalSinceNow: -60 * 60 * 24 * 14),
            isEnableColor: true
        ),
        DateData(
            date: Date(timeIntervalSinceNow: -60 * 60 * 24 * 15),
            isEnableColor: true
        ),
    ]

    let now = Date()
    let daysOfWeek = Calendar.getWeekDates()
    let columns = Array(repeating: GridItem(.flexible(), spacing: 0), count: 7)
    
    var body: some View {
        VStack() {
            HStack {
                ForEach(daysOfWeek, id: \.self) { dayOfWeek in
                    Text(dayOfWeek.toStringWeekday())
                        .fontWeight(.light)
                        .font(.caption)
                        .frame(maxWidth: .infinity)
                }
            }
            LazyVGrid(columns: columns) {
                ForEach(days, id: \.self) { day in
                    let isCurrentMonth = day.monthInt == date.monthInt
                    let isEnableColor = dateDatas.first { dateData in
                        day.isSameDay(date: dateData.date)
                    }?.isEnableColor ?? false
                    let isPreviousDayEnableColor = dateDatas.first { dateData in
                        dateData.date.isSameDay(date: day.addingTimeInterval(-60 * 60 * 24))
                    }?.isEnableColor ?? false
                    let isNextDayEnableColor = dateDatas.first { dateData in
                        dateData.date.isSameDay(date: day.addingTimeInterval(60 * 60 * 24))
                    }?.isEnableColor ?? false
                    Text(day.toStringD())
                        .fontWeight(.bold)
                        .foregroundStyle(day.isSameDay(date: now) ? Color.red : isCurrentMonth ? .primary: .secondary)
                        .frame(maxWidth: .infinity, minHeight: 40)
                        .modifier(CalendarDateBackGroundShape(
                            isCompletedCurrentDate: isEnableColor,
                            isCopmletedPreviousDay: isPreviousDayEnableColor,
                            isCompletedNextDay: isNextDayEnableColor
                        ))
                }
            }
            Spacer()
        }
    }
}

private struct CalendarDateBackGroundShape: ViewModifier {
    let isCompletedCurrentDate: Bool
    let isCopmletedPreviousDay: Bool
    let isCompletedNextDay: Bool
    
    let completedColor = Color.blue
    
    func body(content: Content) -> some View {
        content
            .background(
                GeometryReader{ geometry in
                    let width = geometry.size.width
                    let height = geometry.size.height
                    let halfWidth = geometry.size.width / 2
                    ZStack {
                        Circle()
                            .foregroundStyle(
                                isCompletedCurrentDate
                                ? completedColor
                                : .clear
                            )
                        Rectangle()
                            .foregroundStyle(
                                isCompletedCurrentDate && isCopmletedPreviousDay
                                ? completedColor
                                : .clear
                            )
                            .frame(width: halfWidth, height: height)
                            .offset(x: -(halfWidth / 2))
                        Rectangle()
                            .foregroundStyle(
                                isCompletedCurrentDate && isCompletedNextDay
                                ? completedColor
                                : .clear
                            )
                            .frame(width: halfWidth, height: height)
                            .offset(x: halfWidth / 2)
                    }
                    .frame(width: width, height: height)
                }
            )
    }
}

#Preview {
    return CustomCalendarView()
}

struct CustomUIKitDatePicker: UIViewRepresentable {
    @Binding var date: Date

    func makeUIView(context: Context) -> UIDatePicker {
        let datePicker = UIDatePicker()
        datePicker.datePickerMode = .yearAndMonth
        datePicker.preferredDatePickerStyle = .wheels
        datePicker.addTarget(
            context.coordinator,
            action: #selector(Coordinator.dateChanged(_:)),
            for: .valueChanged
        )
        return datePicker
    }

    func updateUIView(_ uiView: UIDatePicker, context: Context) {
        uiView.date = date
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject {
        var parent: CustomUIKitDatePicker

        init(_ parent: CustomUIKitDatePicker) {
            self.parent = parent
        }

        @objc func dateChanged(_ sender: UIDatePicker) {
            parent.date = sender.date
        }
    }
}

extension Calendar {
    static func getWeekDates() -> [Date] {
        let calendar = Calendar.current
        let today = Date()
        let startOfWeek = calendar.date(
            from: calendar.dateComponents(
                [.yearForWeekOfYear, .weekOfYear],
                from: today
            )
        )!
        var weekDates: [Date] = []
        for dayOffset in 0..<7 {
            if let date = calendar.date(byAdding: .day, value: dayOffset, to: startOfWeek) {
                weekDates.append(date)
            }
        }
        return weekDates
    }
}

```

それっぽくはなりましたが、実際に使うにはもう少し見た目やコードを整える必要があるのでご参考程度に。

不備、間違い等あればご指摘願います。