It’s now or never

IT系の技術ブログです。気になったこと、勉強したことを備忘録的にまとめて行きます。

【SwiftUI】SwiftUIでハマったところ(onAppaerが呼ばれるタイミング/environmentの伝播の範囲)

概要

最近はSwiftUIでアプリを書いているんですが、色々と戸惑うところが多かったので備忘録的にまとめておきます。

環境

Swift version 5.1

onAppearが呼ばれるタイミング

画面再描画時の挙動

SwiftUIには、Viewが初めて描画されるタイミングで呼ばれるコールバックメソッドとして onAppear が用意されています。
(この逆のメソッドとしてViewが非表示になるタイミングで呼ばれる onDisappear もあります。)

SwiftUIでは、Viewの状態の変更変更を検知し、画面の再描画をすることでリアクティブなプログラムを実現していますが、 onAppearは、この画面再描画とは関係なく初めてViewが表示される1回目に呼ばれます。

Viewが再描画される時は、Viewのstructは基本的に再度生成されます。(initメソッドが呼ばれます)
しかし、ViewがinitializeされてもonAppearは呼ばれません。

この辺を理解せずに、onAppearでデータの初期化処理を書いている場合、意図しないタイミングで再描画が走り、データが消えてしまうことがあります。
(僕は何度かハマりました..)

画面遷移時の挙動

SwiftUIの画面遷移は、大きく分けて NavigationLink を使ったスタックの遷移(UIKitでいうUINavigationControllerによるpush/pop)と、 sheet を使ったモーダルの遷移があります。

これらの画面遷移によって画面が変わった場合の onAppear が呼ばれるタイミングにも違いがあります。

NavigationLinkを使った画面遷移の場合は、親画面へ戻る時には親画面の再描画が走ります。そしてこのタイミングで onAppearが呼ばれます

親View -> 子ViewへNavigationLinkで遷移 -> 戻るボタンで親Viewへ遷移 -> onAppear が呼ばれる

しかし、sheetを使ってモーダルとして画面を表示した場合、モーダルを閉じたタイミングでは onAppearは呼ばれません

親View -> 子Viewをsheetで表示 -> dismiss親Viewへ遷移 -> onAppear は呼ばれない

これは、UIKitでも似たような挙動です。

UIKitの画面管理クラスであるViewControllerにも、画面表示時に呼ばれる viewWillAppear というメソッドが存在します。

また、UIKitのスタックによる画面遷移には、UINavigationControllerが使用されますが、このUINavigationControllerを使って画面遷移した場合、 親の画面へ戻った時には、viewWillAppearが呼ばれます

一方でモーダルの遷移には present というメソッドを使いますが、このメソッドでモーダル遷移した場合は戻ったときにviewWillAppearは呼ばれません

この辺は、UIKitの挙動を理解されている方ならば直感的かもしれません。

sheetによるモーダル遷移は、Environment(EnvironmentObject)の値が伝播しない

SwiftUIにはViewの環境変数のようなもので、View階層の一番上のViewに設定したEnvironmentは階層下の子Viewでも参照することが可能です。

View間で値を受け渡すときに非常に重宝するEnvironmentなのですが、画面遷移の方法によって値の伝播のされ方が異なります。

前述の通り、SwiftUIの画面遷移は NavigationLink を使ったスタックの遷移と、sheetを使ったモーダル遷移があります。

NavigationLinkを使ったスタックによる画面遷移を行った場合、Environmentは遷移先のViewにも伝播され使用することができます。
しかし、sheetを使ったモーダル遷移の場合、Environmentは遷移先のViewには伝播されません。

// テスト用のEnvironmentObject
class EnvObject: ObservableObject {
    var text = "envObject"
}

struct ParentView: View {
    @State var isActiveSheet = false
    @State var isActiveLink = false

    var body: some View {
        NavigationView {
            VStack {
                Button(action: {
                    self.isActiveSheet = true
                }) {
                    Text("show sheet")
                }
                // NOTE: NavigationLinkでの遷移は、親のEnvironmentをそのまま引き継ぐ
                NavigationLink(destination: NavigationSubView(), isActive: $isActiveLink) {
                    Text("show link")
                }
            }
            .onAppear {
                print("onAppear")
            }
            .sheet(isPresented: $isActiveSheet) {
                // NOTE: sheetの遷移では再度environmentをセットしないと値を参照できない
                SheetView()
                    .environmentObject(EnvObject())
            }
        }
        .environmentObject(EnvObject())
    }
}

struct SheetView: View {
    @EnvironmentObject var envObject: EnvObject
    
    var body: some View {
        VStack {
            Text("Sheet")
            Text(envObject.text)
        }
    }
}

struct NavigationSubView: View {
    @EnvironmentObject var envObject: EnvObject
    
    var body: some View {
        VStack {
            Text("Navigation")
            Text(envObject.text)
        }
    }
}

上記サンプルは、EnvironmentObjectを使った値の伝播例ですが、sheetを使った画面遷移時は environmentObject メソッドで再度値を渡さないとエラーになります。

UIKit時代からのViewControllerの階層構造を考えると理解は出来る仕様なのですが、久しぶりのiOSでSwiftUIから入ったため何度か戸惑いました。

まとめ

今回は以上です。
SwiftUIはUIKitとは全く異なる画期的な変化だと思いつつもUIKitの挙動を知らないと???となる時が時々あります。
ほかにも色々とハマりどころがあるのでこまめにまとめていきます。