Very odd SwiftUI.View.onAppear() behavior: something to do with how Swift work?

Two seemingly identical .onAppear() on NavigationView root view but behave very differently: one is working correctly, the other incorrectly:

import SwiftUI

struct Child: View {
    var body: some View {
        Text("Child View")
            .navigationTitle("Child")
    }
}

struct ContentView: View {
    @State var timerEventChangeThis = 0
    private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    // Bad: doing nothing here and this is called on timer fire while away to child view!!!
    // nothing but only timer firing, this is called whle in child view
    func doOnAppear() {
        print("๐Ÿ‘‰๐Ÿ‘‰๐Ÿ‘‰doOnAppear๐Ÿ‘ˆ๐Ÿ‘ˆ๐Ÿ‘ˆ ๐Ÿ‘Žif you navigate to Child view, this should not be called until pop back๐Ÿคฎ๐Ÿฅบ")
    }

    static func staticDoOnAppear() {
        print("๐Ÿ‘๐Ÿ‘ŒstaticDoOnAppear๐Ÿ‘Œ๐Ÿ‘")
    }

    var body: some View {
        NavigationView {
            NavigationLink(">>>> Child", destination: Child())
                .navigationTitle("\(timerEventChangeThis)")

                // the following three .onAppear is the same, but ...
                // this .onAppear works correctly: it's called at the right time when pop back
                .onAppear { print("๐Ÿ˜ณ onAppear...") }
                // ๐Ÿฅด๐Ÿฅด this onAppear is called when timer fire,  while away to child view !!
                .onAppear(perform: doOnAppear)
                // and static func works correctly, too:
                .onAppear(perform: Self.staticDoOnAppear)
        }
        .onReceive(timer) { _ in timerEventChangeThis += 1 }
    }
}

what makes the second .onAppear bad? (Edit: and static func is fine!): Is this due to something how Swift work? I just cannot see anything in SwiftUI that can tell the two form apart and do thing differently.

This is what originally got me in this wild chase: simply reference a ObservableObject in the .onAppear closure cause problem:

import SwiftUI

final class AppState: ObservableObject {
    @Published var counter = 0  // nothing is changing this
}

@main
struct TimerTriggerOnAppearFuncIsEvenWorstApp: App {
    @StateObject var appState = AppState()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(appState)
        }
    }
}

struct Child: View {
    var body: some View {
        Text("Child View")
            .navigationTitle("Child")
    }
}

struct ContentView: View {
    @State var timerEventChangeThis = 0
    private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    @EnvironmentObject var appState: AppState

    var body: some View {
        NavigationView {
            NavigationLink(">>>> Child", destination: Child())
            .navigationTitle("\(timerEventChangeThis)")
            // ๐Ÿ˜ฉ๐Ÿ˜ฉ problem here:
            // when navigate to child, on timer fire mutate `timerEventChangeThis`
            // onAppear() is called while you are stil in Chid View due to simply `_ = appState.counter`!!!
            // set empty capture list just to be sure this problem it's not due to capture
            .onAppear { [] in
                print("๐Ÿ˜พ๐Ÿ˜พ๐Ÿ˜พonAppear๐Ÿ˜พ๐Ÿ˜พ๐Ÿ˜พ ๐Ÿ‘Žif you navigate to Child view, this should not be called until pop back๐Ÿคฎ๐Ÿฅบ")
                // ๐Ÿ‘Ž๐Ÿž๐Ÿž
                // just have this ObservableObject here cause the problem!!
                // commented this out, problem goes away
                _ = appState.counter
                // this also cause problem:
//                if appState.counter == 100 {
//                    // nothing
//                }
            }
        }
        .onReceive(timer) { _ in timerEventChangeThis += 1 }
    }
}

What could possibly be the reason for such odd behavior? There is no way that I can see someone can write code to do such thing!

I think this is a bug in SwiftUI. I use this to overcome the issue

extension View {
    public func onAppearFix(perform action: (() -> Void)? = nil ) -> some View {
        self.overlay(UIKitAppear(action: action).disabled(true))
    }
}

private struct UIKitAppear: UIViewControllerRepresentable {
	let action: (() -> Void)?

	func makeUIViewController(context: Context) -> Controller {
		let vc = Controller()
		vc.action = action
		return vc
	}

	func updateUIViewController(_ controller: Controller, context: Context) {}

	class Controller: UIViewController {
		var action: (() -> Void)? = nil

		override func viewDidLoad() {
			view.addSubview(UILabel())
		}

		override func viewDidAppear(_ animated: Bool) {
			action?()
		}
	}
}
1 Like

Thank you very much for confirming this is a bug and your fix!

So anytime the onAppear closure refer to self then this bug occur. Was so confusing to me when I added .onAppear to my root view and this bug is happening.

I don't know for sure if it's actually a bug or we misuse it.

What do you mean when you say refers to self ? (self is used in the closure or ou apply the modifier on self)
I didn't manage to find a way to know if it will work or not so I always use my fix.

Swift closure can carry with it self so it can refer to the struct instance.

struct Foo: View {
    let someVar = 1

    func someFunc() { }

    static func someStaticFunc() { }

    var body: some View {
        Text("Parent")
             // these two trigger the bug
            .onAppear { _ = someVar }      // this closure carries `self` so it can refer to `someVar`
            .onAppear(perform: someFunc)   // `someFunc` carries `self`

            // these two doesn't trigger the bug
            .onAppear { print("here") }    // this closure does not carry `self`
            .onAppear(perform: someStaticFunc) // this does not carry `self`
    }
}

Got it thanks !

I made small change to your UIKitAppear:

  1. action is not optional, default to { }
  2. add Controller.init(_ action: @escaping () -> Void) so can just do Controller(action)
  3. I don't know UIKit at all, but this look odd:
override func viewDidLoad() {
    view.addSubview(UILabel())
}

I comment out view.addSubview(UILabel()) and seem to still work.

private struct UIKitAppear: UIViewControllerRepresentable {
    let action: () -> Void

    func makeUIViewController(context: Context) -> Controller { Controller(action) }

    func updateUIViewController(_ controller: Controller, context: Context) { }

    class Controller: UIViewController {
        let action: () -> Void

        init(_ action: @escaping () -> Void) {
            self.action = action
            super.init(nibName: nil, bundle: nil)
        }

        // 'required' initializer 'init(coder:)' must be provided by subclass of 'UIViewController'
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }


        override func viewDidLoad() {
//            view.addSubview(UILabel())
        }

        override func viewDidAppear(_ animated: Bool) {
            action()
        }
    }
}

I must say that I compiled this from different sources while looking for a solution. I'm pretty sure the viewDidload override had a use (something like forcing the view to draw) but I'm not sure. It might just be debug garbage :)

Problem is fixed Xcode 13 beta 5

1 Like