UICalendarViewの表示可能期間を変更するとクラッシュすることがある

# はじめに
SwiftUIでiOSアプリを開発しているときに、デコレーションしたり表示期間を設定できたりするカレンダーを手軽に表示したいと思い`UICalendarView`という標準のコンポーネントを見つけたので、使ってみました。

https://developer.apple.com/documentation/uikit/uicalendarview

iOS16から使用できるようになったみたいで、割と新顔さんのコンポーネントのようです。
こちらは`UI`とついているところからもわかるかもですが、`UIKit`のコンポーネントなのですが、こいつをSwiftUIで導入してみるという記事自体はわかりやすいのがたくさんあります。

https://www.fuwamaki.com/article/353

ただ今回躓いた、表示期間の設定周りでクラッシュするといった事象について、いろいろググってみたりしましたがなかなか記事がなかったので、今回はこの問題の対処にフォーカスした記事を書きました！

# 起きた事象

SwiftUI で`UICalendarView` を使用するとき、`availableDateRange`※1、`visibleDateComponents`※2を更新するとクラッシュする事象に遭遇しました。

※1 [availableDateRange](https://developer.apple.com/documentation/uikit/uicalendarview/availabledaterange)はカレンダーの表示可能期間を表現するプロペティです。
※2 [visibleDateComponents](https://developer.apple.com/documentation/uikit/uicalendarview/visibledatecomponents)はカレンダーを表示した時に表示する日を表現するプロパティです。

クラッシュした時のログが以下です。

```
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid state. Unable to find a lower bounds in range.'
```

以下がその問題のコードです。

```swift
struct CalendarView: UIViewRepresentable {

    @Environment(\.locale)
    private var locale

    private let initialDate: DateComponents
    private let interval: DateInterval

    init(initialDate: DateComponents,
         interval: DateInterval) {

        self.initialDate = initialDate
        self.interval = interval
    }

    func makeUIView(context: Context) -> UICalendarView {

        return UICalendarView()
    }

    // swiftlint:disable:next unused_parameter
    func updateUIView(_ uiView: UICalendarView, context: Context) {

        uiView.availableDateRange = interval
        uiView.visibleDateComponents = initialDate
    }
}
```

```swift
struct SampleView: View {

    @State var displayInterval: DateInterval = .init(start: .now, end: .now)
    var initialDisplayDate: DateComponents {

        return Calendar.current.dateComponents([.year, .month, .day],
                                               from: displayInterval.start)
    }

    var body: some View {
        VStack {
            Button {
                if displayInterval.start.timeIntervalSince1970 == .zero {
                    displayInterval = .init(start: .now, end: .now)
                }
                else {
                    displayInterval = .init(start: Date(timeIntervalSince1970: .zero), duration: .zero)
                }
            } label: {
                Text("Toggle Interval")
            }
            CalendarView(initialDate: initialDisplayDate, interval: displayInterval)
        }
    }
}
```

行っていることとしては、初期状態としてカレンダーには現在日のみ表示可能期間、表示日として設定し、
"Toggle Interval"を押下すると表示可能期間、表示日を"1970/1/1"としています。

![UICalendarViewクラッシュ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/c642dc88-0549-3f37-3ffa-750928e9c02c.gif)

# 原因
`availableDateRange`更新時に`visibleDateComponents`の値が更新後の`availableDateRange`の外になる値だとクラッシュしてしまうことがあるようです。

こちらは、`UICalendarView`を SwiftUI として使えるようにしたコンポーネントの OSS のコードより知った情報です。

https://github.com/AllanJuenemann/CalendarView

具体的には以下コメントをみての推測です。

```
    // Make sure the currently visible date components are within range before updating
	// availableDateRange. Otherwise, UICalendarView may throw an exception or behave
	// in an unexpected way due to internal inconsistencies.
```

日本語訳

> 更新する前に、現在表示されている日付コンポーネントが availableDateRange の範囲内にあることを確認してください
> そうしないと、UICalendarView が例外をスローするか、動作する可能性があります。
> 内部の不一致により、予期しない方法で発生します。

https://github.com/AllanJuenemann/CalendarView/blob/a0f913aff2bf63a808e60c9490fe034043613c5f/Sources/CalendarView/CalendarView.swift#L121

# 修正

原因と思われるものがわかったので、`availableDateRange`更新前に`visibleDateComponents`の値が更新後の`availableDateRange`の範囲外になる値の場合は、`visibleDateComponents`を更新後の`availableDateRange`の範囲内の値となるように更新するようにします。

```diff_swift
struct CalendarView: UIViewRepresentable {

    @Environment(\.locale)
    private var locale

    private let initialDate: DateComponents
    private let interval: DateInterval

    init(initialDate: DateComponents,
         interval: DateInterval) {

        self.initialDate = initialDate
        self.interval = interval
    }

    func makeUIView(context: Context) -> UICalendarView {

        return UICalendarView()
    }

    func updateUIView(_ uiView: UICalendarView, context: Context) {

-       uiView.availableDateRange = interval
-       uiView.visibleDateComponents = initialDate

+       // update available date range
+       if let visibleDate = calendar.date(from: uiView.visibleDateComponents),
+          !interval.contains(visibleDate) {
+
+           let newVisibleDate = visibleDate < interval.start ? interval.start : interval.end
+           uiView.visibleDateComponents = calendar.dateComponents([.year, .month], from: newVisibleDate)
+       }
+
+       uiView.availableDateRange = interval
+
+       // update visible date
+       if let selectVisibleDate = calendar.date(from: initialDate),
+          interval.contains(selectVisibleDate) {
+
+           uiView.setVisibleDateComponents(initialDate, animated: true)
+       }

        // update locale
        uiView.locale = locale
    }
}
```

ここでしれっと`visibleDateComponents`を更新するコードも修正しています。
`setVisibleDateComponents`で`visibleDateComponents`を更新すると、アニメーションを伴って表示する日付を変更できます。
また、クラッシュのリスクから保護するために、更新しようとしている`visibleDateComponents`の値が表示可能期間内に含まれるかを確認した上で更新しています。

```swift
// update visible date
      if let selectVisibleDate = calendar.date(from: initialDate),
          interval.contains(selectVisibleDate) {

           uiView.setVisibleDateComponents(initialDate, animated: true)
       }
```

# 修正後のコードで再確認

修正後のコードで再度実行してみます！

```
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Unable to set a visible month that is before the minimum or after the maximum date.'
```

クラッシュしました、、ただ、前回のクラッシュとは理由が違います。

# 原因 2

クラッシュのログを見る限り、更新した`visibleDateComponents`が現在設定されている`availableDateRange`の範囲外となっていることで怒られていそう。

# 最終的な修正

```diff_swift
struct CalendarView: UIViewRepresentable {

    @Environment(\.locale)
    private var locale

    private let initialDate: DateComponents
    private let interval: DateInterval

    init(initialDate: DateComponents,
         interval: DateInterval) {

        self.initialDate = initialDate
        self.interval = interval
    }

    func makeUIView(context: Context) -> UICalendarView {

        return UICalendarView()
    }

    func updateUIView(_ uiView: UICalendarView, context: Context) {

        // update available date range
        if let visibleDate = calendar.date(from: uiView.visibleDateComponents),
           !interval.contains(visibleDate) {

            let newVisibleDate = visibleDate < interval.start ? interval.start : interval.end

+           if !uiView.availableDateRange.contains(newVisibleDate) {
+
+               let start = min(visibleDate, newVisibleDate, uiView.availableDateRange.start)
+               let end = max(visibleDate, newVisibleDate, uiView.availableDateRange.end)
+               uiView.availableDateRange = .init(start: start, end: end)
+           }

            uiView.visibleDateComponents = calendar.dateComponents([.year, .month], from: newVisibleDate)
        }

        uiView.availableDateRange = interval

        // update visible date
        if let selectVisibleDate = calendar.date(from: initialDate),
           interval.contains(selectVisibleDate) {

            uiView.setVisibleDateComponents(initialDate, animated: true)
        }

        // update locale
        uiView.locale = locale
    }
}
```

以下コードを追加して、`visibleDateComponents`を更新する前に、
現在の`availableDateRange`の範囲に更新後の`visibleDateComponents`が含まれているか確認し、もし含まれていない場合は、
現在の`visibleDateComponents`から外れないかつ、更新後の`visibleDateComponents`に外れない`availableDateRange`に更新してから、`visibleDateComponents`を更新するようにします。

```swift
           if !uiView.availableDateRange.contains(newVisibleDate) {

               let start = min(visibleDate, newVisibleDate, uiView.availableDateRange.start)
               let end = max(visibleDate, newVisibleDate, uiView.availableDateRange.end)
               uiView.availableDateRange = .init(start: start, end: end)
           }
```

こうすることで無事`UICalendarView`の表示範囲の更新が安全に行えるようになりました。

# おわり
最終的にはややごちゃついた実装になってしまいましたが、クラッシュする問題自体は解消されました。
もっと良い方法を知っている方がいれば是非コメントにてご教授いただけますと幸いです！