カレンダーアプリ開発(iPhone版)⑤ – 月の変更とセルタップアクション

投稿日:

更新日:

カテゴリ:

サンプルアプリにしては、カレンダーアプリは少し難しい選定だったかもしれませんと今になって思いましたが、まあ、頑張りましょう!

ここの要点は2つです。

  1. 1ヶ月単位のビューを想定し、左右には無限にスワイプできるようにしたい
  2. とある日を選択したら、該当の日のイベントの登録画面を表示したい

クラスの概要

まず主要なクラスの紹介です。

InfinitePageView

  • InfinitePageView は、TabView をラップし、無限にスワイプできるページビューを作成します。
  • ページのビューを作成するための view クロージャーを提供します。
  • beforeafter クロージャーは、現在の選択を基に前後の選択を提供します。
  • selection バインディングは、選択されたアイテムを保持します。

MonthCell

  • MonthCell は、カレンダービュー内の個々の日付セルを描画します。
  • セルの背景色や表示する日付、イベントラベルを決定します。
  • セルがタップされたときに、選択された日付を calendarDate に保存し、関連するUUIDを更新します。

MonthView

  • MonthView は、カレンダービューの月を表し、 MonthCell を表示します。
  • カレンダーの月ごとに日付セルをレイアウトします。
  • dateOfIndex メソッドは、グリッドの特定のインデックスに対応する日付を計算します。

ContentView

  • ContentView は、ナビゲーションバーを含むアプリのメインビューです。
  • InfinitePageView を含み、それぞれのページに MonthView を配置します。
  • ナビゲーションバーやシート表示(sheet)のためのビューを提供し、選択した日付に関連するイベントフォームを表示します。
  • ページのインデックスや選択された日付の変更に応じて、他のビューやデータを更新します。

無限スワイプできるページビュー

元ネタはこちらの「Bidirectional infinite PageView in SwiftUI」です。

オリジンでは1→2→3→1→2→3→…のようにぐるぐるする動きが、インデントを丸めずに無限に過去・未来の日付を表示するに使います。

サンプルコードの「SwiftUIView」を動かしてみていただければお分かりかと思いますが、まずカレンダーを置いといて、無限スワイプできるページビューは次のようになります。

 

これをカレンダービューに当てはめると次のようになります。

上記を実現するためには、主にContentViewで以下のように実装しています。ポイントを#1~#6を挙げて少し解説します。

        VStack {
          InfinitePageView(
            selection: $pageIndex,
            before: { $0 - 1 }, #1
            after: { $0 + 1 },
            view: { index in
              let targetDate = Calendar.current.date(byAdding: .month, value: index, to: currentDate) ?? Date() #2
              MonthView(currentDate: targetDate)
                .id(index)
                // セルのタップイベント
                // 同じセルタップしても動作するようにUUIDを利用
                .onChange(of: calendarDate.uuid) { #3
                  if (index != pageIndex) {
                    return
                  }
                  print("page index: \(pageIndex)")
                  print("selectedDate: \(calendarDate.selectedDate.dateTimeString())")
                  isPresented.toggle() #4
                }
                // スワイプしたらヘッダの表示が当月に変わるよう
                .onChange(of: pageIndex, { oldValue, newValue in
                  if (index != pageIndex) {
                    return
                  }
                  calendarDate.lastSelectedYearMonth = targetDate.yearMonth() #5
                  calendarDate.showingDate = targetDate #6
                }
              )
            }
          )
        }
  1. $0は一番目の引数です。今回の場合はview: { index inとあるように、一番目の引数はindexと思ってもらえばいいと思います。
    前のページの場合、indexを1引くし、後ろのページの場合、indexを1足すことにしてます。
    そしたら、このindexに基づいて当月のMonthViewを描画する流れになります。
  2. 該当月の日付を計算しています。日付計算でnullになるなら現在日付をセットします。
    MonthViewは指定日付の月を描画するように実装してますので、日は何でもOKです。
  3. .onChange(of: calendarDate.uuid)をUUID変わったら反応するようにしてます。
    これと対応しているのはMonthCellにある以下の実装です。

        .environmentObject(calendarDate)
        .onTapGesture {
          calendarDate.selectedDate = date
          calendarDate.uuid = UUID()
        }
    

    ご覧の通り、MonthCellでタップされたらUUIDを新しく生成してますので、タップするたびuuidの値は変わります。
    当初では、日付変わったら反応するように実装してみたところ、同じ日付で2回目以降のタップに反応しなくなりますので、このように実装を修正しました。

  4. イベントの登録フォームをシートビューとして表示させようとしてます。次のようにまずシートビューを表示するか否かを保持する変数を宣言します。
      @State private var isPresented: Bool = false

    そして、次のように、シートビューを表示するかどうかをこの変数を見るように設定します。

          .sheet(isPresented: $isPresented) {

    そしたら、セルタップされたら、isPresented.toggle()が呼び出され、イベント登録フォームが表示されます。
    ちなみに、シートビューが表示されたら、このisPresentedは自動的にfalseに変えられます。

  5. 1ヶ月のビューの中に、次のように先月分と来月分の日付も表示されます。
    これらがタップされたら、画面は該当月を表示させたいです。

    これを実現するために、今どの月を表示しているかを保持しているのがcalendarDate.lastSelectedYearMonthになります。
    この環境変数と紐づいているのは次のonAppear()アクションになります。
    イベント登録フォームを表示させる時に、選択された年月は当月かどうかを確認します。先月ならページのindexを-1するし、来月ならページのindexを+1にしてカレンダーを再描画します。

          .sheet(isPresented: $isPresented) {
            EventFormView(date: calendarDate.selectedDate)
              .onAppear() {
                print("year month: \(calendarDate.selectedDate.yearMonth()), last: \(calendarDate.lastSelectedYearMonth)")
                if (calendarDate.selectedDate.yearMonth() < calendarDate.lastSelectedYearMonth) {
                  print("last month")
                  pageIndex -= 1
                } else if (calendarDate.selectedDate.yearMonth() > calendarDate.lastSelectedYearMonth) {
                  print("next month")
                  pageIndex += 1
                }
                calendarDate.lastSelectedYearMonth = calendarDate.selectedDate.yearMonth()
              }
          }
    
  6. 次のnavigationBarTitleを設定することで、画面上部中央に現在の年月を表示させています。
    スワイプしたら、上の表示も合わせて変える必要がありますので、スワイプのアクションにcalendarDate.showingDateを更新しています。

            .navigationBarTitle(calendarDate.showingDate.yearMonthString(), displayMode: .inline)

 

どこからでも共有できるオブジェクト

前の解説で度々出てくるcalendarDateContentViewで次のように宣言されてます。

  @EnvironmentObject var calendarDate: CalendarDate

CalendarDate型の中身はともかく、どこからでもこのオブジェクトにアクセスできることが特徴と言えるでしょう。

今回ContentViewには1つのMonthViewがあり、MonthViewにはたくさんのMonthCellがセットされており、MonthCellのアクションをどうやってContentViewに知らせるかは悩んでおりました。Objective-Cの時代では、callbackみたいなDelegateを実装していたような気がしますが、Swiftではどうやるんだろうと調べていく中、@State@Bindingの組み合わせの案と@EnvironmentObjectの案が見つかりました。

@State@Bindingの組み合わせの案だと、ContentViewには@State宣言して、MonthViewには@Bindingした上、さらにMonthCellにも@Bindingする必要がありますので、実装が煩雑で不採用になりました。代わりに@EnvironmentObjectで今の日付など諸々を一つのオブジェクトに持たせて今の実装になりました。

 

ソースコード

最後にソースコードを一式をこちらにアップロードします。

UkiCalendar_20240107.zip

 

参考

  1. Bidirectional infinite PageView in SwiftUI

投稿日

カテゴリー:

投稿者:

タグ:

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です