サンプルアプリにしては、カレンダーアプリは少し難しい選定だったかもしれませんと今になって思いましたが、まあ、頑張りましょう!
ここの要点は2つです。
- 1ヶ月単位のビューを想定し、左右には無限にスワイプできるようにしたい
- とある日を選択したら、該当の日のイベントの登録画面を表示したい
クラスの概要
まず主要なクラスの紹介です。
InfinitePageView
InfinitePageView
は、TabView
をラップし、無限にスワイプできるページビューを作成します。- ページのビューを作成するための
view
クロージャーを提供します。 before
とafter
クロージャーは、現在の選択を基に前後の選択を提供します。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
}
)
}
)
}
$0
は一番目の引数です。今回の場合はview: { index in
とあるように、一番目の引数はindexと思ってもらえばいいと思います。
前のページの場合、indexを1引くし、後ろのページの場合、indexを1足すことにしてます。
そしたら、このindexに基づいて当月のMonthView
を描画する流れになります。- 該当月の日付を計算しています。日付計算でnullになるなら現在日付をセットします。
MonthView
は指定日付の月を描画するように実装してますので、日は何でもOKです。 .onChange(of: calendarDate.uuid)
をUUID変わったら反応するようにしてます。
これと対応しているのはMonthCell
にある以下の実装です。.environmentObject(calendarDate) .onTapGesture { calendarDate.selectedDate = date calendarDate.uuid = UUID() }
ご覧の通り、MonthCellでタップされたらUUIDを新しく生成してますので、タップするたびuuidの値は変わります。
当初では、日付変わったら反応するように実装してみたところ、同じ日付で2回目以降のタップに反応しなくなりますので、このように実装を修正しました。- イベントの登録フォームをシートビューとして表示させようとしてます。次のようにまずシートビューを表示するか否かを保持する変数を宣言します。
@State private var isPresented: Bool = false
そして、次のように、シートビューを表示するかどうかをこの変数を見るように設定します。
.sheet(isPresented: $isPresented) {
そしたら、セルタップされたら、
isPresented.toggle()
が呼び出され、イベント登録フォームが表示されます。
ちなみに、シートビューが表示されたら、このisPresented
は自動的にfalse
に変えられます。 - 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() } }
- 次の
navigationBarTitle
を設定することで、画面上部中央に現在の年月を表示させています。
スワイプしたら、上の表示も合わせて変える必要がありますので、スワイプのアクションにcalendarDate.showingDate
を更新しています。.navigationBarTitle(calendarDate.showingDate.yearMonthString(), displayMode: .inline)
どこからでも共有できるオブジェクト
前の解説で度々出てくるcalendarDate
はContentView
で次のように宣言されてます。
@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
で今の日付など諸々を一つのオブジェクトに持たせて今の実装になりました。
ソースコード
最後にソースコードを一式をこちらにアップロードします。
コメントを残す