1582年10月のカレンダーを正しく表示したい
これは株式会社TimeTree Advent Calendar 2024の24日目の記事です。
こんにちは。iOSエンジニアのgonseeです。
カレンダーアプリのアドベントカレンダーということで、せっかくなのでカレンダーにまつわることを書きたいと思います。
消えた10日間の謎
突然ですが、iPhoneの標準カレンダーアプリで1582年10月を表示してみてください。年間表示にして高速スクロールすれば意外とすぐにさかのぼれると思います。そこから10月の1ヶ月表示にするとこんなことになります。
10/4の次が10/15になっていて間の10日間が飛んでいます。バグのように見えますがそうではありません。実はこれはユリウス暦からグレゴリオ暦への移行に関係しています。
ユリウス暦とは、共和政ローマのユリウス・カエサルによって紀元前45年から実施された暦で、平年は1年を365日とし、4年に1度閏年を置いて366日とします。1年の平均日数は365.25日となります。
しかし実際の地球の公転周期から導かれる1年(1太陽年)は365.2422日であり、ユリウス暦の1年はそれより11分ほど長いことになります。わずかな違いですが、長い年月をかけるとちょっとずつ季節がずれてしまいます。
一方で、私たちが現在日常で使っているのはグレゴリオ暦です。 Foundation.Calendar
の gregorian
でもお馴染みですね。グレゴリオ暦は1582年、ローマ教皇グレゴリウス13世の命によってユリウス暦を改良したものです。
以下のルールにしたがって、400年に97回の閏年を置きます。
- 西暦年が4で割り切れる年は(原則として)閏年
- ただし、西暦年が100で割り切れる年は(原則として)平年
- ただし、西暦年が400で割り切れる年は必ず閏年
※こちらは13日目の記事「うるう年を考慮したお誕生日の判定方法」から引用させていただきました。
このルールによって1年の平均日数は
365 + 97/400 = 365.2425日
となり、1太陽年との誤差は約26秒とかなり小さくなりました。1日分ずれるのに3200年以上かかる計算になります。
1582年の時点で、ユリウス暦の太陽年との誤差の蓄積により約10日間のずれが発生していました。このずれを解消するためにグレゴリオ暦への改暦のタイミングである10月に10日分日付を進めたのです。
※1582年10月に実際にグレゴリオ暦への改暦を行なったのはヨーロッパの一部の地域だけで、その他の地域ではもっと後に行われています。
Foundation.Calendarの実装
グレゴリオ暦への移行前後でFoundation.Calendarはどのような挙動になるでしょうか。1582年10月4日の1日後を計算してみます。
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .gmt
let components = DateComponents(year: 1582, month: 10, day: 4)
let date = calendar.date(from: components)!
print(date)
// 1日後を計算する
let nextDate = calendar.date(byAdding: .day, value: 1, to: date)!
print(nextDate)
実行すると以下のようになります。
1582-10-04 00:00:00 +0000
1582-10-15 00:00:00 +0000
10月4日の次が15日なりました!
このことはObjective-C時代の「Date and Time Programming Guide」というドキュメントの「Historical Dates」の項に書いてあります。
NSCalendarは、1582年10月に起きたユリウス暦からグレゴリオ暦への移行をモデル化しています。この移行期間中に10日間が飛ばされました。これは、1582年10月15日が1582年10月4日の翌日として扱われることを意味します。
一部の国ではグレゴリオ暦が採用された時期がそれぞれ異なります。しかし、一貫性を保つため、移行はロケールに関係なく同じタイミングでモデル化されています。
TimeTreeはどうなってるの…?
この事実を知ったときにまず思ったのが、「TimeTreeで1582年10月を表示したらどうなっちゃうの…?」でした。
幸い(?)TimeTreeのiOSアプリではそこまで過去の日付を表示する想定で作られていないので、1900年より前を表示するためには1ヶ月ずつスワイプしていかなければならず、現実的ではありません。端末の時刻設定を操作すれば簡単にできるのでは?と思いましたが2001年より前にはさかのぼれないようでした。
そこで、コード上で細工をして無理やり1582年10月を表示してみます。結果がこちら。
クラッシュしなかった!まずはそこが大事。
そしてカレンダーをよく見てみると、4日の次は正しく15日が表示されています。しかし、24日の次に15日に戻っていて、ここからおかしくなっています。曜日もずれてしまっているので火曜日の後半部分が土曜日の青色に、水曜日の後半部分の色が日曜日の赤色になってしまっています。
何が起こっているのか、コードを追ってみました。
カレンダーを描画するにあたって、左上の日付から右に向かって0, 1, ..., 6、次の週も左から右に7, 8, ..., 13とインデックスを振ることとします。
このとき指定のインデックスに対応する DateComponents
を返すメソッドがあり、これを元にカレンダーのどの枠が何月何日になるかを決めています。単純化したコードを以下に示します。
let firstDateComponents = DateComponents(year: 1582, month: 10, day: 1)
func dateComponents(at index: Int) -> DateComponents {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .gmt
var components = firstDateComponents
// dayの値が月末を超えてもdate計算時に翌月以降として扱われます
components.day? += index
let date = calendar.date(from: components)!
return calendar.dateComponents([.year, .month, .day], from: date)
}
for i in 0 ... 34 {
print("i:\(i), \(dateComponents(at: i))")
}
index=0の日付 firstDateComponents
のdayに指定のindexを加えてDateを取得し、再びDateComponentsに戻すことで対応する年月日を得ています。
実行結果を見るとindexが14になったところで日付が巻き戻ってしまっているのがわかります。
i:11, year: 1582 month: 10 day: 22 isLeapMonth: false
i:12, year: 1582 month: 10 day: 23 isLeapMonth: false
i:13, year: 1582 month: 10 day: 24 isLeapMonth: false
i:14, year: 1582 month: 10 day: 15 isLeapMonth: false
i:15, year: 1582 month: 10 day: 16 isLeapMonth: false
i:16, year: 1582 month: 10 day: 17 isLeapMonth: false
indexが13までは、indexを加えたDateComponentsが DateComponents(year: 1582, month: 10, day: 14)
と、実際には存在しない日付になるので、正しく10/24に補正されるのに対して、indexが14からは DateComponents(year: 1582, month: 10, day: 15)
と、実際に存在する日付になってしまうので、10日間の補正が入らずに日付がずれていました。
これを修正するとしたら、以下のようにするとよさそうです。
let firstDateComponents = DateComponents(year: 1582, month: 10, day: 1)
func dateComponents(at index: Int) -> DateComponents {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .gmt
let firstDate = calendar.date(from: firstDateComponents)!
let date = calendar.date(byAdding: .day, value: index, to: firstDate)!
return calendar.dateComponents([.year, .month, .day], from: date)
}
Calendar
の date(byAdding:value:to:)
は10日間の補正を正しく考慮して計算してくれるので、ずれが解消できました。
i:11, year: 1582 month: 10 day: 22 isLeapMonth: false
i:12, year: 1582 month: 10 day: 23 isLeapMonth: false
i:13, year: 1582 month: 10 day: 24 isLeapMonth: false
i:14, year: 1582 month: 10 day: 25 isLeapMonth: false
i:15, year: 1582 month: 10 day: 26 isLeapMonth: false
i:16, year: 1582 month: 10 day: 27 isLeapMonth: false
これでカレンダーが正しく表示できるか試してみます。
惜しい!日付の並びは合っていますが、2週間分余計に表示されてしまっています。コードを追ってみると、どうやら月の日数の計算がおかしそうです。
先ほどと同様に、問題になっている箇所の単純化したコードを示します。
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .gmt
let firstDateComponents = DateComponents(year: 1582, month: 10, day: 1)
let firstDate = calendar.date(from: firstDateComponents)!
let range = calendar.range(of: .day, in: .month, for: firstDate)!
let numberOfDays = range.count
print(numberOfDays)
月の日数を計算するのにCalendarのrange(of:in:for:)を使っています。Discussionに以下のように書いてあります。
このメソッドを使用すると、例えば、指定した日付が属する月において「日」コンポーネントが取り得る範囲を計算することができます。
通常の10月であれば Range<Int>
の値 1..<32
が返ってきて、countから31日間ということがわかります。1582年10月もrangeは同じ 1..<32
を返します。確かに月末と月初の日付は変わらないのでこの挙動は正しそうです。しかし同様にcountを日数としてしまうと、間に10日間飛ばされているという情報がないので、実際とは異なる値になってしまいます。
別のやり方で月の日数を計算することを考えます。月の最初の日付と、次の月の最初の日付を求めて、その間の日数を計算するという方法はどうでしょうか。
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .gmt
let firstDateComponents = DateComponents(year: 1582, month: 10, day: 1)
var lastDateComponents = firstDateComponents
// 12月の場合 month += 1 で13月になってしまいますが、
// calendarで計算するときに適切に翌年1月として扱われます
lastDateComponents.month? += 1
// 2つのDateComponentsから期間を計算するメソッドはNSCalendarにしかないのでブリッジしています。
// 2つのDateから期間を計算するメソッドであればCalendarから使えます。
let numberOfDays = (calendar as NSCalendar).components(
.day, from: firstDateComponents, to: lastDateComponents
).day!
print(numberOfDays)
実行すると正しく 21
が得られました!
カレンダーを表示してみます。
無事、1582年10月のカレンダーを正しく表示できました!
まとめ
日付や時刻に関わる開発では知らないと思わぬ落とし穴にはまることがあります。さすがに1582年のカレンダーを表示しなければならない要件はなかなかないと思いますが、システム上で簡単に年をさかのぼることができてしまうと意図しない挙動が発生するかもしれません。さかのぼれるのは何年までと制限をかけておくのがよさそうです。
今回の記事に興味を持っていただいた方はぜひ、昨年のアドベントカレンダーで書いた「カレンダー開発怖い話」シリーズも合わせてチェックしてみてください。
参考リンク
TimeTreeのエンジニアによる記事です。メンバーのインタビューはこちらで発信中! note.com/timetree_inc/m/m4735531db852
Discussion