47
31

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 5 years have passed since last update.

Cloud Firestore + SwiftUI + Ballcapで始めるFirebaseアプリ開発

Last updated at Posted at 2019-09-27

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の名前を定義してDocumentReferenceStorageReferenceを保持することができます。
次の例では/tasksを定義しています。

final class Task: Object {
    override class var name: String { "tasks" }
}

Collectionの名前

/tasks/

DocumentReference, StorageReference

/tasks/:id

DataRepresentableプロトコル

DataRepresentableプロトコルはObjectDataを保持させることを担います。
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?
}

開発者は、ModelableCodableに準拠したModel定義する必要があります。
今回は以下の2のプロパティを定義しました。

    struct Model: Modelable, Codable {
        // Taskのタイトル
        var title: String = ""
        // Taskの期限
        var due: ServerTimestamp?
    }

また、DataRepresentableに準拠したObjectは次のことを行えるようになります。

  • Objectを任意のIDで初期化する
  • データの保存、取得、更新、削除

ここまでの機能でCloud Firestoreを使って簡単なアプリは作れそうですね。

DataListenableプロトコル

DataListenableプロトコルはObjectDataの変更を監視するための機能を持っています。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へ伝えたいので@Publisheddataへ追記します。

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を指定する必要がありますが、Objectidを保持しているので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()

上の例ではTaskCollectionupdatedAtの順番に30件取得するDataSourceを表します。

onAppear

次にonAppearについて見ていきましょう。onAppearViewが表示されたタイミングで一度だけ呼ばれます。ここで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から取得したSnapshotTaskに変換しています。

.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

listenDataSourceが監視を開始するためのメソッドです。このメソッドをコールしないと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

次に、TextFieldtextTasktitleをバインドしましょう。

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

タスクの完了

タスクの完了をさせるために、isCompletedtrueにして更新しています。

task[\.isCompleted] = true
task.update()

タスクの削除

タスクの削除にはdeleteを呼び出すだけです。

task.delete()
47
31
1

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
47
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?