View controller responds to app delegate notifications in iOS 12 but not in iOS 13
Asked Answered
H

1

9

I have an app that supports iOS 12. I am adding support for iOS 13. I have a view controller that needs to perform a quick action when the app goes to the background.

Prior to iOS 13 that is simple enough. Add a line such as:

NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)

in viewDidLoad or maybe an init.

Then add the didEnterBackground method:

@objc func didEnterBackground() {
    // Do my background stuff
}

That's all good with iOS 12 and earlier.

But now with scene support in iOS 13, my notification isn't being called when run with iOS 13. It still works with an iOS 12 simulator/device.

What changes do I need to make?

Hunyadi answered 14/8, 2019 at 4:10 Comment(0)
H
23

When supporting scenes under iOS 13, many of the UIApplicationDelegate lifecycle methods are no longer called. There are now corresponding lifecycle methods in the UISceneDelegate. This means there is a need to listen to the UIScene.didEnterBackgroundNotification notification under iOS 13. You can find more details in the documentation at the Managing Your App's Life Cycle page.

You need to update the notification observer code to:

if #available(iOS 13.0, *) {
    NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: UIScene.didEnterBackgroundNotification, object: nil)
} else {
    NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
}

This allows your view controller (or view) to listen to the correct event depending on which version of iOS it is running under.

The same didEnterBackground method is called for both events depending on the version of iOS.


But there is an added complication if your app supports multiple windows.

If the user of your app has opened multiple windows of your app, then every copy of this view controller (or view) will be notified of the background event even if the given view controller is still in the foreground or if has been in the background all along.

In the likely case you only want the one window that was just put into the background to respond to the event, you need to add an extra check. The object property of the notification will tell you which specific scene has just entered the background. So the code needs to check to see if the notification's window scene is scene associated with the view controller (or view).

Brief side trip: See this answer for details on how to get the UIScene of a UIViewController or UIView. (It's not as straightforward as you would hope).

This requires an update to the didEnterBackground method as follows:

@objc func didEnterBackground(_ notification: NSNotification) {
    if #available(iOS 13.0, *) {
        // This requires the extension found at: https://mcmap.net/q/204730/-uiapplication-shared-delegate-equivalent-for-scenedelegate-xcode11
        if let winScene = notification.object as? UIWindowScene, winScene === self.scene {
            return; // not my scene man, I'm outta here
        } // else this is my scene, handle it
    } // else iOS 12 and we need to handle the app going to the background

    // Do my background stuff
}

There is a way to make this a little simpler. When registering with NotificationCenter, you can specify your own window scene as an argument to the object parameter. Then the didEnterBackground method will only be called for your own window scene.

The trick with this is getting your own window scene at the time you register for the notification. Since you can only get a view controller's scene after viewDidAppear has been called at least once, you can't use any init, viewDidLoad, or even viewWillAppear. Those are all too early.

Since viewDidAppear can be called more than once, you will end up calling addObserver each time and that is a problem because then your handler will get called multiple times for a single event. So one thought is to unregister the observer in viewDidDisappear. But then this now has the problem of your view controller not being called if some other view controller is covering it. So the trick it to add the observer in viewDidAppear but only the first time it is called for a specific instance of the view controller.

If you can wait until viewDidAppear, then first you need to add a property to your class to keep track of whether it's been viewed yet or not.

var beenViewed = false

Then add viewDidAppear:

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)

    if !beenViewed {
        beenViewed = true

        if #available(iOS 13.0, *) {
            // Only be notified of my own window scene
            NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: UIScene.didEnterBackgroundNotification, object: self.view.window?.windowScene)
        } else {
            NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
        }
    }
}

And then your didEnterBackground can be the old simple version again:

@objc func didEnterBackground() {
    // Do my background stuff
}

For Objective-C, the code is as follows:

Register for the notifications before viewDidAppear:

if (@available(iOS 13.0, *)) {
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didEnterBackground:) name:UISceneDidEnterBackgroundNotification object:nil];
} else {
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didEnterBackground:) name:UIApplicationDidEnterBackgroundNotification object:nil];
}

The more complicated didEnterBackground:

- (void)didEnterBackground:(NSNotification *)notification {
    if (@available(iOS 13.0, *)) {
        // This requires the extension found at: https://mcmap.net/q/204730/-uiapplication-shared-delegate-equivalent-for-scenedelegate-xcode11
        if (notification.object != self.scene) {
            return; // not my scene
        }  // else my own scene
    } // else iOS 12

    // Do stuff
}

If you want to use viewDidAppear and have a simpler didEnterBackground:

Add an instance variable to your class:

BOOL beenViewed;

Then add viewDidAppear:

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];

    if (!beenViewed) {
        beenViewed = YES;

        if (@available(iOS 13.0, *)) {
            // Only be notified of my own window scene
            [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didEnterBackground) name:UISceneDidEnterBackgroundNotification object:self.view.window.windowScene];
        } else {
            [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didEnterBackground) name:UIApplicationDidEnterBackgroundNotification object:nil];
        }
    }
}

And the simpler didEnterBackground:

- (void)didEnterBackground {
    // Do stuff
}
Hunyadi answered 14/8, 2019 at 4:10 Comment(10)
viewDidAppear can be called many times, and you are calling addObserver every time. Bad idea.Loera
@Loera Why is it a bad idea? It's paired with removing in viewDidDisappear. Adding an observer is very low overhead and it's not like viewDidAppear is called in fast succession. And using viewDidAppear is only one of the presented options if deemed appropriate. But I really would like to hear why you think it's a bad idea.Hunyadi
Because the selector will be called multiple times when the notification fires.Loera
@Loera Not if it's unregistered in viewDidDisappear as shown in the answer.Hunyadi
Okay, maybe you’re right. Maybe I should have made the opposite objection. :) We present a fullscreen controller, the unregistration takes place, we go into the background, and the view controller never hears about it.Loera
@Loera Yes, that's true. But using viewDidAppear is only one of several possible places to setup the notifications that I have presented in the answer. Obviously, each person should use the place that best suits their app. If viewDidAppear isn't the best place for your app, use one of the other solutions I present in the answer. I'll add that caveat to my answer for those that aren't aware of that potential issue.Hunyadi
I'm still mulling this over: "If the user of your app has opened multiple windows of your app, then every copy of this view controller (or view) will be notified of the background event even if the given view controller is still in the foreground or if has been in the background all along." Why? Saying NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: UIScene.didEnterBackgroundNotification, object: nil) was a mistake. Instead of nil, the object should be this view controller's view's window's scene and no other.Loera
@Loera My "if the user...." comment is based on the line of code you just quoted. That's the whole point of the rest of my answer. It's to explain why passing nil to the object parameter is a problem. See the next paragraph. And getting a reference to the view's scene is what you need but getting that scene isn't always as simple as it seems it should be.Hunyadi
Okey-dokey! As I say, I'm still coming back to this worrying about where the issues are... :)Loera
Gives nice insight. Awesome :)Ingathering

© 2022 - 2024 — McMap. All rights reserved.