最近追加されたiOSの純正アプリを触ってると、いい感じのカレンダーがあったんですが、標準で用意されていないようなので再現を試みました。
作りたいもの
要件
- 月選択ができる
- 矢印で月を切り替えられる
- 「今日」の色が変わっている
- 指定の日の色を変更できる
(ジャーナルアプリでいうと日記を書いた日)
1〜3をだけでよければDatePickerでも良さそうです。
DatePicker("dateSelect", selection: $date)
.datePickerStyle(.graphical)
ただしこれでは4が満たせません。
頑張って自作します。
2024/1/18 追記
コメントにてご指摘いただいたのですが、UIKitに用意されていました
作ったもの
コード全体
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
}
}
それっぽくはなりましたが、実際に使うにはもう少し見た目やコードを整える必要があるのでご参考程度に。
不備、間違い等あればご指摘願います。