Firebase, SwiftUIとも注目度の高いテクノロジーを使うと本当に簡単にアプリが作れてしまいます。
今回は初心者向けに、Cloud FirestoreとSwiftUIを使ってアプリを作る方法を解説します。
アプリの仕様
今回は、2つのViewを準備してデータの作成、表示、更新、削除ができる簡単なアプリを解説作って行こうと思います。
いわゆるTODOアプリっぽいやつですね。
また、今回はBallcapを使ってアプリを作っていきます。
Ballcapはcocoapods
を使ってインストールできます。
モデル定義
まずはデータベースモデル定義をしていきましょう。
final class Task: Object, DataRepresentable, DataListenable, ObservableObject, Identifiable {
typealias ID = String
override class var name: String { "tasks" }
struct Model: Codable, Modelable {
var title: String = ""
var due: ServerTimestamp?
}
@Published var data: Task.Model?
var listener: ListenerRegistration?
}
色々とごちゃごちゃしてるので順を追ってみていきましょう。
Ballcap Object継承
BallcapのObject
を継承すると__Cloud Firestore__のCollection
の名前を定義してDocumentReference
とStorageReference
を保持することができます。
次の例では/tasks
を定義しています。
final class Task: Object {
override class var name: String { "tasks" }
}
Collectionの名前
/tasks/
DocumentReference, StorageReference
/tasks/:id
DataRepresentableプロトコル
DataRepresentable
プロトコルはObject
にData
を保持させることを担います。
DataRepresentable
に準拠させることで、Model
の定義とdata
の保持を要求されます。
final class Task: Object, DataRepresentable {
override class var name: String { "tasks" }
struct Model: Modelable, Codable {
// Modelを定義
}
// Cloud FirestoreのField dataを保持する
var data: Task.Model?
}
開発者は、Modelable
とCodable
に準拠したModel
定義する必要があります。
今回は以下の2のプロパティを定義しました。
struct Model: Modelable, Codable {
// Taskのタイトル
var title: String = ""
// Taskの期限
var due: ServerTimestamp?
}
また、DataRepresentable
に準拠したObject
は次のことを行えるようになります。
-
Object
を任意のIDで初期化する - データの保存、取得、更新、削除
ここまでの機能でCloud Firestore
を使って簡単なアプリは作れそうですね。
DataListenableプロトコル
DataListenable
プロトコルはObject
のData
の変更を監視するための機能を持っています。DataListenable
に準拠させるとlistener
の保持を要求されます。
final class Task: Object, DataRepresentable, DataListenable {
override class var name: String { "tasks" }
struct Model: Modelable, Codable {
// Taskのタイトル
var title: String = ""
// Taskの期限
var due: ServerTimestamp?
}
// Cloud FirestoreのField dataを保持する
var data: Task.Model?
// Cloud FirestoreのField dataの変更を監視する
var listener: ListenerRegistration?
}
ここまでで、Ballcapでの機能追加は終わりです。
次に、SwiftUIに必要なプロトコルへ準拠させていきます。
ObservableObjectプロトコル
ObservableObjectプロトコルは@Published
で定義されたプロパティの変更をSwiftUIへ伝える役割を果たします。
今回はもちろんdata
の変更をSwiftUIへ伝えたいので@Published
をdata
へ追記します。
final class Task: Object, DataRepresentable, DataListenable, ObservableObject {
override class var name: String { "tasks" }
struct Model: Modelable, Codable {
// Taskのタイトル
var title: String = ""
// Taskの期限
var due: ServerTimestamp?
}
// Cloud FirestoreのField dataを保持する
@Published var data: Task.Model?
// Cloud FirestoreのField dataの変更を監視する
var listener: ListenerRegistration?
}
Identifiableプロトコル
IdentifiableプロトコルはSwiftUIのListなどに必要なプロトコルです。
id
を指定する必要がありますが、Object
はid
を保持しているのでtypealias
だけを追記します。
typealias ID = String
final class Task: Object, DataRepresentable, DataListenable, ObservableObject, Identifiable {
typealias ID = String
override class var name: String { "tasks" }
struct Model: Modelable, Codable {
// Taskのタイトル
var title: String = ""
// Taskの期限
var due: ServerTimestamp?
}
// Cloud FirestoreのField dataを保持する
@Published var data: Task.Model?
// Cloud FirestoreのField dataの変更を監視する
var listener: ListenerRegistration?
}
長かったですが必要な定義が整いました。
Viewを作る
TaskListViewを作る
まずはTask
をリスト表示するViewを作っていきましょう。もっともシンプルな形はこれです。
これに機能を追加していきます。
struct TaskListView: View {
@State var tasks: [Task] = []
var body: some View {
NavigationView {
List {
ForEach(tasks) { task in
VStack {
Text(task[\.title])
}
}
}
}
}
}
CloudFirestoreと連携させてList表示させる
struct TaskListView: View {
@State var tasks: [Task] = []
let dataSource: DataSource<Task> = Task.order(by: "updatedAt").limit(30).dataSource()
var body: some View {
NavigationView {
List {
ForEach(tasks) { task in
VStack {
Text(task[\.title])
}
}
}
.onAppear(perform: {
self.dataSource
.retrieve(from: { (_, snapshot, done) in
let task: Task = Task(snapshot: snapshot)!
done(task)
})
.onChanged({ (_, snapshot) in
self.tasks = snapshot.after
})
.listen()
})
}
}
}
DataSource
が登場しました。ここでDataSource
について見ていきましょう。
DataSource
DataSource
はBallcapがサポートするCloud FirestoreのCollection
を管理する機能です。次のようにDataSource
を定義することができます。
DataSource
は他にもarrayContains
などのCollectionReference
がもつ全ての機能をサポートしています。
let dataSource: DataSource<Task> = Task
.order(by: "updatedAt")
.limit(30)
.dataSource()
上の例ではTask
のCollection
をupdatedAt
の順番に30
件取得するDataSource
を表します。
onAppear
次にonAppear
について見ていきましょう。onAppear
はView
が表示されたタイミングで一度だけ呼ばれます。ここでDataSource
の監視をスタートし、変更をキャッチできるようにしています。
.onAppear(perform: {
self.dataSource
.retrieve(from: { (_, snapshot, done) in
let task: Task = Task(snapshot: snapshot)!
done(task)
})
.onChanged({ (_, dataSourceSnapshot) in
self.tasks = dataSourceSnapshot.after
})
.listen()
})
retrieve
retrieve
では、CollectionReference
から取得したSnapshot
をTask
に変換しています。
.retrieve(from: { (_, snapshot, done) in
let task: Task = Task(snapshot: snapshot)!
done(task)
})
onChanged
onChanged
では、取得したデータの配列を管理しています。
クロージャの中で渡されるdataSourceSnapshot
に変更情報が含まれます。
.onChanged({ (_, dataSourceSnapshot) in
self.tasks = dataSourceSnapshot.after
})
DataSource
の中でSnapshot
が定義されており、このSnapshot
には、変更前のデータbefore
変更後のデータafter
変更されたデータchanges
が格納されていています。
今回は変更後のデータだけをtasks
へ渡しています。
public final class DataSource<T: DataRepresentable>: ExpressibleByArrayLiteral {
public typealias Changes = (deletions: [Element], insertions: [Element], modifications: [Element])
public struct Snapshot {
public let before: [Element]
public let after: [Element]
public let changes: Changes
}
}
listen
listen
はDataSource
が監視を開始するためのメソッドです。このメソッドをコールしないとDataSource
は起動しないので忘れないようにコールしましょう。
DataSource
は多くの機能を提供しているので詳細はこちらをご覧ください。
https://github.com/1amageek/Ballcap-iOS#datasource
新しいTaskを作る
新しいTask
が作れるようにNavigationBar
に機能を追加してNewTaskView
へ遷移できるようにしました。
struct TaskListView: View {
@State var isPresented: Bool = false
@State var tasks: [Task] = []
let dataSource: DataSource<Task> = Task.order(by: "updatedAt").dataSource()
var body: some View {
NavigationView {
List {
ForEach(tasks) { task in
VStack {
Text(task[\.title])
}
}
}
.onAppear {
self.dataSource
.retrieve(from: { (_, snapshot, done) in
let task: Task = Task(snapshot: snapshot)!
done(task)
})
.onChanged({ (_, snapshot) in
self.tasks = snapshot.after
})
.listen()
}
.navigationBarTitle("Todo")
.navigationBarItems(trailing: Button("追加") {
self.isPresented.toggle()
})
.sheet(isPresented: $isPresented, content: {
NewTaskView()
})
}
}
}
NewTaskView
新しいTask
を作るためのNewTaskView
は次のようになっています。
struct NewTaskView: View {
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
@ObservedObject(initialValue: Task()) var task: Task
var body: some View {
NavigationView {
TextField("新しいタスク", text: self.$task[\.title])
.navigationBarTitle("新しいタスク")
.navigationBarItems(trailing: Button("保存") {
self.task.save()
self.presentationMode.wrappedValue.dismiss()
})
}
}
}
まず次の変数に注目しましょう。
NewTaskView
では新しいTask
を定義したいので次のようにView
に変数として保持するようになってます。またObservedObject
となっていて変更を受け取れるようになっています。
@ObservedObject(initialValue: Task()) var task: Task
次に、TextField
のtext
にTask
のtitle
をバインドしましょう。
TextField("新しいタスク", text: self.$task[\.title])
保存ボタンを押すと、タスクが保存され閉じるようになっています。
self.task.save()
self.presentationMode.wrappedValue.dismiss()
これで新しいタスクを追加することができました。
実際に実行してみると、新しいタスクが追加されていることが見えると思います。
Taskの編集もしたいのでNewTaskViewをEditTaskViewへ
新規追加だけでなく編集もできるようにしておきましょう。
struct EditTaskView: View {
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
@ObservedObject var task: Task
var body: some View {
NavigationView {
TextField("新しいタスク", text: self.$task[\.title])
.padding()
.navigationBarTitle("新しいタスク")
.navigationBarItems(trailing: Button("保存") {
self.task.save()
self.presentationMode.wrappedValue.dismiss()
})
}
}
}
変更したのは変数の部分だけで、initialValue
を削除し、外部から与えてもらう形にしました。
@ObservedObject var task: Task
TaskListView
も変更します。
struct TaskListView: View {
enum Presentation: View, Hashable, Identifiable {
case new
case edit(task: Task)
var body: some View {
switch self {
case .new: return AnyView(EditTaskView(task: Task()))
case .edit(let task): return AnyView(EditTaskView(task: task))
}
}
}
@State var presentation: Presentation?
@State var tasks: [Task] = []
let dataSource: DataSource<Task> = Task.order(by: "updatedAt").dataSource()
var body: some View {
NavigationView {
List {
ForEach(tasks) { task in
VStack {
Text(task[\.title])
}
.contextMenu {
Button("編集") {
self.presentation = .edit(task: task.copy())
}
}
}
}
.onAppear {
self.dataSource
.retrieve(from: { (_, snapshot, done) in
let task: Task = Task(snapshot: snapshot)!
done(task)
})
.onChanged({ (_, snapshot) in
self.tasks = snapshot.after
})
.listen()
}
.sheet(item: self.$presentation) { $0 }
.navigationBarTitle("Todo")
.navigationBarItems(trailing: Button("追加") {
self.presentation = .new
})
}
}
}
大きな変更をみていきます。次のようにPresentation
を準備しました。
これに関しては別の記事でまとめてますのでこちらをご覧ください。
enum Presentation: View, Hashable, Identifiable {
case new
case edit(task: Task)
var body: some View {
switch self {
case .new: return AnyView(EditTaskView(task: Task()))
case .edit(let task): return AnyView(EditTaskView(task: task))
}
}
}
@State var presentation: Presentation?
新しいタスクを作る時
self.presentation = .new
タスクを編集する時
タスクを編集する時は、.edit
へ現在のタスクのコピーを渡すようにしています。
EditTaskView
にそのままObservedObject
を渡してしまうと、保存せずとも編集された情報をTaskListView
が受け取ってしまうため、編集を途中でキャンセルしたとしてもデータが変更されてしまいます。それを防ぐため別のObject
としてEditTaskView
へ渡しています。
ForEach(tasks) { task in
VStack {
Text(task[\.title])
}
.contextMenu {
Button("編集") {
self.presentation = .edit(task: task.copy())
}
}
}
タスクを完了と削除
ここまでで、タスクの新規作成、編集を行うことができるようになりました。
次にタスクの完了と削除を実装しましょう。今回は編集の時にも使ったcontextMenu
を使って実装しました。
List {
ForEach(tasks) { task in
VStack {
Text(task[\.title])
}
.contextMenu {
Button("完了") {
task[\.isCompleted] = true
task.update()
}
Button("編集") {
self.presentation = .edit(task: task)
}
Button("削除") {
task.delete()
}
}
}
}
タスクの完了
タスクの完了をさせるために、isCompleted
をtrue
にして更新しています。
task[\.isCompleted] = true
task.update()
タスクの削除
タスクの削除にはdelete
を呼び出すだけです。
task.delete()