NCMBのSwift SDKを使ってカレンダーアプリを作る（その3：予定の登録と更新、削除）

NCMBのSwift SDKを使ってカレンダーアプリを作ります。予定を登録したり、カレンダーコンポーネント（FSCalendar）を使って予定を表示できるというアプリです。

[前回の記事では認証処理を実装しました](https://qiita.com/goofmint/items/10ff0672cfe7d0501ef2)ので、今回は予定の表示と作成・更新、そして削除処理を実装していきます。

## コードについて

今回のコードは[NCMBMania/swift-calendar-app](https://github.com/NCMBMania/swift-calendar-app)にアップロードしてあります。実装時の参考にしてください。

## カレンダーの予定一覧について

### ObservableObjectの利用

カレンダーの予定、表示している年月、選択している日付はObservableObjectで管理します。こうすることで、FSCalendarと連携したり、画面を遷移した後でもデータを一元管理できます。

ObservableObjectは以下のようになります。

```swift
// カレンダー用の構造体
class DayData: ObservableObject {
    @Published var schedules: [NCMBObject] = []
    @Published var selectedDate = Date()
    @Published var currentYearMonth = Date()
}
```

### 画面について

![Simulator Screen Shot - iPhone 14 Pro - 2022-11-22 at 10.50.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/498d7378-da42-5679-a0ec-932d9e2b9453.png)


カレンダー画面は以下のようになります。カレンダーの表示自体はFSCalendarをラッピングしたFSCalendarViewにて行っています（後述）、

```swift
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の実装方法【イベント日表示と選択日表示の方法】](https://tomato-develop.com/swiftui-fscalendar-how-to-implement-show-event-selected-date/)を参考にさせてもらっています。

```swift
//
//  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へセットします。

```swift
// 予定を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つまでの表示で、それ以上を返しても結果は変わりませんでした。

```swift
// 予定がある日付にドットを表示する処理
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
}
```

### 日付を選択後、予定を一覧表示する

カレンダーの日付を選択したら、該当日の予定を一覧表示します。まず該当日だけのデータにフィルタリングします。

```swift
// 該当日のスケジュールだけを返す関数
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）に遷移します。

```swift
// 選択した日の予定を一覧表示
List(_dateFilter(), id: \.objectId) { schedule in
		// 一覧をタップしたら、編集画面に遷移
		NavigationLink(destination: FormView(schedule: Binding.constant(schedule), updated: $updated, deleteObjectId: $deleteObjectId)) {
				// 一覧表示用
				CalendarListItemView(schedule: schedule)
		}
}
```

CalendarListItemView は受け取ったスケジュールを描画します。

```swift
// 予定の一覧用（行）
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)
    }
}
```

## 予定を追加・編集する

![Simulator Screen Shot - iPhone 14 Pro - 2022-11-22 at 10.50.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/e905eda0-beef-aca0-e4de-90eb1dc362e5.png)


予定を追加したり、編集する際には FormView へ移動します。新規作成の場合は CalendarView のナビゲーションーメニューにあるプラスアイコンより遷移します。

```swift
.navigationBarItems(trailing:
												NavigationLink(destination: FormView(schedule: $schedule, updated: $updated, deleteObjectId: $deleteObjectId), label: {
				Image(systemName: "plus")
		})
)
```

### フォーム画面について

フォーム画面（FormView）は予定のタイトルと詳細、予定開始日時、終了日時などを入力して登録を行います。

```swift
// 予定の入力・更新画面
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であっても編集に利用できないため、画面表示を行ったタイミングで入力用変数に適用します。

```swift
// スケジュールデータを入力用に適用される処理
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はデータをセキュアに扱うために必要です。今回はデータを作成した本人のみ、読み書きできるようにします。

```swift
// スケジュールの保存処理
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` メソッドになります。確認ダイアログを出して、データを削除します。

```swift
// 予定の削除処理
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のデータストアでは日付や文字列（他にも数字や配列、真偽値、オブジェクト、位置情報）など様々な形式でデータの保存・取得ができます。ぜひ柔軟に皆さんのアプリに活かしてください。

[mBaaSでサーバー開発不要！ | ニフクラ mobile backend](https://mbaas.nifcloud.com/)