Swift Digest

Calendar に recurrence rule を導入する

Recurrence rules in Calendar

Proposal
SF-0009
Authors
Hristo Staykov
Review Manager
Tina Liu
Status
Accepted

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

カレンダー上で「毎週月曜の朝」「毎月最終金曜日の 18:00」「旧暦の元日」のように繰り返し発生するイベントを扱うとき、その繰り返しのパターンを表現する仕組みを recurrence rule(繰り返しルール)と呼びます。Apple のエコシステムには、EventKit の EKRecurrenceRule や SiriKit の INRecurrenceRule といった既存 API がありましたが、いずれも特定のフレームワークに紐づいたものでした。

Calendar 自体を所有しているのは Foundation であり、recurrence rule の表現と、そのルールに従った日付列の列挙は、本来 Foundation 側で扱うのが自然です。一方、SF-0001 で追加された Calendar.dates(byMatching:...) は単一の DateComponents にマッチする日付を列挙するもので、「2 週間ごと」「毎月の第 1 土曜日または第 1 日曜日のうち先に来る方」「うるう年でない年は 2 月 29 日の代わりに 2 月 28 日にする」といった、繰り返しの間隔・複合条件・例外時の扱いまで含めた仕様を 1 つの値として表現することはできませんでした。

加えて、recurrence rule は iCalendar 仕様(RFC-5545、および非グレゴリオ暦への拡張である RFC-7529)の RRULE として広く使われており、カレンダーアプリ間でのデータのやりとりにも頻出する概念です。Foundation 側で RRULE のサブセットに対応した型を持っておくと、こうした標準形式との往復もしやすくなります。

02 どのように解決されるのか

繰り返しパターンを表す Calendar.RecurrenceRule 型と、そのルールから日付列を取り出すメソッド recurrences(of:in:)Calendar に追加されます。FoundationPreview 0.4 以降で利用できます。

Calendar.RecurrenceRule は RFC-5545 の RRULE のサブセットをモデル化したもので、EquatableCodableSendable に適合します。iCalendar 仕様で表現できる範囲のうち、秒単位での繰り返し以外を扱えます。

基本形

最低限指定するのは、基準となる Calendar と繰り返しの frequency.minutely / .hourly / .daily / .weekly / .monthly / .yearly)です。

var recurrence = Calendar.RecurrenceRule(calendar: .current, frequency: .daily)
for date in recurrence.recurrences(of: .now) {
    // 2024-02-09, 13:43
    // 2024-02-10, 13:43
    // 2024-02-11, 13:43
    // ...
}

recurrences(of:in:) の戻り値は some (Sequence<Date> & Sendable) なので、prefixzip などの Sequence 向けアルゴリズムとそのまま組み合わせられます。

繰り返しの間隔は interval で調整します。たとえば frequency = .weeklyinterval = 2 で「隔週」を表せます。

var recurrence = Calendar.RecurrenceRule(calendar: .current, frequency: .weekly)
recurrence.interval = 2
for date in recurrence.recurrences(of: .now) {
    // 2024-02-09, 13:43
    // 2024-02-23, 13:43
    // 2024-03-08, 13:43
}

開始日は recurrences(of:) に渡す Date で指定し、recurrence rule 自体は持ちません。これは「イベント本体の開始日とルールの開始日が二重に保持されて食い違う」ことを避けるための設計です。Event のようなモデルでは、開始日・所要時間・ルールをそれぞれ独立に持つことを想定しています。

終了条件

end プロパティで「いつ繰り返しを止めるか」を指定します。Calendar.RecurrenceRule.End の以下のいずれかを使います。

  • .afterOccurrences(_ count: Int): 最初の発生も含めて指定回数で打ち切ります。
  • .afterDate(_ date: Date): 指定日以降の発生を含めません。
  • .never: 無限に続きます(既定値)。
let recurrence = Calendar.RecurrenceRule(
    calendar: .current,
    frequency: .daily,
    end: .afterOccurrences(3)
)
for date in recurrence.recurrences(of: .now) {
    // 2024-02-09, 13:43
    // 2024-02-10, 13:43
    // 2024-02-11, 13:43
}

フィルタ用フィールド

seconds / minutes / hours / weekdays / daysOfTheMonth / daysOfTheYear / months / weeks の各プロパティで、繰り返しの中でさらに「いつマッチさせるか」を制御します。値を [] にすると、そのフィールドは未指定として扱われます。

これらのフィールドは、frequency との組み合わせによって、結果を絞り込む(limit) 動作になる場合と、繰り返し内でマッチを増やす(expand) 動作になる場合があります。たとえば .weekly 頻度で weekdays を指定すると、その週の中で指定した曜日すべてに展開されます。

var recurrence = Calendar.RecurrenceRule(calendar: .current, frequency: .weekly)
recurrence.weekdays = [.every(.tuesday), .every(.wednesday), .every(.thursday)]
for date in recurrence.recurrences(of: .now) {
    // 2024-02-13, 13:43 (火)
    // 2024-02-14, 13:43 (水)
    // 2024-02-15, 13:43 (木)
    // 2024-02-20, 13:43 (火)
    // ...
}

開始日が金曜だった場合、それ自体は条件にマッチしないため列に含まれません。一方、開始日の時・分・秒など、recurrence rule で上書きしていない小さい単位のコンポーネントはそのまま引き継がれます。

.yearly 頻度に対する months のように、フィールドが「絞り込み」ではなく「展開」として作用する組み合わせもあります。詳細な対応は RFC-5545 の規定に準じます。

曜日と月の指定

曜日は Weekday で表します。

  • .every(_ weekday: Locale.Weekday): 該当曜日すべて。
  • .nth(_ n: Int, _ weekday: Locale.Weekday): 月内(.monthly)または年内(.yearly)の n 番目のその曜日。n が負なら末尾から数えます。

たとえば「毎月の最終金曜日 18:00」は次のように書きます。

var recurrence = Calendar.RecurrenceRule(
    calendar: .current,
    frequency: .monthly,
    end: .afterOccurrences(5)
)
recurrence.weekdays = [.nth(-1, .friday)]
recurrence.hours = [18]
recurrence.minutes = [0]

月は Month 型で表現され、Int リテラルからも初期化できます。isLeap を持つので、ヒンドゥー暦などで現れる「うるう月」も区別できます。daysOfTheMonthdaysOfTheYear は負の値で「月末/年末から数えた日」を表せます。

旧暦の元日(中国暦の最初の月の 1 日目)を 5 回列挙する例です。

let lunarCalendar = Calendar(identifier: .chinese)
var recurrence = Calendar.RecurrenceRule(
    calendar: lunarCalendar,
    frequency: .yearly,
    end: .afterOccurrences(5)
)
recurrence.daysOfTheMonth = [1]
recurrence.months = [1]

setPositions による絞り込み

setPositions は、1 回の繰り返し区間内で展開された候補のうち何番目を採用するかを指定します。たとえば「毎月、最初の週末(土曜または日曜の早い方)」は次のように書けます。

var recurrence = Calendar.RecurrenceRule(calendar: .current, frequency: .monthly)
recurrence.weekdays = [.every(.saturday), .every(.sunday)]
recurrence.setPositions = [1]
for date in recurrence.recurrences(of: .now) {
    // 2024-03-01, 13:43
    // 2024-04-06, 13:43
    // ...
}

存在しない日時の扱い: matchingPolicy

繰り返しを展開した結果、Calendar 上に存在しない日時が現れることがあります。代表例はうるう日(2 月 29 日)と、夏時間(DST)開始時に飛ばされる時刻です。matchingPolicy でこの扱いを選べます(既定値は .nextTimePreservingSmallerComponents)。

  • .strict: 厳密一致がない場合はその回をスキップします。
  • .previousTimePreservingSmallerComponents: 同じ小コンポーネントを保ったまま、より前の時刻にずらします。
  • .nextTimePreservingSmallerComponents: 同じ小コンポーネントを保ったまま、より後ろの時刻にずらします。

たとえば 1996 年 2 月 29 日 14:00 を起点に、毎年 1 回繰り返すケースでは次のようになります。

let birthday: Date // 1996-02-29 14:00
var recurrence = Calendar.RecurrenceRule(calendar: .current, frequency: .yearly)

recurrence.matchingPolicy = .previousTimePreservingSmallerComponents
// 1996-02-29 14:00 / 1997-02-28 14:00 / 1998-02-28 14:00 / ... / 2000-02-29 14:00

recurrence.matchingPolicy = .nextTimePreservingSmallerComponents
// 1996-02-29 14:00 / 1997-03-01 14:00 / 1998-03-01 14:00 / ... / 2000-02-29 14:00

recurrence.matchingPolicy = .strict
// 1996-02-29 14:00 / 2000-02-29 14:00 / 2004-02-29 14:00 / ...

重複した時刻の扱い: repeatedTimePolicy

DST 終了のように、同じ時刻が 1 日に 2 回現れる日があります。repeatedTimePolicy はそのときに最初の出現だけを採用するか、両方を採用するかを切り替えます。既定値は .onlyFirst で、最初の出現だけを返します。

たとえば PDT から PST に戻る日に「毎日 1 時」を列挙すると、.onlyFirst では PDT 側の 01:00 だけが採用され、PST 側の 01:00(UTC で 1 時間後)は除かれます。

周波数ごとの簡略コンストラクタ

.minutely / .hourly / .daily / .weekly / .monthly / .yearly の各 frequency には、その頻度で使えるフィールドだけを引数に取る型メソッドが用意されています。たとえば Calendar.RecurrenceRule.weekly(calendar:interval:end:matchingPolicy:repeatedTimePolicy:months:weekdays:hours:minutes:seconds:setPositions:) のような形で、.weekly 頻度には意味を持たない daysOfTheMonth などが引数に出てこないようになっています。

既存 API との関係

これは Calendar への追加 API で、既存コードへの影響はありません。enumerateDates や SF-0001 で追加された dates(byMatching:...) も従来どおり利用できます。Calendar.RecurrenceRule で表現できる recurrence rule はすべて iCalendar の RRULE に変換できる範囲に収まっており、外部のカレンダーデータとの相互運用がしやすい設計です。