2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

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()
}

画面について

Simulator Screen Shot - iPhone 14 Pro - 2022-11-22 at 10.50.33.png

カレンダー画面は以下のようになります。カレンダーの表示自体は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)
    }
}

予定を追加・編集する

Simulator Screen Shot - iPhone 14 Pro - 2022-11-22 at 10.50.59.png

予定を追加したり、編集する際には 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のデータストアでは日付や文字列(他にも数字や配列、真偽値、オブジェクト、位置情報)など様々な形式でデータの保存・取得ができます。ぜひ柔軟に皆さんのアプリに活かしてください。

mBaaSでサーバー開発不要! | ニフクラ mobile backend

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?