🐕

【SwiftUI】@StateはStored Propertyではない

2022/05/29に公開

タイトルをどうつけるべきか悩んだ結果、これになりました。
要は複数の@Stateつきのプロパティを更新したとき、思った挙動にならなかったので調べた記録です。

やりたかったこと

やりたかったことはシンプルで、SwiftUIベースのアプリで、シェアシートを出そうとしていました。
SwiftUIだとモーダル遷移がすべて .sheet() を使うことになるので、
@Stateつきの状態変数を2つつくりました。
.sheet() で使うBool値と、シェアシートで共有するデータです。

サンプルコードとしてはこんな感じ。

import SwiftUI

struct FailureView: View {
    @State var isShowSheet = false
    @State var shareData: String?
    
    var body: some View {
        Button("Share") {
            shareData = "Show this message"
            isShowSheet = true
        }
        .sheet(isPresented: $isShowSheet) {
            Text("Passed data: " + (shareData ?? ""))
        }
    }
}

ただこのコード、shareData を受け渡すことができません。
nilになります。

@StateはStored Propertyではない

なぜでしょうか。
それは端的に言えば、StateはStored Propertyではないからです。

僕の@Stateの理解は、いわばこんな感じでした。

var isShowSheet: Bool {
    get {
        // SwiftUIの管理するメモリ領域から値を返却
	return _isShowSheet
    }
    
    set {
        // SwiftUIの管理するメモリ領域への保存
	_isShowSheet = newValue
	// コンポーネントの更新
	updateViewIfNeeded()
    }
} 

@StateはSwiftUIが管理しているメモリ領域にあるStored Propertyに紐づいていて、
更新するとコンポーネントの再作成が走るように条件づけられている、という理解でした。

そしてコンポーネントが再作成されても、通常の変数は初期化されるけれど、@State をつけていると保持されているので、
更新があれば更新された値を使うし、更新がなければ前回の値を使う……と思っていました。

これが誤解で、正しくは

  • 更新であれば更新値
  • 更新がなければ初期値

でした。
最初のサンプルコードの @State var shareData: String? は初期値がnilなので、
isShowSheet の更新タイミングではnilが返っていたと思われます。

もうちょっと詳しく動作を書く

以上の説明でピンと来なかった方向けに、何が起こっていたのかを詳しく書いてみようと思います。

  1. ユーザーがボタンを押す
  2. shareData = "Show this message" で状態変数が更新される
  3. ただこのとき、isShowSheet はfalseなので、モーダルが出ることはない
  4. なので画面再描画はされない
  5. (変化がないので、画面再描画がスキップされる……はず)
  6. isShowSheet = true で状態変数が更新される
  7. モーダルが出る
  8. ただ shareData はnil

いかがでしょうか?

対策

オススメの対策は、Viewにフラグとデータを持たせずに、Controller要素(PresenterなりViewModelなり)に持たせることです。
あるいはフラグだけはViewに残して、シェア対象のデータは外に預かってもらってもいいかもしれません。
とにかく@Stateの複合条件は避けましょう。

ただViewで完結させる方法はなんかないかなと思って調べてみたら、できないことはありませんでした。
.sheet()isPresented: ではなくて、.sheet()item: を使うと、
Bool値ではなく、オブジェクトの更新そのものを画面再描画のトリガーにできます。

import SwiftUI

struct SuccessView: View {
    @State var shareItem: ShareItem?
    
    var body: some View {
        Button("Change(Success)") {
            shareItem = ShareItem(data: "Show this message")
        }
        .sheet(item: $shareItem) { item in
            Text("Passed data: " + item.data)
        }
    }
}

struct ShareItem: Identifiable {
    var id = UUID()
    var data: String
}

ただ指定するitemはIdentifiableである必要があります。
どちらの対策がいいかはお好みで。

Gistにあげました

一応動くサンプルコードをGistにあげましたので、自分の環境で試したい方はご活用ください。

https://gist.github.com/0si43/9e4aa041bdeaef62846aafa87d1e8d06

Discussion