为什么这个 SwiftUI 状态在作为非绑定参数传递时没有更新?

huangapple go评论57阅读模式
英文:

Why is this SwiftUI state not updated when passed as a non-binding parameter?

问题

以下是翻译的部分:

这里是我的想法:

1. 拥有一个 SwiftUI 视图,它可以更改本地 State 变量。
2. 在按钮点击时,将该变量传递到我的应用程序的某个其他部分。

然而,由于某种原因,即使我更新了状态变量,当它传递到下一个视图时,它并没有得到更新。

下面是显示问题的一些示例代码:

struct NumberView: View {
    @State var number: Int = 1

    @State private var showNumber = false

    var body: some View {
        NavigationStack {
            VStack(spacing: 40) {
                Button {
                    number = 99
                    print(number)
                } label: {
                    Text("Change Number")
                }

                Button {
                    showNumber = true
                } label: {
                    Text("Show Number")
                }
            }
            .fullScreenCover(isPresented: $showNumber) {
                SomeView(number: number)
            }
        }
    }
}

struct SomeView: View {
    let number: Int

    var body: some View {
        Text("\(number)")
    }
}

如果您点击“Change Number”,它会更新本地状态为 99。但是,当我创建另一个视图并将其作为参数传递时,它显示的是 1 而不是 99。出现了什么问题?

需要注意的一些事项:

- 如果取消注释 `Text("\(number)")`,它会正常工作。但我认为这不应该是必需的。
- 如果将 `SomeView` 更改为使用 binding,它也会正常工作。但对于我的应用程序,这不适用。我的实际用例是一个“选择游戏选项”的视图。然后,我将创建一个非 SwiftUI 游戏视图,并希望将这些选项作为参数传递。因此,我不能在我的游戏代码中一路使用 binding,仅仅因为这个 bug。我希望只捕获用户输入的内容并创建一个带有该数据的 Parameters 对象。
- 如果将其更改为 `navigationDestination` 而不是 `fullScreenCover`,它也会正常工作。 ¯\_()_/¯ 对此没有头绪...

希望这对您有所帮助,如果您有其他问题,请随时提问。

英文:

Here's what I want to do:

  1. Have a SwiftUI view which changes a local State variable
  2. On a button tap, pass that variable to some other part of my application

However, for some reason, even though I update the state variable, it doesn't get updated when it's passed to the next view.

Here's some sample code which shows the problem:

struct NumberView: View {
    @State var number: Int = 1

    @State private var showNumber = false

    var body: some View {
        NavigationStack {
            VStack(spacing: 40) {
//              Text("\(number)")

                Button {
                    number = 99
                    print(number)
                } label: {
                    Text("Change Number")
                }

                Button {
                    showNumber = true
                } label: {
                    Text("Show Number")
                }
            }
            .fullScreenCover(isPresented: $showNumber) {
                SomeView(number: number)
            }
        }
    }
}

struct SomeView: View {
    let number: Int

    var body: some View {
        Text("\(number)")
    }
}

If you tap on "Change Number", it updates the local state to 99. But when I create another view and pass this as a parameter, it shows 1 instead of 99. What's going on?

Some things to note:

  • If you uncomment Text("\(number)"), it works. But this shouldn't be necessary IMO.
  • It also works if you make SomeView use a binding. But for my app, this won't work. My actual use case is a 'select game options' view. Then, I will create a non-SwiftUI game view and I want to pass in these options as parameters. So, I can't have bindings all the way down my gaming code just because of this bug. I want to just capture what the user enters and create a Parameters object with that data.
  • It also works if you make it a navigationDestination instead of a fullScreenCover. ¯\(ツ)/¯ no idea on that one...

答案1

得分: 1

以下是翻译好的部分:

"A View is a struct, therefore its properties are immutable, so the view can not change its own properties." - 视图是一个结构体,因此其属性是不可变的,因此视图不能更改自己的属性。

"This is why changing the property named number from inside the body of the view needs this property to be annotated with a @State property wrapper." - 这就是为什么要在视图的主体内更改名为 number 的属性时需要使用 @State 属性包装器进行标注。

"Thanks to Swift and SwiftUI, transparent read and write callbacks let the value being seen changed." - 由于Swift和SwiftUI的支持,透明的读写回调允许值被更改而被看到。

"So you must not pass number as a parameter of SomeView() when calling fullScreenCover(), but pass a reference to number, for the callbacks to be systematically called: $number." - 因此,在调用 fullScreenCover() 时,你不能将 number 作为 SomeView() 的参数传递,而必须传递对 number 的引用,以便系统地调用回调:$number

"Since you are not passing an integer anymore to construct struct SomeView, the type of the property named number in this struct can not any longer be an integer, but must be a reference to an integer (namely a binding): use the @Binding annotation for this." - 由于你不再传递整数来构建结构体SomeView,因此该结构体中名为number的属性的类型不再是整数,而必须是整数的引用(即绑定):使用@Binding进行注释。

"So, replace SomeView(number: number) by SomeView(number: $number) and let number: Int by @Binding var number: Int to do the job." - 因此,将SomeView(number: number)替换为SomeView(number: $number),将let number: Int替换为@Binding var number: Int即可完成任务。

"All of that said to obtain a valid source code, their is a little trick that has not been explained up to now: if you simply replace in your source code Text("Change Number") by Text("Change Number \(number)"), without using $ reference nor @Binding keywords anywhere, you will see that the problem is also automatically solved!" - 所有这些都是为了获得有效的源代码,现在还有一个小技巧尚未解释:如果你在源代码中简单地将Text("Change Number")替换为Text("Change Number \(number)"),而不在任何地方使用$引用或@Binding关键字,你会发现问题也会自动解决!

"No need to use @Binding in SomeView!" - 在SomeView中不需要使用@Binding

"This is because SwiftUI makes optimizations when building a tree of views." - 这是因为SwiftUI在构建视图树时进行了优化。

"All of that said again, one could argue that Apple says that you do not have to use @Binding to pass a value to a child view if this child view only wants to access the value..." - 所有这些再次说,可以争论的是,苹果表示如果子视图只想访问值,你不必使用@Binding将值传递给子视图...

"This is why you should definitely follow the @State and @Binding design pattern, taking into account that if you declare a state in a view that does not use it to display its content, you should declare this state as a @Binding in child views even if those children do not need to make changes to this state." - 这就是为什么你绝对应该遵循@State@Binding的设计模式,要考虑到,如果你在一个视图中声明了一个状态,而该状态不用于显示其内容,那么即使这些子视图不需要对该状态进行更改,你也应该将该状态声明为子视图中的@Binding。

"The best way to use @State is to declare it in the highest view that needs it to display something: never forget that @State must be declared in the view that owns this variable; creating a view that owns a variable but that does not have to use it to display its content is an anti-pattern." - 使用@State的最佳方式是在最需要它来显示内容的视图中声明它:永远不要忘记@State必须在拥有这个变量的视图中声明;创建一个拥有变量但不必使用它来显示内容的视图是一个反模式。

英文:

A View is a struct, therefore its properties are immutable, so the view can not change its own properties. This is why changing the property named number from inside the body of the view needs this property to be annotated with a @State property wrapper. Thanks to Swift and SwiftUI, transparent read and write callbacks let the value being seen changed. So you must not pass number as a parameter of SomeView() when calling fullScreenCover(), but pass a reference to number, for the callbacks to be systematically called: $number. Since you are not passing an integer anymore to construct struct SomeView, the type of the property named number in this struct can not any longer be an integer, but must be a reference to an integer (namely a binding): use the @Binding annotation for this.

So, replace SomeView(number: number) by SomeView(number: $number) and let number: Int by @Binding var number: Int to do the job.

Here is the correct source code:

import SwiftUI

struct NumberView: View {
    @State var number: Int = 1

    @State private var showNumber = false

    var body: some View {
        NavigationStack {
            VStack(spacing: 40) {
//              Text("\(number)")

                Button {
                    number = 99
                    print(number)
                } label: {
                    Text("Change Number")
                }

                Button {
                    showNumber = true
                } label: {
                    Text("Show Number")
                }
            }
            .fullScreenCover(isPresented: $showNumber) {
                SomeView(number: $number)
            }
        }
    }
}

struct SomeView: View {
    @Binding var number: Int

    var body: some View {
        Text("\(number)")
    }
}

After all that said to obtain a valid source code, their is a little trick that has not been explained up to now: if you simply replace in your source code Text("Change Number") by Text("Change Number \(number)"), without using $ reference nor @Binding keywords anywhere, you will see that the problem is also automatically solved! No need to use @binding in SomeView! This is because SwiftUI makes optimizations when building a tree of views. If it knows that the displayed view changed (not only its properties), it will compute the view with updated @State values. Adding number to the button label makes SwiftUI track changes of the number state property and it now updates its cached value to display the Text button label, therefore this new value will be correctly used to create SomeView. All of that may be considered as strange things, but is simply due to optimizations in SwiftUI. Apple does not fully explain how it implements optimizations building a tree of views, there are some informations given during WWDC events but the source code is not open. Therefore, you need to strictly follow the design pattern based on @State and @Binding to be sure that the whole thing works like it should.

All of that said again, one could argue that Apple says that you do not have to use @Binding to pass a value to a child view if this child view only wants to access the value: share the state with any child views that also need access, either directly for read-only access, or as a binding for read-write access (https://developer.apple.com/documentation/swiftui/state). This is right, but Apple says in the same article that you need to place [state] in the highest view in the view hierarchy that needs access to the value. With Apple, needing to access a value means that you need it to display the view, not only to do other computations that have no impact on the screen. This is this interpretation that lets Apple optimize the computation of the state property when it needs to update NumberView, for instance when computing the content of the Text("Change Number \(number)") line. You could find it really tricky. But there is a way to understand that: take the initial code you wrote, remove the @State in front of var number: Int = 1. To compile it, you need to move this line from inside the struct to outside, for instance at the very first line of your source file, just after the import declaration. And you will see that it works! This is because you do not need this value to display NumberView. And thus, it is perfectly legal to put the value higher, to build the view named SomeView. Be careful, here you do not want to update SomeView, so there is no border effects. But it would not work if you had to update SomeView.

Here is the code for this last trick:

import SwiftUI

// number is declared outside the views!
var number: Int = 1

struct NumberView: View {
    // no more state variable named number!
    // No more modification: the following code is exactly yours!

    @State private var showNumber = false
    
    var body: some View {
        NavigationStack {
            VStack(spacing: 40) {
//              Text("\(number)")

                Button {
                    number = 99
                    print(number)
                } label: {
                    Text("Change Number")
                }

                Button {
                    showNumber = true
                } label: {
                    Text("Show Number")
                }
            }
            .fullScreenCover(isPresented: $showNumber) {
                SomeView(number: number)
            }
        }
    }
}

struct SomeView: View {
    let number: Int
    var body: some View {
        Text("\(number)")
    }
}

This is why you should definitely follow the @State and @Binding design pattern, taking into account that if you declare a state in a view that does not use it to display its content, you should declare this state as a @Binding in child views even if those children do not need to make changes to this state. The best way to use @State is to declare it in the highest view that needs it to display something: never forget that @State must be declared in the view that owns this variable; creating a view that owns a variable but that does not have to use it to display its content is an anti-pattern.

答案2

得分: 1

因为`number`没有在主体中读取,SwiftUI的依赖跟踪会检测到它。你可以像这样对其进行激活:

.fullScreenCover(isPresented: $showNumber) { [number] in

现在,每当`number`发生变化,将会创建一个带有更新后的`number`值的新闭包。顺便提一下,`[number] in` 的语法称为 "捕获列表",可以在[这里][1]了解更多。

  [1]: https://docs.swift.org/swift-book/documentation/the-swift-programming-language/closures#Capturing-Values
英文:

Since number isn't read in body, SwiftUI's dependency tracking detect it. You can give it a nudge like this:

.fullScreenCover(isPresented: $showNumber) { [number] in

Now a new closure will be created with the updated number value whenever number changes. Fyi the [number] in syntax is called a "capture list", read about it here.

答案3

得分: 0

Nathan Tannar通过另一个渠道给了我这个解释,我认为这是我问题的关键所在。似乎这是 SwiftUI 的一种怪异行为,因为它知道何时以及如何根据状态更新视图。谢谢,Nathan!

这是因为数字没有在视图的主体中“读取”。SwiftUI 是智能的,只有在视图的依赖项更改时才触发视图更新。这为 fullScreenCover 修改器造成问题的原因是因为它捕获了主体的 @escaping 闭包。这意味着直到弹出封面时才会读取它。由于它没有被读取,视图主体在 @State 更改时不会重新评估,您可以通过在视图主体中设置断点来验证这一点。由于视图主体不会重新评估,@escaping 闭包永远不会被重新捕获,因此它将保持原始值的副本。

另外,您会发现,一旦首次呈现封面,然后关闭,随后的呈现将会正确更新。
可以说,这似乎是 SwiftUI 的一个 bug,fullScreenCover 可能不应该是 @escaping。您可以通过在主体内部读取数字,或者用类似这样的东西包装修改器来解决这个问题,因为这里的 destination 没有 @escaping 捕获,所以数字将在视图主体评估中被读取。

struct FullScreenModifier<Destination: View>: ViewModifier {
    @Binding var isPresented: Bool
    @ViewBuilder var destination: Destination
    func body(content: Content) -> some View {
        content
            .fullScreenCover(isPresented: $isPresented) {
                destination
            }
    }
}
英文:

Nathan Tannar gave me this explanation via another channel which I think gets to the crux of my problem. It does seem that this is a SwiftUI weirdness caused by knowing when and how it updates views based on state. Thanks Nathan!

It’s because the number isn’t “read” in the body of the view. SwiftUI is smart in that it only triggers view updates when a dependency of the view changes. Why this causes issues with the fullScreenCover modifier is because it captures an @escaping closure for the body. Which means it’s not read until the cover is presented. Since its not read the view body will not be re-evaluated when the @State changes, you can validate this by setting a breakpoint in the view body. Because the view body is not re-evaluated, the @escaping closure is never re-captured and thus it will hold a copy of the original value.

As a side note, you’ll find that once you present the cover for the first time and then dismiss, subsequent presentations will update correctly.
Arguably this seems like a SwiftUI bug, the fullScreenCover probably shouldn’t be @escaping. You can workaround by reading the number within the body, or wrapping the modifier with something like this, since here destination is not @escaping captured so the number will be read in the views body evaluation.

struct FullScreenModifier&lt;Destination: View&gt;: ViewModifier {
    @Binding var isPresented: Bool
    @ViewBuilder var destination: Destination
    func body(content: Content) -&gt; some View {
        content
            .fullScreenCover(isPresented: $isPresented) {
                destination
            }
    }
}

huangapple
  • 本文由 发表于 2023年2月19日 16:45:02
  • 转载请务必保留本文链接:https://go.coder-hub.com/75498944.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定