はじめに
SwiftUIでiOSアプリを開発しているときに、デコレーションしたり表示期間を設定できたりするカレンダーを手軽に表示したいと思いUICalendarView
という標準のコンポーネントを見つけたので、使ってみました。
iOS16から使用できるようになったみたいで、割と新顔さんのコンポーネントのようです。
こちらはUI
とついているところからもわかるかもですが、UIKit
のコンポーネントなのですが、こいつをSwiftUIで導入してみるという記事自体はわかりやすいのがたくさんあります。
ただ今回躓いた、表示期間の設定周りでクラッシュするといった事象について、いろいろググってみたりしましたがなかなか記事がなかったので、今回はこの問題の対処にフォーカスした記事を書きました!
起きた事象
SwiftUI でUICalendarView
を使用するとき、availableDateRange
※1、visibleDateComponents
※2を更新するとクラッシュする事象に遭遇しました。
※1 availableDateRangeはカレンダーの表示可能期間を表現するプロペティです。
※2 visibleDateComponentsはカレンダーを表示した時に表示する日を表現するプロパティです。
クラッシュした時のログが以下です。
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid state. Unable to find a lower bounds in range.'
以下がその問題のコードです。
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
}
}
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"としています。
原因
availableDateRange
更新時にvisibleDateComponents
の値が更新後のavailableDateRange
の外になる値だとクラッシュしてしまうことがあるようです。
こちらは、UICalendarView
を SwiftUI として使えるようにしたコンポーネントの OSS のコードより知った情報です。
具体的には以下コメントをみての推測です。
// 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 が例外をスローするか、動作する可能性があります。
内部の不一致により、予期しない方法で発生します。
修正
原因と思われるものがわかったので、availableDateRange
更新前にvisibleDateComponents
の値が更新後のavailableDateRange
の範囲外になる値の場合は、visibleDateComponents
を更新後のavailableDateRange
の範囲内の値となるように更新するようにします。
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
の値が表示可能期間内に含まれるかを確認した上で更新しています。
// 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
の範囲外となっていることで怒られていそう。
最終的な修正
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
を更新するようにします。
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
の表示範囲の更新が安全に行えるようになりました。
おわり
最終的にはややごちゃついた実装になってしまいましたが、クラッシュする問題自体は解消されました。
もっと良い方法を知っている方がいれば是非コメントにてご教授いただけますと幸いです!