How onAppear/onDisappear closures work in iOS

Mike Olson
7 min readMay 10, 2024

--

This post is to document some obscure stuff I learned about UIKit in iOS. I spent a bunch of time puzzling this out, and hope to save others the trouble I went to. If you’re not building mobile apps for the iPhone, you can reasonably skip this one.

Screenshot of the MultiClock app running on iPhone

I built my MultiClock app in Xcode, using Swift and SwiftUI. I specifically target the iPhone, but of course it runs fine on the iPad as well. As of this writing, I’m using Xcode 15.3, targeting iOS 16.0 and later. The code I describe is available on my github, and I’m talking about release v2.3 here.

One of my users proposed a new feature for the app: Include a new setting that keeps the display from dimming and going dark. That way, if you want to use the app as a desk clock, you don’t have to touch it every twenty seconds or so to keep it live. The setting should default to off, of course, so spare the battery, but users who want that behavior should be able to get it.

Seemed like a great idea! Also it gave me another reason to fire up Xcode and hack away on my app. That’s a good day!

My app uses a class instance that already included public variables that get set from the UserDefaults framework from the iOS Settings app, so all I had to do was to add a new setting to disable the iOS idle timer under user control:

class MultiClock: ObservableObject {
...
// settable in the iOS Settings app
var mc_primetime = true // paint prime metric times in red
var mc_12hour = true // use a 12-hour clock
var mc_lhconverter = false // lay out the converter for lefties in landscape orientation
var mc_idletimerdisabled = false // keep the display active for use as a desk clock
...
}

The variable mc_idletimerdisabled is the one I added.

In the init() method for that class, I arrange to read all the defaults from the system settings. I added the relevant settings to the app’s plist as well. And I wrote a method to reload the defaults when necessary. Here’s how that works, in case you haven’t found it on StackOverflow yet:

    private func initDefaults() {
// First time we run on a device, need to register the defaults set in the Setting bundle with
// the UserDefaults database. It's kind of bogus that iOS doesn't do this for you automatically.
// Thanks, StackOverflow!
let settingsUrl = Bundle.main.url(forResource: "Settings", withExtension: "bundle")!.appendingPathComponent("Root.plist")
let settingsPlist = NSDictionary(contentsOf:settingsUrl)!
let preferences = settingsPlist["PreferenceSpecifiers"] as! [NSDictionary]

var defaultsToRegister = Dictionary<String, Any>()

for preference in preferences {
guard let key = preference["Key"] as? String else {
NSLog("Key not found")
continue
}
defaultsToRegister[key] = preference["DefaultValue"]
}
UserDefaults.standard.register(defaults: defaultsToRegister)
}

func loadDefaults() {
let defaults = UserDefaults.standard
self.mc_12hour = defaults.bool(forKey: "mc_12hour")
self.mc_primetime = defaults.bool(forKey: "mc_primetime")
self.mc_lhconverter = defaults.bool(forKey: "mc_lefthanded")
self.mc_idletimerdisabled = defaults.bool(forKey: "mc_idletimerdisabled")
}

And then I declared my interest in any changes to Settings for the app, and wrote a handler to reload them when the user messes with them. This code is in the ContentView for the app:

        .onAppear {
NotificationCenter.default.addObserver(forName: NSNotification.uDefaults, object: nil, queue: nil) { _ in
// if the user changes defaults in the systems settings, reload the state variables in the clock
mc.loadDefaults()
}
}

That onAppear closure is from version 2.0 of the app. It gets created as soon as the ContentView for MultiClock is created (that’s at onAppear time), and declares our interest in the notification. Simple, right? But this whole blog post is about the counter-intuitive nature of onAppear. Stay tuned!

In version 2.3, that closure has been rewritten as

        .onAppear {
NotificationCenter.default.addObserver(forName: NSNotification.uDefaults, object: nil, queue: nil) { _ in
// if the user changes defaults in the systems settings, reload the state variables in the clock
mc.loadDefaults()

// Enforce any changed setting of idletimerdisabled. This defaults to false when the app is installed,
// but the user can override in order to use the app as a desk clock that doesn't go dim and blank out
// after a short idle period. The UI needs to change behavior immediately, so we do this as soon as we
// are notified of the update to defaults.
UIApplication.shared.isIdleTimerDisabled = mc.mc_idletimerdisabled
}
}

My app uses the SwiftUI TabView to display three separate views: All the current time info in the TimeView (this is really what the app is for); a handy calculator for converting among civil and solar times in hh:mm or metric in the ConverterView; and a short novella explaining why I wrote the app and how to use it in the InfoView.

Here’s that construct in ContentView:

    var body: some View {
TabView() {
TimeView()
.tabItem {
Label("time", systemImage: "deskclock.fill")
.foregroundColor(.gray)
Text("Time")
}.tag(0)
ConverterView()
.tabItem {
Label("convert", systemImage: "arrow.left.arrow.right.square")
.foregroundColor(.gray)
Text("Convert)")
}.tag(1)
InfoView()
.tabItem {
Label("info", systemImage: "info.circle.fill")
.foregroundColor(.gray)
Text("Info")
}.tag(2)
}
...
}

The TabView framework is really nice. It gives that iPhone interactivity you love; you simply swipe left and right to change views, and the views come onto the screen with a satisfying, just-slides-in, already-painted clarity. There are some known anomalies in use of TabView (if you rotate the phone from vertical to horizontal, SwiftUI chooses one of the tabs at random to render after the change), but it’s such a nice experience and so easy to use that I am willing to tolerate some stochasticity.

As a good iOS citizen, I wanted to break the UI conventions as little as possible. I decided that the TimeView should obey the user setting in disabling the idle timer, but that the ConverterView and InfoView should not. If one of those is visible and the phone doesn’t get touched for a bit, it should go dark, just as normal.

This seemed like a simple thing to do. I’d simply attach onAppear and onDisappear closures to each of the three views. The TimeView would enforce the behavior from the Settings when it appeared:

      .onAppear {
UIApplication.shared.isIdleTimerDisabled = mc.mc_idletimerdisabled
}
.onDisappear { UIApplication.shared.isIdleTimerDisabled = false }

As soon as the TimeView appears, it enforces the user’s chosen setting. When it disappears, it reverts to the system standard. ConverterView and InfoView would be good citizens:

      .onAppear { UIApplication.shared.isIdleTimerDisabled = false }

Any time either of them appears, it enforces the system standard. I made these changes with all the confidence and optimism that inexperience bring.

If that had worked, of course, there wouldn’t be a blog post.

The observed behavior was puzzling and apparently random. I’d turn on the “Keep display live” option in the iOS Settings app for MultiClock, open the app, and the TimeView would behave exactly as I wanted. But if I rotated it a couple of times, or if I swiped from one view to the other and then back again, the TimeView would forget that it was supposed to stay on. Sometimes I could rotate a few times before it forgot, but it would reliably forget after two or three rotations.

Verified that the Settings were still correct.

What the heck?

My intuitive understanding of “appear” and “disappear” is that they’re events that happen when the relevant view shows up in front of my eyeballs. The onAppear closure should get called every single time the view shows up on the screen, and the onDisappear closure should get called when it leaves. Also, if I’m swiping from the TimeView to the ConverterView, the TimeView should disappear (its onDisappear closure should be invoked) before the ConverterView appears (its onAppear cosure is invoked).

I think that because I’m a puny human.

The onAppear and onDisappear closures are really about the instantiation of those views by iOS in the application’s memory. When you rotate the phone, the TabView isn’t sure which view it’s going to show, so it may get ready to paint any of them. It’ll invoke the onAppear closure for that view, even if it doesn’t wind up appearing on the screen.

Also, once a view has been instantiated, it’s kept around in case it needs to be rendered again. Its onDisappear closure doesn’t get called just because it’s no longer on the screen. And if it does need to be painted on the screen again, its onAppear closure won’t be called, because it’s already right there in memory, all set up.

When I rotated the phone during testing, iOS would occasionally call the onAppear closure for one of the don’t-keep-the-screen-on views. That would globally override whatever I had set for the TimeView. And the TimeView wouldn’t fix the problem when it got painted again, because it had previously appeared.

In iOS UIKit, “appear” means “allocate in memory,” and “disappear” means “deallocate from memory.”

The solution to all this was to enforce the user setting uniformly for all the views. Instead of changing behavior when the Converter or Info tabs become active, I don’t try to make any changes in the view appearance or disappearance. Instead, I enforce new UI behavior for the app when I notice that the user has changed the Settings.

That worked! I dislike it a little bit, but it’s UIKit’s world. I just live in it.

--

--

Mike Olson

Berkeley-based techie with an interest in business. Worried about the world.