It’s now or never

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

【SwiftUI】sheetやNavigationLinkで遷移するときに子Viewが再描画されてしまうのを防ぐ

はじめに

この記事の内容は、実装方法に依存する話です。
実装によっては必要ない場合もありますのでその前提で読んでいただければと思います。

環境

概要

SwiftUIの画面描画は基本的には階層構造になっています。

struct FirstView: View {
    var body: some View {
        VStack {
            SecondView()
        }
    }
}

struct SecondView: View {
    var body: some View {
        VStack {
            ThirdView()
        }
    }
}

このように、親のViewから子Viewをbodyプロパティ内から呼び出す方式になっており親Viewのbodyプロパティが再計算されると、その子Viewも再度生成されます。
(「生成される=構造体が作り直される」のため、init()メソッドが呼ばれます)

最近SwiftUIでアプリを実装するにあたり、この仕組みによってハマったことがあり、今回はその話です。

発生していた問題

実装しているアプリで、次のような sheet を使ったモーダルViewを表示しているときの話です。

struct FirstView: View {
    @State var isShowSecondView = false
    var body: some View {
        VStack {
            ・・・
            Button(action: {
                self.isShowSecondView.toggle()
            }) {
                Text("show SecondView")
            }
            .sheet(isPresented: $isShowSecondView) {
                SecondView()
            }
        }
    }
}

sheetで呼び出すSecondViewは次のようになっています。

struct SecondView: View {
    var body: some View {
        VStack {
            Text("SecondView")
            // 初期データを表示する
            ・・・
        }
        .onAppear {
            // DBから初期データを読み込む
            loadInitData()
        }
    }
}

SecondViewでは、onAppearで初期データを読み込んでおり、データを画面に表示していました。

また、このアプリでは、定期的にデータをサーバーから再取得する処理があり、「FirstViewではデータが再取得されたタイミングで画面を再描画」していました。

このような構成で実装していると、なぜか SecondViewの初期データが表示されない という事象が度々発生していました。

問題の原因

これは概要にもあるViewの階層構造の読み込みに起因する話で、FirstViewのbodyプロパティが再計算されたタイミング(=再描画されたタイミング)でSecondViewも再度生成されます。

これが原因で onAppearで読みこんでいた初期データの内容が、SecondViewの再描画時に消えてしまっていたというのが原因でした。
(onAppear は初回描画時にのみ呼ばれ、再描画では呼ばれないため2回目の描画時にデータが消えてしまった)

解決方法

結論としては、この問題を解決するため次のように ボタン+sheet modifierを別のViewに切り分ける ことで対応しました。

struct ShowSecondViewButton: View {
    @State var isShowSecondView = false
    var body: some View {
        Button(action: {
            self.isShowSecondView.toggle()
        }) {
            Text("show SecondView")
        }
        .sheet(isPresented: $isShowSecondView) {
            SecondView()
        }
    }
}

struct FirstView: View {
    var body: some View {
        VStack {
            ・・・
            ShowSecondViewButton()
        }
    }
}

なぜこれで解決できるのか?

sheet modifierとButtonがFirstViewのbody内にある場合、その他のbodyの要素が更新されるとこれらのViewも強制的に更新されてしまい。
よって、モーダルが表示されている状態では、そのモーダルViewであるSecondViewも強制的に更新されます。

しかし、Viewの計算型プロパティであるbodyは、中身が変わらない場合 Viewが再生成されても、呼び出されません
よって、sheet modifierとButtonだけを別Viewとして切り出せば、そのbodyの内容が変わらない限りは再描画することはありません。

上の例でいうと FirstView が再描画されたタイミングで ShowSecondViewButton のinit()は呼ばれますが、bodyのプロパティは再度呼ばれないため、モーダルである SecondView も再描画されない。ということです。

補足(この現象がよく発生するパターン)

今回の事象は、SecondViewのbodyプロパティの内容が変更されなければ再描画されないため、必ず発生するわけではありません。

struct SecondView: View {
    @Environment(\.presentationMode) var presentationMode

    var body: some View {
        NavigationView {
            HStack {
                Text("second view")
            }
            .navigationBarItems(leading: Button(action: {
                self.presentationMode.wrappedValue.dismiss()
            }) {
                Text("閉じる")
            })
        }
    }
}

上記のようなモーダル画面の場合は、再生成時にbodyが再計算されないため今回のような事象は起きません。

ではどんな場合に発生するかというと、1つは「ViewでObservedObjectを使っているケース」があります。

class SecondViewModel: ObservableObject { }
struct SecondView: View {
    @Environment(\.presentationMode) var presentationMode
    @ObservedObject(initialValue: SecondViewModel()) var viewModel: SecondViewModel

    var body: some View {
        NavigationView {
            HStack {
                Text("second view")
            }
            .navigationBarItems(leading: Button(action: {
                self.presentationMode.wrappedValue.dismiss()
            }) {
                Text("閉じる")
            })
        }
    }
}

先程のSecondViewに SecondViewModel という@ObservedObjectを宣言します。
そうするとこのViewは、生成時に毎回 body内が再計算されるようになります。
(詳しい原理までは、理解しきれてませんが監視のロジック的にそういう挙動になるのだと思っています)

僕のアプリでもこのような実装をしていたので、毎回再描画が発生していました。

その他、initメソッド内でbody内で参照するプロパティを動的に更新するケースでも再描画が発生します。

まとめ・所感

SwiftUIを始めたばかりの頃、onAppear でデータを初期化する実装で、データがよく消えるという事象が起こり結構はまりました。
initでデータ初期化する方法もあるとは思いますが、ネットワークから取ってくる場合などは再描画のたびに通信されるため、あまり現実的ではありません。

自分の中の結論としては、レンダリングの範囲が限定されるように細かくViewを分けるのがベストだと思っており、とくに画面遷移部分は今回のように分離してガードしています。
もっといい方法がありそうな気もしていますが、見つかるまではこの方法で対応しようと思います。 (そのうちバージョンが上がればもっと簡単に実装できるようになりそう?)

今回のような事象は、階層構造の上位Viewで頻繁に画面描画が発生するアプリ(タイマー系など)は起きがちなので注意が必要かもしれません。

以上、似たような事象で困っている人の参考になれば幸いです。