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