Open2

[SwiftUI] AppStorageの変更がGeometryReaderより下に伝わらないバグ

kntkymtkntkymt

実行環境

Xcode 15.0 (15A240d)
iPhone 15 Pro (17.0) Simulator

現象

struct Preview: View {
    @AppStorage("count")
    var count = 0

    var body: some View {
        let _ = print("body")
        GeometryReader { reader in
            let _ = print("under geometry")
            Button {
                count += 1
            } label: {
                Text(count.description)
                    .font(.largeTitle)
                    .frame(width: reader.size.width, height: reader.size.height)
            }
        }
    }
}

countの表示が更新されない。printを入れてデバッグしてみると
そもそもprint("under geometry")が(初回以降)呼ばれていないことがわかった。
この挙動は、GeometryReaderのEquatableがtrueになっているのでは?と推測
参考: https://qiita.com/fuziki/items/6fe7a304b30146ba43c7

なおStateだと正常に動作する。意味不明

kntkymtkntkymt

GeometryReaderの定義を見ると、クロージャーだけを持つViewっぽい

public var content: (SwiftUI.GeometryProxy) -> Content

https://github.com/xybp888/iOS-SDKs/blob/9443257262ad5c51615a3af6e781682ccbeffb47/iPhoneOS17.0.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64e-apple-ios.swiftinterface#L14887

これを模したRowと言うViewを自作し、検証していく
↓検証コード

struct RowPreview: View {
    @AppStorage("count")
    var count = 0

    var body: some View {
        let _ = print("body")
        Row {
            let _ = print("under row")

            Button {
                count += 1
            } label: {
                Text(count.description)
            }
        }
    }
}

クロージャー

struct Row<Content: View>: View {

    let view: () -> Content

    init(@ViewBuilder view: @escaping () -> Content) {
        self.view = view
    }

    var body: some View {
	let _ = print("row body")
        view()
    }
}
  • 更新されない
    • under row, row bodyが呼ばれない
    • Stateだと呼ばれる

クロージャー+引数

struct Row<Content: View>: View {

    let view: (Int) -> Content

    init(@ViewBuilder view: @escaping (Int) -> Content) {
        self.view = view
    }

    var body: some View {
	let _ = print("row body")
        view(Int.random(1...100)) // 毎回の呼び出しで引数が異なるように
    }
}

多分これがGeometryReaderと同じ形

  • 更新されない
    • 引数が違うとかは関係なさそう
    • まあそもそも"under row"すら呼ばれてないので当たり前か

クロージャー+他に変数

struct Row<Content: View>: View {

    let count: Int
    let view: (Int) -> Content

    init(@ViewBuilder view: @escaping (Int) -> Content) {
        self.view = view
	self.count = Int.random(1...100)
    }

    var body: some View {
	let _ = print("row body")
        view(count)
    }
}
  • 更新される
    • 変数のcountがEquatableに影響してfalseになっているから

Viewにする

struct Row<Content: View>: View {

    let view: Content

    init(@ViewBuilder view: @escaping () -> Content) {
        self.view = view()
    }

    var body: some View {
        view
    }
}
  • 更新される
    • ViewならEquatableでfalseにしてくれるのか?

クロージャー + Equatable

struct Row<Content: View>: View, Equatable {
    static func == (lhs: Row<Content>, rhs: Row<Content>) -> Bool {
        false
    }

    let view: () -> Content

    init(@ViewBuilder view: @escaping () -> Content) {
        self.view = view
    }

    var body: some View {
        let _ = print("row body")
        view()
    }
}
  • 更新される
    • Equatableを明示的にfalseにしているから

考察

  • クロージャーだけを変数にもち、Equatableに準拠していないViewの場合、謎の判定基準によりEquatableがtrueになる。
  • AppStorageだと起こるがStateだと起こらず、GeometryReaderとかScrollViewReaderのSwiftUIのViewでもなるのは流石にAppStorageのバグでしょ

対処方法

  • AppStorageを使わない
  • 自作Viewだけの問題ならEquatableを実装して常にfalseにする