0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-01-16

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

作りたいもの

ジャーナル_カレンダー.gif

要件

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

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

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

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

頑張って自作します。

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

作ったもの

コード全体

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
    }
}

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

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

0
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?