肘子的 Swift 记事本

Timing of onAppear Invocation

Published on

Get weekly handpicked updates on Swift and SwiftUI!

onAppear( task ) is a modifier frequently used by SwiftUI developers, but there has not been any authoritative documentation specifying when its closure is invoked. This article will prove, with the new API provided by SwiftUI 4, that onAppear is invoked after layout but before rendering.

Problem

Similar to many previous blogs, we will start with a problem from a question in the chatroom.

https://cdn.fatbobman.com/image-20230328163706115.png

  • code 1: Why does it say “array out of bounds” when running, it seems like onAppear did not execute.

    code 2: If changing VStack to List+ForEach removes the error and the onAppear code has not been modified, it is not clear what the reason for this is.

Ignore whether the writing style in the example is reasonable or recommended, and only consider why there is an array out-of-bounds situation in the first code paragraph; and why the second code paragraph can run correctly.

Creating instances, evaluating, layout, and rendering

In SwiftUI, a view usually goes through four stages in its lifecycle:

Creating instances

This is a stage that most views in the displayable branch of the view tree will go through. In the lifetime of a view, SwiftUI may create view instances multiple times.

Due to the optimization mechanism of lazy views, SwiftUI will not create their instances for subviews that are not yet in the visible area.

Evaluation

A displayed view will go through the process at least once. Since the view in SwiftUI is actually a function, SwiftUI needs to evaluate the view (call the body property) and retain the calculation result. When the view’s dependency (source of truth) changes, SwiftUI will recalculate the view result value and compare it with the old value. If a change occurs, the old value is replaced with the new value.

Layout

After calculating all the view values that need to be displayed, SwiftUI will enter the layout phase. Through the process of the parent view providing suggested sizes to the child views and the child views returning required sizes, the final layout result is calculated.

For the process of layout, please read SwiftUI Layout: The Mystery of Size.

Rendering

SwiftUI will render the view on the screen by calling lower-level APIs. This process is strictly beyond the scope of SwiftUI’s management.

Who is Appear relative to?

In many dictionaries, “appear” is explained as “to come into sight; become visible,” which can make developers mistakenly think that onAppear is called after the view is rendered (when the user sees it). But in SwiftUI, onAppear is actually called before rendering.

Assuming that Apple did not make a naming mistake, at this point, “appear” is more like for the SwiftUI system. After the view completes the process of creating instances, evaluating, and layout (the management process of the SwiftUI architecture is completed), it can be said that the view has “appeared” in front of the SwiftUI.

Evidence

In this section, we will use evidence to prove the above inference.

When we wrote about the lifecycle of a SwiftUI view, we could only infer the timing of onAppear’s call through observation. With the continuous improvement of versions, SwiftUI 4 provides us with enough tools to obtain more solid evidence.

Determine if the view is being evaluated

Adding code similar to the following in the view is a common way for us to determine whether SwiftUI is evaluating the view:

Swift
VStack {
  let _ = print("evaluate")
}

Determine if a view is currently in the layout phase

In version 4.0, SwiftUI introduced the Layout protocol that allows us to create custom layout containers. By creating instances that conform to this protocol, we can determine whether the current view is currently in the layout phase.

Swift
struct MyLayout: Layout {
    let name: String
    func sizeThatFits(proposal _: ProposedViewSize, subviews _: Subviews, cache _: inout ()) -> CGSize {
        print("\(name) layout")
        return .init(width: 100, height: 100)
    }

    func placeSubviews(in _: CGRect, proposal _: ProposedViewSize, subviews _: Subviews, cache _: inout ()) {}
}

The above code creates a layout container that always returns a fixed size of 100 * 100 when its parent view asks for its size, and reports it to us through the console.

Checking if the view is ready to be rendered

Although SwiftUI views do not provide an API to show this process, we can use the UIViewControllerRepresentable protocol to wrap a UIViewController, and determine the current state through its lifecycle callback methods.

Swift
struct ViewHelper: UIViewControllerRepresentable {
    func makeUIViewController(context _: Context) -> HelperController {
        return HelperController()
    }

    func updateUIViewController(_: HelperController, context _: Context) {
    }

    // new api in SwiftUI 4 
    func sizeThatFits(_: ProposedViewSize, uiViewController _: HelperController, context _: Context) -> CGSize? {
        print("helper layout")
        return .init(width: 50, height: 50)
    }
}

final class HelperController: UIViewController {
    override func viewWillAppear(_: Bool) {
        print("will appear(render)")
    }
}

In the above code, the timing of the sizeThatFits and Layout protocol’s sizeThatFits calls are consistent, both accessed during the layout process when the parent view asks for the required size of the child view. On the other hand, viewWillAppear is called before the UIViewController is presented (it can be understood as before rendering) and is called by UIKit.

The “view” wrapped by UIViewControllerRepresentable is not a real view. For SwiftUI, it is a black hole that provides the required size, so there is no evaluation involved.

Integration

With the tools mentioned above, we can now fully understand the processing of a SwiftUI view and the timing of onAppear call with the following code.

Swift
struct LayoutTest: View {
    var body: some View {
        MyLayout(name: "outer") {
            let _ = print("outer evaluate")
            MyLayout(name: "inner") {
                let _ = print("inner evaluate")
                ViewHelper()
                    .onAppear {
                        print("helper onAppear")
                    }
            }
            .onAppear {
                print("inner onAppear")
            }
        }
        .onAppear {
            print("outer onAppear")
        }
    }
}

输出如下:

Bash
outer evaluateinner evaluateouter layoutinner layouthelper layoutouter onAppearhelper onAppearinner onAppearwill appear(render)

Analysis

From the above output, we can clearly understand the entire process of view processing:

  • SwiftUI first evaluates the view (from outermost to innermost)
  • After all evaluations are completed, it starts layout (from parent view to child view)
  • After layout is completed, the onAppear closure corresponding to the view is called (the order is unknown, do not assume the execution order between onAppear)
  • Render the view

Thus, it can be proved that onAppear is indeed called after layout and before rendering.

Answer

Going back to the initial question of this article.

First block of code

  • Evaluate VStack
  • Calculate to Text and create Text instance
  • When creating an instance, getWord needs to be called to get the parameter
  • At this time, because the newWords array is empty, an array index out of bounds error occurs

That is to say, when the first block of code reports an error, the view has not even entered the layout phase, let alone calling onAppear.

Without considering whether the absolute index value is correct, the following code can avoid the problem:

Swift
if !newWords.isEmpty {
    Text(getWord(at:0))
}

Second Code Block

  • Evaluate the List
  • Since ForEach processes subviews based on the number of newWords, there will be no problem even if newWords is empty at this point
  • Complete the layout
  • Call the onAppear closure and assign a value to newWords
  • Since newWords is the source of truth for this view, changes to it will cause the view to refresh
  • Repeat the above process, at this point newWords already has a value, and ForEach will process all subviews normally

Conclusion

In this article, we clarified the timing of onAppear calls using new tools provided by SwiftUI 4, which may be an application of functionality that was not thought of when developing new APIs.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways