NCMBのSwift SDKを使ってカレンダーアプリを作ります。予定を登録したり、カレンダーコンポーネント(FSCalendar)を使って予定を表示できるというアプリです。
前回の記事では認証処理を実装しましたので、今回は予定の表示と作成・更新、そして削除処理を実装していきます。
コードについて
今回のコードはNCMBMania/swift-calendar-appにアップロードしてあります。実装時の参考にしてください。
カレンダーの予定一覧について
ObservableObjectの利用
カレンダーの予定、表示している年月、選択している日付はObservableObjectで管理します。こうすることで、FSCalendarと連携したり、画面を遷移した後でもデータを一元管理できます。
ObservableObjectは以下のようになります。
// カレンダー用の構造体
class DayData: ObservableObject {
@Published var schedules: [NCMBObject] = []
@Published var selectedDate = Date()
@Published var currentYearMonth = Date()
}
画面について
カレンダー画面は以下のようになります。カレンダーの表示自体はFSCalendarをラッピングしたFSCalendarViewにて行っています(後述)、
struct CalendarView: View {
@ObservedObject var dayData = DayData()
// 作成・更新した際のフラグ
@State private var updated = false
// 削除されたオブジェクトIDを入れる
@State private var deleteObjectId = ""
// 作成したスケジュールが入るNCMBObject
@State private var schedule: NCMBObject = NCMBObject(className: "Schedule")
// 予定をNCMBのデータストアから取得する関数
func _getSchedule() {
// 後述
}
// 一覧のタイトル用
func _viewTitle() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MM月dd日"
return dateFormatter.string(from: dayData.selectedDate)
}
// 該当日のスケジュールだけを返す関数
func _dateFilter() -> [NCMBObject] {
// 後述
}
var body: some View {
NavigationView {
VStack {
// カレンダービュー
FSCalendarView(dayData: Binding.constant(dayData))
.frame(height: 400)
.onChange(of: dayData.currentYearMonth, perform: {(newValue) in
// 表示月が変わったら、予定を取得し直す
_getSchedule()
})
Text(_viewTitle())
.font(.title)
.padding()
// 選択した日の予定を一覧表示
List(_dateFilter(), id: \.objectId) { schedule in
// 一覧をタップしたら、編集画面に遷移
NavigationLink(destination: FormView(schedule: Binding.constant(schedule), updated: $updated, deleteObjectId: $deleteObjectId)) {
// 一覧表示用
CalendarListItemView(schedule: schedule)
}
}
Spacer()
}
.navigationTitle("カレンダー")
// ナビゲーションバーのプラスアイコン
.navigationBarItems(trailing:
NavigationLink(destination: FormView(schedule: $schedule, updated: $updated, deleteObjectId: $deleteObjectId), label: {
Image(systemName: "plus")
})
)
// 予定を追加、更新した際のイベント
.onChange(of: updated, perform: {_ in
if updated {
// 予定を追加
if schedule.objectId != nil {
dayData.schedules.append(schedule)
schedule = NCMBObject(className: "Schedule")
}
// フラグを落とす
updated = false
}
})
// 予定を削除された際のイベント
.onChange(of: deleteObjectId, perform: {_ in
if deleteObjectId != "" {
// 予定データから削除された予定を削除
dayData.schedules.removeAll(where: {
$0.objectId == deleteObjectId
})
// 削除されたオブジェクトIDをリセット
deleteObjectId = ""
}
})
}
// 表示された際にスケジュールを取得する
.onAppear {
_getSchedule()
}
}
}
FSCalendarViewの内容
FSCalendarViewは以下のようになります。【SwiftUI】FSCalendarの実装方法【イベント日表示と選択日表示の方法】を参考にさせてもらっています。
//
// FSCalendarView.swift
// calendar
//
// Created by Atsushi Nakatsugawa on 2022/11/22.
//
import SwiftUI
import FSCalendar
import UIKit
import NCMB
// カレンダービュー
struct FSCalendarView: UIViewRepresentable {
@Binding var dayData: DayData
func makeUIView(context: Context) -> FSCalendar {
typealias UIViewType = FSCalendar
let fsCalendar = FSCalendar()
fsCalendar.delegate = context.coordinator
fsCalendar.dataSource = context.coordinator
fsCalendar.appearance.headerDateFormat = "yyyy年MM月"
return fsCalendar
}
// 再描画用
func updateUIView(_ uiView: FSCalendar, context: Context) {
uiView.reloadData()
}
func makeCoordinator() -> Coordinator{
return Coordinator(self)
}
class Coordinator: NSObject, FSCalendarDelegate, FSCalendarDataSource {
var parent: FSCalendarView
init(_ parent: FSCalendarView){
self.parent = parent
}
// 予定がある日付にドットを表示する処理
func calendar(_ calendar: FSCalendar, numberOfEventsFor date: Date) -> Int {
// データがない場合は0を返して終わり
if (parent.dayData.schedules.isEmpty) {
return 0;
}
// 該当日のデータだけを抽出
let events: [NCMBObject] = parent.dayData.schedules.filter({(obj) in
if obj.objectId == nil {
return false
}
let startDate = obj["startDate"]! as Date
let targetDate = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: startDate).date
return date.compare(targetDate!) == .orderedSame
})
// 該当日のカウントを返す
return events.count
}
// 日付を選択した際の処理
func calendar(_ calendar: FSCalendar, didSelect date: Date, at monthPosition: FSCalendarMonthPosition) {
parent.dayData.selectedDate = date
}
// 年月を変更した際の処理
func calendarCurrentPageDidChange(_ calendar: FSCalendar) {
parent.dayData.currentYearMonth = calendar.currentPage
}
}
}
予定の取得とストアへの設定
画面を表示したタイミングでNCMBのデータストアから予定一覧を取得しています。デフォルトでは表示月の月初から月末までの予定をすべて取得し、それをObservableObjectへセットします。
// 予定をNCMBのデータストアから取得する関数
func _getSchedule() {
// 対象となるクラス(DBで言うテーブル名相当)
var query = NCMBQuery.getQuery(className: "Schedule")
// 1日00時00分
var startDate = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: dayData.currentYearMonth)
startDate.day = 1
// 翌月1日00時00分
var endDate = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: startDate.date!)
endDate.month! += 1
endDate.day = 1
// 検索条件設定
query.where(field: "startDate", greaterThanOrEqualTo: startDate.date!)
query.where(field: "endDate", lessThan: endDate.date!)
query.limit = 1000
// 検索実行
query.findInBackground(callback: {results in
if case let .success(ary) = results {
// 結果を適用
DispatchQueue.main.async {
dayData.schedules = ary;
}
}
})
}
カレンダー表示
カレンダーはFSCalendarで行っていますが、予定があるところにはドットが表示されます。以下はその該当部分のコードです。最大3つまでの表示で、それ以上を返しても結果は変わりませんでした。
// 予定がある日付にドットを表示する処理
func calendar(_ calendar: FSCalendar, numberOfEventsFor date: Date) -> Int {
// データがない場合は0を返して終わり
if (parent.dayData.schedules.isEmpty) {
return 0;
}
// 該当日のデータだけを抽出
let events: [NCMBObject] = parent.dayData.schedules.filter({(obj) in
if obj.objectId == nil {
return false
}
let startDate = obj["startDate"]! as Date
let targetDate = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: startDate).date
return date.compare(targetDate!) == .orderedSame
})
// 該当日のカウントを返す
return events.count
}
日付を選択後、予定を一覧表示する
カレンダーの日付を選択したら、該当日の予定を一覧表示します。まず該当日だけのデータにフィルタリングします。
// 該当日のスケジュールだけを返す関数
func _dateFilter() -> [NCMBObject] {
// 該当日の0時00分
let startDateComponent = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: dayData.selectedDate)
// 該当日の23時59分
var endDateComponent = Calendar.current.dateComponents([.calendar, .year, .month, .day], from: dayData.selectedDate)
endDateComponent.minute = -1
endDateComponent.day! += 1
return dayData.schedules.filter({ (schedule) in
// データ削除対策用
if schedule.objectId == nil {
return false
}
// 予定の開始日時・終了日時を取得
let startDate = schedule["startDate"]! as Date
let endDate = schedule["endDate"]! as Date
//予定の開始日時・終了日時が範囲に収まっているか判定
return startDateComponent.date! <= startDate && endDateComponent.date! > endDate
})
}
そして、この結果をListで表示します。リストをタップすると、編集画面(FormView)に遷移します。
// 選択した日の予定を一覧表示
List(_dateFilter(), id: \.objectId) { schedule in
// 一覧をタップしたら、編集画面に遷移
NavigationLink(destination: FormView(schedule: Binding.constant(schedule), updated: $updated, deleteObjectId: $deleteObjectId)) {
// 一覧表示用
CalendarListItemView(schedule: schedule)
}
}
CalendarListItemView は受け取ったスケジュールを描画します。
// 予定の一覧用(行)
struct CalendarListItemView: View {
@State var schedule: NCMBObject
// 時間を表示する文字列を返す
func _viewTime() -> String {
let startDate = schedule["startDate"]! as Date
let endDate = schedule["endDate"]! as Date
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "HH:mm"
return "\(dateFormatter.string(from: startDate))〜\(dateFormatter.string(from: endDate))"
}
// 描画
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(schedule["title"]! as String)
.fontWeight(.bold)
.font(.title3)
.padding(.leading, 20)
Spacer()
Text(_viewTime())
.frame(alignment: .trailing)
.font(.caption)
.padding(.trailing, 20)
}
Text(schedule["body"]! as String)
.padding(.horizontal, 20)
.padding(.top, 5)
.padding(.bottom, 10)
}
.frame(maxWidth: .infinity)
}
}
予定を追加・編集する
予定を追加したり、編集する際には FormView へ移動します。新規作成の場合は CalendarView のナビゲーションーメニューにあるプラスアイコンより遷移します。
.navigationBarItems(trailing:
NavigationLink(destination: FormView(schedule: $schedule, updated: $updated, deleteObjectId: $deleteObjectId), label: {
Image(systemName: "plus")
})
)
フォーム画面について
フォーム画面(FormView)は予定のタイトルと詳細、予定開始日時、終了日時などを入力して登録を行います。
// 予定の入力・更新画面
struct FormView: View {
// 一覧から受け取った予定
@Binding var schedule: NCMBObject
// 作成・更新用フラグ
@Binding var updated: Bool
// 削除用のオブジェクトIDを入れる
@Binding var deleteObjectId: String
@Environment(\.presentationMode) var presentation
// 入力用
@State private var _title: String = ""
@State private var _body: String = ""
@State private var _startDate: Date = Date.now
@State private var _endDate: Date = Date.now
// スケジュールデータを入力用に適用される処理
func _setValue() -> Void {
_title = schedule["title"] ?? ""
_body = schedule["body"] ?? ""
// 新規データの場合は nil なので、判別してからセット
if let startDateValue = schedule["startDate"] as Any? {
_startDate = startDateValue as! Date
}
if let endDateValue = schedule["startDate"] as Any? {
_endDate = endDateValue as! Date
}
}
// 開始日が変更されたら、それに合わせて終了日を自動設定
func _setEndDate() -> Void {
var params = Calendar.current.dateComponents([.calendar, .year, .month, .day, .hour, .minute], from: _startDate)
params.hour! += 1 // 1時間後にする
_endDate = params.date!
}
// スケジュールの保存処理
func _save() -> Void {
// 後述
}
// 予定の削除処理
func _delete() -> Void {
// 後述
}
var body: some View {
VStack(spacing: 16) {
TextField("予定のタイトル", text: $_title)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(maxWidth: .infinity)
.padding(.horizontal, 20)
DatePicker("開始日時", selection: $_startDate)
.padding(.horizontal, 20)
DatePicker("終了日時", selection: $_endDate)
.padding(.horizontal, 20)
TextEditor(text: $_body)
.frame(maxWidth: .infinity, maxHeight: 300)
.border(.gray)
.padding(.horizontal, 20)
Button(action: {
_save()
}, label: {
Text("新規保存 or 更新")
})
_schedule.wrappedValue.objectId != nil ?
Button(action: {
_delete()
}, label: {
Text("予定の削除")
}) : nil
// 開始日が変更された際のイベント
}.onChange(of: _startDate, perform: {(_) in
_setEndDate()
})
// 表示された際のイベント
.onAppear {
_setValue()
}
}
}
データの準備
NCMBObjectはBindingであっても編集に利用できないため、画面表示を行ったタイミングで入力用変数に適用します。
// スケジュールデータを入力用に適用される処理
func _setValue() -> Void {
_title = schedule["title"] ?? ""
_body = schedule["body"] ?? ""
// 新規データの場合は nil なので、判別してからセット
if let startDateValue = schedule["startDate"] as Any? {
_startDate = startDateValue as! Date
}
if let endDateValue = schedule["startDate"] as Any? {
_endDate = endDateValue as! Date
}
}
データの保存
データを新規作成・更新する流れは同じです。入力値の適用と、ACL(アクセス権限)を設定します。ACLはデータをセキュアに扱うために必要です。今回はデータを作成した本人のみ、読み書きできるようにします。
// スケジュールの保存処理
func _save() -> Void {
// 入力値を設定
schedule["title"] = _title
schedule["body"] = _body
schedule["startDate"] = _startDate
schedule["endDate"] = _endDate
// ACL(アクセス権限)を設定
var acl = NCMBACL.empty
let user = NCMBUser.currentUser
acl.put(key: user!.objectId!, readable: true, writable: true)
schedule.acl = acl
// 保存実行
schedule.saveInBackground(callback: { result in
// 保存が成功していれば、更新フラグを立てる
if case .success(_) = result {
DispatchQueue.main.async {
updated = true
presentation.wrappedValue.dismiss()
}
}
})
}
予定の削除
予定を削除するのは delete
メソッドになります。確認ダイアログを出して、データを削除します。
// 予定の削除処理
func _delete() -> Void {
// 削除すると nil になるので、その前に保存
let objectId = schedule.objectId!
// 削除実行
schedule.deleteInBackground(callback: {result in
if case .success(_) = result {
DispatchQueue.main.async {
// フラグを立てる
deleteObjectId = objectId
presentation.wrappedValue.dismiss()
}
}
})
}
まとめ
ここまでの流れでカレンダーアプリの完成です。NCMBのデータストアでは日付や文字列(他にも数字や配列、真偽値、オブジェクト、位置情報)など様々な形式でデータの保存・取得ができます。ぜひ柔軟に皆さんのアプリに活かしてください。