0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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"としています。

UICalendarViewクラッシュ.gif

原因

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を更新するコードも修正しています。
setVisibleDateComponentsvisibleDateComponentsを更新すると、アニメーションを伴って表示する日付を変更できます。
また、クラッシュのリスクから保護するために、更新しようとしている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の表示範囲の更新が安全に行えるようになりました。

おわり

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?