SwiftUIでモータルビューを閉じた時に何か処理を実行したい

SwiftUIでは、モーダルビューは sheet メソッドを使って表示します。

struct ContentView: View {
    @State private var presentModal = false
    
    var body: some View {
        VStack {
            Button(action: {
                self.presentModal = true
            }) {
                Text("Show modal")
            }
        }
        .sheet(isPresented: self.$presentModal) {
            Text("Modal View")  // このビューをモーダル表示
        }
    }
}

f:id:gibachan03:20190930201131g:plain:w200

で、遷移先のモーダルビュー側でユーザーが何かしらのアクション(フォームの入力とか)をしたらビューを閉じる、とかはよくありますよね。そして閉じたタイミングで、遷移元のビューで何かしらの処理を実行したい(画面を更新するなど)というのが今回の趣旨です。

要件としては以下の通り

  • モーダルビューが閉じられたタイミングで処理を実行する
  • その処理は遷移元のビュー側に記述したい(モーダルビューの中には書かない)
    • UIViewControllerviewWillAppear(_:) でやるようなイメージ。(iOS13からはfullscreenでないと呼ばれないけど・・)
  • SwiftUIではビューの閉じ方がいくつかあるので、そのどのケースでも同じ手法にしたい

モーダルビューを閉じる方法

モーダルビューを閉じるにはざっくり2通りの方法があって、

  • ビューを上から下へ引っ張るジェスチャーで閉じる
    • 標準で実装されてる
  • プログラムから閉じる
    • コードを自分で書く

このどちらのケースで閉じた場合でも、同じ方法で処理を実行したいです。

また、さらに プログラムから閉じる 方法にも2通りあるのでこれらについても後述します。(が、今回の趣旨においてはどちらも差はないです)

ダメだった方法

sheet メソッドにはシートが閉じられた場合に呼ばれるクロージャを引数に指定できます。 ( onDismiss

public func sheet<Content>(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> Content) -> some View where Content : View

この onDismissジェスチャーでビューを閉じた場合には呼ばれます。 しかしプログラムから閉じた場合には呼ばれないため、今回の要件には沿わないです。

良さそうな案

結局、上記のどの方法で閉じられた場合にもモーダルビュー側の onDisappear が呼ばれるので、それを起点にして目的の処理を実行します。以下はデリゲートパターンでの実装例です。

  • まず、モーダルビューが閉じられた場合に呼ばれるメソッドを持つprotocolを定義
protocol DismissHandlerDelegate {
    func handleDismiss()
}
  • 遷移元ビュー側で目的の処理を実装する
extension ContentView: DismissHandlerDelegate {  // ContentViewが遷移元ビュー
    func handleDismiss() {
        print("dimmissed!!!")  // 何かしらの処理を実行する
    }
}
  • モーダルビューが閉じる(= onDisappear )タイミングでそのデリゲートメソッドを呼ぶ
struct ModalWithDismissHandler: View {
    ...
    var delegate: DismissHandlerDelegate?
    ...    
    var body: some View {
        NavigationView {
            ...
        }
        .onDisappear { self.delegate?.handleDismiss() }
    }
}

これでジェスチャー ・プログラムのどちらで閉じても同じ処理を実行できるようになりました。

【余談】プログラムからモーダルビューを閉じる方法

遷移元のモーダル表示フラグを、遷移先のモーダルビュー側で折る

モーダルビューの定義は次の通り。 Binding<Bool> として遷移元のモーダル表示フラグを受け取れるようにしています。

struct ModalViewWithPresentedState: View {
    @Binding private var presented: Bool
    
    init(isPresented: Binding<Bool>) {
        self._presented = isPresented
    }
    
    var body: some View {
        NavigationView {
            Button(action: {
                self.presented = false   // ここでモーダル表示を折ることで、モーダルを閉じている
            }) {
                Text("Close")
            }
            .navigationBarTitle("Modal")
        }
    }
}

遷移元のモーダル表示処理はこんな感じ

struct ContentView: View {
    @State private var presentModal = false
    
    var body: some View {
        ....
        .sheet(isPresented: self.$presentModal) {
            ModalViewWithPresentedState(isPresented: self.$presentModal)
        }
    } 
}

@Environment(\.presentationMode) を使う

モーダルビューの定義は次の通り。

struct ModalViewWithEnvironment: View {
    @Environment(\.presentationMode) var presentationMode  // これ!
    
    var body: some View {
        NavigationView {
            Button(action: {
                self.presentationMode.wrappedValue.dismiss()    // ここでモーダルを閉じている
            }) {
                Text("Close")
            }
            .navigationBarTitle("Modal")
        }
    }
}

遷移元のモーダル表示処理はこんな感じ。 モーダルビューへ変数を渡す必要がないのでシンプル。

struct ContentView: View {
    @State private var presentModal = false
    
    var body: some View {
        ....
        .sheet(isPresented: self.$presentModal) {
            ModalViewWithEnvironment()
        }
    } 
}

以上です。 すべてのコードは https://gist.github.com/gibachan/305903c9ee0042252d83661ac41007bf に置いてあります。