This article will explore State injection optimization mechanism, the generation timing of modal views (Sheet, FullScreenCover), and data coordination between different contexts (mutually independent view trees) through a reproducible piece of “mysterious code”.
The code for this article can be found here.
Issue
Recently, user Momo6 asked the following question in the chatroom:
In the code below, if the Text("n = \(n)")
code in ContentView is commented out, the n displayed in the fullScreenCover view remains 1 after pressing the button (n is set to 2). If this line of code is not commented out, “n = 2” will be displayed in the fullScreenCover (as expected). Why is this happening?
struct ContentView: View {
@State private var n = 1
@State private var show = false
var body: some View {
VStack {
// If the following line of Text code is commented out,
// the Text in full-screen will still display n = 1 after pressing the Button (n = 2)
// Text("n = \(n)") // Uncomment this line to display "n = 2" in the sheet
Button("Set n = 2") {
n = 2
show = true
}
}
.fullScreenCover(isPresented: $show) {
VStack {
Text("n = \(n)")
Button("Close") {
show = false
print("n in fullScreenCover is", n) // This will always print as 2, regardless of whether the above Text is commented out or not
}
}
}
}
}
For clarity in demonstration purposes, I have changed fullScreenCover to sheet (which does not affect the above phenomenon), and added a ButtonStyle to the Button.
Here, it is suggested to take a few minutes break and see if you can figure out the problem?
Problem Composition
Although it may seem strange, the addition or absence of Text does indeed affect the display content in Sheet views. The reason for this phenomenon is due to the optimization mechanism injected by State, the lifecycle of Sheet (FullScreenCover) views, and the creation of new contexts.
Optimization Mechanism Injected by State
In SwiftUI, for reference types, developers can inject them into views through @StateObject, @ObservedObject, or @EnvironmentObject. For dependencies injected in this way, as long as the objectWillChange.send() method of the instance is called, the associated views will be forced to refresh (recompute body values) regardless of whether the instance’s properties are used in the body of the view.
In contrast, for value types, the main injection method @State in SwiftUI implements a highly optimized mechanism (EnvironmentValue does not provide optimization and behaves the same as reference type injection). This means that even if we declare a variable marked with @State in the struct defining the view, if the property is not used in the body (using syntax supported by ViewBuilder), the view will not be refreshed even if the property changes.
struct StateTest: View {
@State var n = 10
var body: some View {
VStack {
let _ = print("update")
Text("Hello")
Button("n = n + 1") {
n += 1
print(n)
}
}
}
}
In the animation below, the body of the StateTest view will not be recalculated even if the value of n changes, as long as n is not included in Text. When a reference to n is added in Text, the view will be updated every time n value changes.
By observing the State source data of the loaded view, we can see that State contains a private _wasRead property, which becomes true after it is associated with any view.
Returning to our current “problem” code:
struct ContentView: View {
@State private var n = 1
@State private var show = false
var body: some View {
VStack {
// Text("n = \(n)") // Comment out this line, and the n value displayed in the sheet will be 1 (not the expected 2)
Button("Set n = 2") {
n = 2
show = true
}
.buttonStyle(.bordered)
}
.sheet(isPresented: $show) {
VStack {
Text("n = \(n)")
Button("Close") {
show = false
print("n in fullScreenCover is", n)
}
.buttonStyle(.bordered)
}
}
}
}
When we add the Text in ContentView, modifying n in the Button will trigger the re-evaluation of the body, whereas without the Text, it won’t. This is why whether or not to add the Text (which references n in the body) affects whether the body will be re-evaluated.
Lifecycle of Sheet (FullScreenCover) View
Perhaps someone may ask, in the code of sheet, Text also includes a reference to n. Will this reference establish a relationship between n and the ContentView view?
Unlike most View Extensions and ViewModifiers, the closure of the modal view content declared through .sheet
or .fullScreenCover
in the view will only be called and resolved (evaluate the View in the closure) when the modal view is displayed.
Other code blocks declared through view modifiers will perform certain operations when the main view body is evaluated:
- overlay, background, etc. will be called and resolved when the body is evaluated (because they need to be displayed together with the main view).
- alert, contextMenu, etc. will also be called during the body evaluation (can be understood as creating an instance), but will only be evaluated when it needs to be displayed.
This means that even if we add a reference to n in the Text block of the Sheet code, as long as the modal view has not been displayed, n’s _wasRead is still false (and has not been associated with the view).
To demonstrate the above statement, we wrap the code in Sheet in a struct that conforms to the View protocol for our observation.
struct AnalyticsView: View {
@State private var n = 1
@State private var show = false
var body: some View {
let _ = print("Parent View update") // main view body evaluation
VStack {
// Text("n = \(n)") // After commenting out this line, n in sheet displays as 1 (not the expected 2)
Button("Set n = 2") {
n = 2
show = true
}
.buttonStyle(.bordered)
}
.sheet(isPresented: $show) {
SheetInitMonitorView(show: $show, n: n)
}
}
}
struct AnalyticsViewPreview: PreviewProvider {
static var previews: some View {
AnalyticsView()
}
}
struct SheetInitMonitorView: View {
@Binding var show: Bool
let n: Int
init(show: Binding<Bool>, n: Int) {
self._show = show
self.n = n
print("sheet view init") // Create an instance (indicating that the closure of sheet is called)
}
var body: some View {
let _ = print("sheet view update") // sheet view evaluation
VStack {
Text("n = \(n)")
Button("Close") {
show = false
print("n in fullScreenCover is", n)
}
.buttonStyle(.bordered)
}
}
}
From the output, we can see that when evaluating ContextView for the first time (printing Parent View update
), SheetInitMonitorView in the Sheet code block does not output anything (meaning the closure is not called), and only when the modal view is displayed, SwiftUI executes the function in the .sheet
closure to create the Sheet view.
Going back to the original code:
.fullScreenCover(isPresented: $show) {
VStack {
Text("n = \(n)")
Button("Close") {
show = false
print("n in fullScreenCover is", n) // Regardless of whether the above Text is commented out, this will always print as 2.
}
}
}
Although we referenced n in Text through .fullScreenCover
, this code block will not be called when evaluating ContextView, so it will not associate n with ContextView.
When Text is not included in ContextView, the value of n’s _wasRead will change to true after Sheet is displayed (after Sheet view is displayed, the association is created). You can view it by adding the following code in Button:
Button("Set n = 2") {
n = 2
show = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1){ // Delay is to ensure that the view in Sheet has been created
dump(_n)
}
}
Context of Sheet View
When SwiftUI creates and displays a Sheet view, it does not create a branch on the existing view tree, but creates a new independent view tree. This means that the views in Sheet and the original views are in different contexts.
In early versions of SwiftUI, developers had to explicitly inject environment dependencies for the Sheet view tree when they were located in separate independent view trees in different contexts. Later versions have automatically done this injection for developers.
This means that rebuilding the view tree in a new context is more expensive and requires more work than creating a branch in the original view tree.
To optimize efficiency, SwiftUI typically merges several operations. Even if the association operation for the views in the new context is completed before the view evaluation operation, because the change in “n” and the association operation are concentrated in a Render Loop, this will not force the newly associated views to refresh after association (the value has not changed after association).
Phenomenon Analysis
Based on the content introduced above, we will complete a comprehensive analysis of the strange phenomenon in the code of this article:
When ContextView does not contain Text (ContextView is not associated with n)
- When the program runs, SwiftUI evaluates and renders the body of ContextView.
- The closure of
.fullScreenCover
is not called at this time, but captures the current value of n in the view (n = 1). - After clicking the Button, although the content of n changes, the body of ContextView is not re-evaluated.
- Because show changes to true, SwiftUI begins to call the closure of
.fullScreenCover
to create a Sheet view.
Although show is also declared through State, the change of show does not cause ContextView to be updated. This is because in the constructor of .fullScreenCover
, we pass the projectedValue of show (Binding type).
- Due to the reason of merging operations, the Sheet view will not be updated after being associated with n.
- The Text in Sheet displays n = 1.
- After clicking the Close button in Sheet, the closure of Button is executed, and the current value of n is obtained again (n = 2), and the value of 2 is printed.
When Text is included in ContextView (ContextView is associated with n)
- When the program runs, SwiftUI evaluates and renders the body of ContextView.
- The closure of
.fullScreenCover
is not called yet, but it captures the current value of n (n = 1). - After clicking the Button, since the value of n has changed, ContextView is re-evaluated (i.e., DSL code is re-parsed).
- During re-evaluation, the closure of
.fullScreenCover
captures the new value of n (n = 2). - The Sheet view is created and rendered.
- Since the closure of
.fullScreenCover
has already captured the new value, the Text of Sheet displays n = 2.
In other words, by adding Text and associating ContextView with n, when the value of n changes, ContextView is re-evaluated, allowing the closure of fullScreenCover
to capture the changed value of n and display the expected result.
Solution
After understanding the cause of the “exception”, it is no longer difficult to solve and avoid similar strange phenomena from happening again.
Solution 1: Associate in DSL and Force Refresh
In the original code, adding Text as a ContextView and creating an association between it and n is an acceptable solution.
In addition, we can also create an association without adding additional display content:
Button("Set n = 2") {
n = 2
show = true
}
.buttonStyle(.bordered)
// .id(n)
.onChange(of:n){_ in } // id or onChange can create associations without adding display content
In the tweet about creating a self-adaptive height Sheet, I used id to solve the problem of resetting Sheet height.
Option 2, use @StateObject to force refresh
We can avoid the order errors that may occur when associating State between different contexts by creating a reference type Source. In fact, using @StateObject is equivalent to forcing the view to recalculate after the change of vm.n.
struct Solution2: View {
@StateObject var vm = VM()
@State private var show = false
var body: some View {
VStack {
Button("Set n = 2") {
vm.n = 2
show = true
}
.buttonStyle(.bordered)
}
.sheet(isPresented: $show) {
VStack {
Text("n = \(vm.n)")
Button("Close") {
show = false
print("n in fullScreenCover is", vm.n)
}
.buttonStyle(.bordered)
}
}
}
}
class VM: ObservableObject {
@Published var n = 1
}
Solution 3: Use Binding Type to Retrieve New Values
We can view the Binding type as a wrapper for the get and set methods of a certain value. When the Sheet view is evaluated, it will obtain the latest value of n through the get method of Binding.
The get method in Binding corresponds to the original address of n in ContextView, without the need for re-injection into Sheet, so the latest value can be obtained during the evaluation phase.
struct Solution3: View {
@State private var n = 1
@State private var show = false
var body: some View {
VStack {
Button("Set n = 2") {
n = 2
show = true
}
.buttonStyle(.bordered)
}
.sheet(isPresented: $show) {
SheetView(show: $show, n: $n)
}
}
}
struct SheetView:View {
@Binding var show:Bool
@Binding var n:Int
var body: some View {
VStack {
Text("n = \(n)")
Button("Close") {
show = false
print("n in fullScreenCover is", n)
}
.buttonStyle(.bordered)
}
}
}
Solution 4: Delayed Data Update
By delaying the modification of the n value (i.e. modifying it only after evaluating and associating the data in Sheet view), Sheet view can be forced to re-evaluate.
struct Solution4: View {
@State private var n = 1
@State private var show = false
var body: some View {
VStack {
Button("Set n = 2") {
// A slight delay can achieve the effect
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01 ){
n = 2
}
show = true
}
.buttonStyle(.bordered)
}
.sheet(isPresented: $show) {
VStack {
Text("n = \(n)")
Button("Close") {
show = false
print("n in fullScreenCover is", n)
}
.buttonStyle(.bordered)
}
}
}
}
Summary
Although SwiftUI has developed to version 4.0, there are still some unexpected behaviors. When facing these “strange phenomena”, if we can conduct more research on them, we can not only avoid similar problems in the future, but also have a deep understanding of various operating mechanisms of SwiftUI in the analysis process.