SwiftUIで作ったモーダルシートでデリゲートを使ってみる

MongoDB
SwiftUIで作ったモーダルシートでデリゲートを使ってみる

やりたいこと

モーダルシートを閉じて何かする処理をデリゲートで実現してみる

ボタンが複数並んだモーダルシートをSwiftUIで作ってみました。ボタンをクリックするとモーダルを閉じて何か処理をするViewです。

ボタンをクリックした時の処理をデリゲートを使って、モーダルシートを開いたViewに実装してみたいのです。

もう少し具体的に

本の情報を削除するかどうかを確認するモーダルシートに、CloseとDeleteの2つのボタンを置きました。この2つのボタンをクリックした時に発生する処理を、デリゲートでモーダルシートの呼び出し元へ委譲します。

Closeボタンはその名の通りモーダルシートを閉じるものです。今はモーダルシート側に閉じる処理を書いています。これをモーダルシートの呼び出し側で閉じるようにします。

Deleteボタンについては、とりあえずモーダルシートを閉じるぐらいで、今は何もDeleteっぽい処理を行っていません。このままでは何の意味もなさないため、モーダルシートの呼び出し側で本の情報を削除させてみます。

デリゲートを実装してみよう

プロトコルを作る

まずはプロトコルを作ります。デリゲートの処理は何度か書いているとパターンが見えてきます。今回もパターンにハマる形で進めてみます。

protocol ConfirmDelegate {
    func onClickDelete(id:String)
    func onClickClose()
}

今回はモーダルシートを閉じる処理と本の情報を消す処理になります。

ネーミングの流儀は皆様にお任せします。また、プロトコル準拠が必須か任意かについてもケースバイケースでの対応になります。

モーダルシートにてプロトコルを呼び出す

先程のプロトコルをモーダルシートで呼び出します。各ボタンのアクションにプロトコルのメソッドを入れておきます。

struct ConfirmDelete: View {
    var delegate: ConfirmDelegate?

    var body: some View {
        VStack {
            (中略)
            HStack {
                Button(action: {
                    self.delegate?.onClickDelete(id: self.selectedItem?.id ?? "")
                }) {
                    Text("Delete")
                }
                Button(action: {
                    self.delegate?.onClickClose()
                    // self.presentationMode.wrappedValue.dismiss()
                }) {
                    Text("Close")
                }
            }
        }
    }
}

フライングしてDeleteのメソッドに引数が入っています。この値はMongoDBが割り当ててくれるIDで、本の情報をMongoDB上で特定するために使います。

コメントアウトされたpresentationModeって?

もともとはself.presentationMode.wrappedValue.dismiss()でモーダルシートを閉じていました。このpresentationModeは環境変数(Environment)で、Viewを遷移した情報を勝手に保持してくれるようです(適当)。開いたモーダルシートのこともこの環境変数は把握しているため、dismissで閉じることができます。

個人的にはなんとなくしっくりとしません。今回のようにモーダルシート(View)を閉じる時は、呼び出したものが責任を持って閉じるようにしたほうが良いような気がします。

モーダルシートの呼び出し元をプロトコルに準拠させる

デリゲート仕上げです。モーダルシートの呼び出し元をプロトコルに準拠させます。

struct ContentView: View,ConfirmDelegate {

お決まりの位置に準拠させたいプロトコルを書いてしばらくそっとしておくと、XCodeから実装すべきメソッドが実装されていないと怒られます。エラーを読むとStubをつくってやると言われるので、言われるがままにstubを作ってもらいます。

まる

甘えられるものにはトコトンまで甘えちゃう系コーディングスタイル

モーダルシートを開いたり閉じたりするのはbool値(今回の例ではshowingSheet)をtrueにするかfalseにするかで制御していますので、このbool値を切り替える(toggleする)だけでOKです。

本の情報を削除する処理は、Vaporアプリケーションとやり取りするクラス(今回の例ではaccess2mongo)にdeleteメソッドを作ろうと思います。もちろん引数はmongoDBでドキュメントごとに採番されたIDを渡します。

最終的にこのような形になります。

func onClickDelete(id: String) {
  self.showingSheet.toggle()
  self.access2mongo.delete(id: id)
  self.access2mongo.get()//ドキュメントの削除後、リストを最新の状態にする
}

func onClickClose() {
  self.showingSheet.toggle()
}

もちろんself.access2mongo.delete(id: id)はまだ存在しませんので当然エラーになります。これから作っていきます。

View呼び出し時にデリゲートの指定をする

呼び出すモーダルシートに、呼び出し元がプロトコルを実装していることを知らせてあげます。

ConfirmDelete(delegate:self, selectedItem: self.$selectedBook)

小難しいことは何もなく、呼び出すモーダルシートのデリゲートプロパティにプロトコルを実装している自分自身(呼び出し元)を代入するだけです。

本の情報を削除するメソッドを作る

macOSアプリからVaporアプリにHTTPリクエストを投げる

func delete(id:String) {
    let url: URL = URL(string: [処理を受け付けるURL])!
    var request = URLRequest(url: url)
    request.httpMethod = "DELETE"
    request.addValue("application/json", forHTTPHeaderField: "Content-Type")

    let task:URLSessionTask = URLSession.shared.dataTask(with: request) { (data, response, error) in
        // コンソール出力
        if let dataString = data {
            print("data:" + (String(data: dataString, encoding: .utf8) ?? "nilです"))
        }
        print("response: \(String(describing: response))")
        print("error: \(String(describing: error))")
    }
    task.resume()
}

本の情報を取得する時に似ています。単純にHTTPリクエストを作るだけです。重要な点はVaporアプリケーション側で処理を受け付けるURLを決めておくことと、HTTPメソッドにDELETEを指定することの2点ですかね。

この時点でとりあえず先ほどのエラーは消えますが、実行してもエラーが返ってくるだけです。もう少々我慢してください。

VaporアプリでMongoDBのデータを削除するAPIを作る

macOSアプリ側から投げられたHTTPリクエストを受け付けるAPIを作ります。

以前Vapor3向けのRouteを作りましたが、Vapor4が出たということでピッと書き換えます。

app.delete("api","book",":bookID") { req -> EventLoopFuture<HTTPStatus> in
    let client = req.application.mongoClient
    let collection = client.db("vaporapp").collection("books", withType: Book.self)

    guard let id = req.parameters.get("bookID")?.lowercased() else {
        throw Abort(.internalServerError) //適宜エラー処理してください
    }

    let query:BSONDocument = try BSONDocument.init(fromJSON: "{\"_id\": {\"$oid\":\"" + id + "\" }}")

    return collection.deleteOne(query).map { deleteRes -> HTTPStatus in
        let deleteCount = deleteRes?.deletedCount

        if deleteCount == 1 {
            return .ok
        }
        else {
            return .noContent
        }
    }.hop(to: req.eventLoop)
}

注意したい点は文字列のIDをそのまま渡して削除のクエリを作らない点です。JSONで条件を作ってBSON形式に変換するという段階を踏む必要があります。ちょっと面倒。

Web版の方も参考にしてみてくださいね。

ちなみに、削除されたドキュメントが無いときの応答としてnoContentを選びましたが、これで良いのかな?

動かしてみる

今回削除するターゲットはこれにします。あからさまにわかりやすく(これを消す)とタイトルに付けています。

今回消すのはこの本

まずはモーダルシートを開きます。

まずはモーダルシートが開くことを確認しておきます

この状態でCloseボタンをクリックすると、ドキュメントは残ったままでモーダルシートが閉じます。

Closeボタンでモーダルシートが閉じます

改めてモーダルシートを開き、今度はDeleteボタンをクリックします。
すると、モーダルシートが閉じられるとともに、ターゲットにしたドキュメントが消えていることが分かります。

本の情報が消えました

消え方が少し雑です。DBを再度叩くのではなく、対象のセルを何かアニメーションで消すほうが見た目が良さそうです。

まとめ

皆様おなじみのデリゲートを使って、macOS向けアプリケーションのモーダルシートから呼び出し元へ処理を委譲してみました。呼び出した側が呼び出したもの(モーダルシートなど)の管理を一手に引き受ける形に慣れているので、その慣れた形をSwiftUIでも使うことができてちょっとうれしい(小並感)。

モーダルシートなどのView呼び出し時に引数でデリゲートの代入ができるのも、地味だけれどもとても便利です。SwiftUIを使う前は結構代入を忘れがちでだったもので。