
Swiftで月次カレンダーの表示アルゴリズム(2)
こちらの記事の続きになります。
前回は「レーン」を使って一週間の予定を振り分けることで、予定の期間が重複せずいい感じに表示することができることがわかりました。
実際の予定(EKEvent)をレーンに振り分けるには、もう一段の考慮が必要です。月単位の予定を持つCalendarMonthItem、その中で週単位に予定を振り分けたWeeklyEvents、週内の予定をレーン分けするためのLaneを定義し、以下のようなイメージで予定を管理することにします。

@Observable
final class CalendarMonthItem {
let startDate: Date // 対象月の初日
private struct WeeklyEvents {
struct Lane {
var events: [EKEvent?] = .init(repeating: nil, count: 7)
}
var lanes: [Lane] = []
}
private var weeklyEvents: [Int: WeeklyEvents] = [:]
}
次に、ある月の予定を振り分ける処理です。
今月のすべての予定を取得する
予定を週ごとに振り分ける
並べ替えながらレーンに振り分ける
を行っています。
前回のアルゴリズムで扱っていた日付データは週の中で閉じていたのですが、実際の予定はその前の週が予定開始日だったり、翌週が予定終了日だったりするので、そこを考慮した振り分けとレーン処理になっています。
final class CalendarMonthItem {
...
private func prepare() {
guard weeklyEvents.isEmpty else { return }
let startDateOfThisMonth = startDate
let startDateOfNextMonth = startDate.nextMonth().startOfMonth() // 来月の初日
// (1)今月のすべての予定を取得
let predicate = eventStore.predicateForEvents(
withStart: startDateOfThisMonth,
end: startDateOfNextMonth,
calendars: nil
)
let events = eventStore.events(matching: predicate)
// (2)予定を週ごとに振り分ける(週をまたぐ場合は複数振り分け)
var eventsByWeek: [Int: [EKEvent]] = [:]
let numberOfWeeksInMonth = startDate.numberOfWeeksInMonth() // 今月の週数
for event in events {
// 開始予定日が前月以前の場合、今月の初週(1)に含める
let start = event.startDate < startDateOfThisMonth ? 1 : event.startDate.weekOfMonth()
// 終了予定日が翌月以降の場合、今月の最終週に含める
let end = event.endDate >= startDateOfNextMonth ? numberOfWeeksInMonth : event.endDate.weekOfMonth()
for weekOfMonth in start...end {
if eventsByWeek[weekOfMonth] == nil {
eventsByWeek[weekOfMonth] = [event]
} else {
eventsByWeek[weekOfMonth]?.append(event)
}
}
}
// (3)レーンに振り分ける
for (weekOfMonth, events) in eventsByWeek {
// 開始予定日 > 終了予定日(降順) > タイトルの順にソート
let sortedEvents = events.sorted { $0.sortOrder < $1.sortOrder }
var weeklyEvents = WeeklyEvents()
var placed = false
for event in sortedEvents {
// 開始予定日が前月以前または前週の場合、週の初日(0)を開始日とする
let start = (
event.startDate < startDateOfThisMonth ||
event.startDate.weekOfMonth() < weekOfMonth
) ? 0 : event.startDate.weekday() - 1
// 終了予定日が翌月以降または翌週の場合、週の終わり(6)を終了日とする
let end = (
event.endDate >= startDateOfNextMonth ||
event.endDate.weekOfMonth() > weekOfMonth
) ? 6 : event.endDate.weekday() - 1
for i in 0..<weeklyEvents.lanes.count {
// レーンのstart...endに予定がなければ、予定を埋める
if weeklyEvents.lanes[i].events[start...end].allSatisfy({ $0 == nil }) {
weeklyEvents.lanes[i].events.fill(range: start...end, event: event)
placed = true
break
}
}
// 予定を埋められなかった場合、新たなレーンを追加
if !placed {
var lane = WeeklyEvents.Lane()
lane.events.fill(range: start...end, event: event)
weeklyEvents.lanes.append(lane)
}
}
self.weeklyEvents[weekOfMonth] = weeklyEvents
}
}
}
private extension EKEvent {
var sortOrder: (TimeInterval, TimeInterval, String) {
return (startDate.timeIntervalSince1970, -endDate.timeIntervalSince1970, title)
}
}
private extension Array where Element == EKEvent? {
mutating func fill(range: ClosedRange<Int>, event: Element) {
replaceSubrange(range, with: Array(repeating: event, count: range.count))
}
}
なおところどころ現れるDate型のメソッドはextensionで拡張したものですが、Calendar型から日付に関する情報を取得するためのシンタックス・シュガーです。
extension Date {
static let defaultCalendar: Calendar = .current
func components(calendar: Calendar = defaultCalendar) -> DateComponents {
return calendar.dateComponents(in: calendar.timeZone, from: self)
}
func year(calender: Calendar = defaultCalendar) -> Int {
return self.components(calendar: calender).year!
}
func month(calender: Calendar = defaultCalendar) -> Int {
return self.components(calendar: calender).month!
}
func day(calender: Calendar = defaultCalendar) -> Int {
return self.components(calendar: calender).day!
}
func hour(calender: Calendar = defaultCalendar) -> Int {
return self.components(calendar: calender).hour!
}
func minute(calender: Calendar = defaultCalendar) -> Int {
return self.components(calendar: calender).minute!
}
func second(calender: Calendar = defaultCalendar) -> Int {
return self.components(calendar: calender).second!
}
func weekday(calender: Calendar = defaultCalendar) -> Int {
return self.components(calendar: calender).weekday!
}
func weekdayOriginal(calender: Calendar = defaultCalendar) -> Int {
return self.components(calendar: calender).weekday! - calender.firstWeekday
}
func numberOfDaysInMonth(calender: Calendar = defaultCalendar) -> Int {
return calender.range(of: .day, in: .month, for: self)!.count
}
func numberOfWeeksInMonth(calender: Calendar = defaultCalendar) -> Int {
return calender.range(of: .weekOfMonth, in: .month, for: self)!.count
}
func weekOfMonth(calender: Calendar = defaultCalendar) -> Int {
return self.components(calendar: calender).weekOfMonth!
}
}
extension Date {
func startOfDay(calendar: Calendar = defaultCalendar) -> Date {
return calendar.startOfDay(for: self)
}
func startOfWeek(calendar: Calendar = defaultCalendar) -> Date {
let dateComponents = calendar.dateComponents([.weekOfYear, .yearForWeekOfYear], from: self)
return calendar.date(from: dateComponents)!
}
func startOfMonth(calendar: Calendar = defaultCalendar) -> Date {
return self
.replaced(day: 1, calendar: calendar)!
.startOfDay(calendar: calendar)
}
func nextDay(offsetBy offset: Int = 1, calendar: Calendar = defaultCalendar) -> Date {
return calendar.date(byAdding: .day, value: offset, to: self)!
}
func nextWeek(offsetBy offset: Int = 1, calendar: Calendar = defaultCalendar) -> Date {
return calendar.date(byAdding: .weekOfYear, value: offset, to: self)!
}
func nextMonth(offsetBy offset: Int = 1, calendar: Calendar = defaultCalendar) -> Date {
return calendar.date(byAdding: .month, value: offset, to: self)!
}
}
あとは指定した日の予定を返すメソッドを用意します。
final class CalendarMonthItem {
...
func events(at date: Date) -> [EKEvent?] {
prepare()
guard let weeklyEvents = weeklyEvents[date.weekOfMonth()] else { return [] }
return weeklyEvents.events(at: date)
}
private struct WeeklyEvents {
...
func events(at date: Date) -> [EKEvent?] {
let weekdayIndex = date.weekday() - 1
return lanes.reduce(into: []) { $0.append($1.events[weekdayIndex]) }
}
}
}
検証アプリで表示した結果がこちら!

レーンによって、予定の並びは良さそうですね!
いまはシンプルに日ごとにその日の予定を表示しているため、期間を持つ予定が「帰省、帰省、帰省…」とバラバラに表示されてしまいます。最後に表示の仕方を工夫して、理想の形に持っていきたいですね。

この続きは、また!