Search code examples
ioscore-datasplash-screenstate-restoration

How to present a loading spinner during State Restoration whilst waiting for CoreData/Network requests?


I already asked about state restoration and CoreData 4 years ago here

State preservation and restoration strategies with Core Data objects in a UIManagedDocument

In the end my App does what I described and state restores URIRepresentations for any CoreData objects it wants to hold. These objects can only resolve once CoreData has loaded (via the UIManagedDocument and its document loaded callback). Everything works ok, although sometimes views are empty during the CoreData document load.

The big issue for me is that the user can attempt to interact with the views of my App during this limbo state, and in doing so can often crash it as new views are setup with null CoreData properties that are required to be setup as they segue.

I need a solution to fix this, adding custom blocking of buttons etc on every view whilst CoreData is still not loaded could manage it but is quite a lot of repeated work and as far as a user experience goes its not the best. I present an alert when an input is pressed and we're still waiting for CoreData to load.

My preferred solution would be to somehow override the ViewController restoration and inject a new top viewController into the restored hierarchy which can show a spinner until CoreData has loaded. I don't see any examples for this, or description of appropriate methods to support such a strategy in the documentation.

Ultimately if I can tell whenever a viewController is being restored if it is THE top viewController then perhaps then I could push a modal loading spinner viewController. Not sure if this is an appropriate time to push a new VC though I guess I could do defer to ViewWillAppear or some other small timer delayed callback. The only issue perhaps being you seeing the original view state restore and then change to the spinner.. if I can make the segue fade the spinner in this may not be too jarring.

Anyone got any suggestions on this? It's the sort of thing that some other apps do all the time like Facebook when they restore and go to the network to reload your posts to read.

Thanks for your time

Regards

Jim


Solution

  • The situation you found yourself in seems enough of a reason to reconsider what you did to get to this. I am using I guess a similar situation as I load all core data objects in separate thread so completions are used like

    MyEntity.fetchAll { items,
       self.entities = items
       self.tableView.reloadData()
    }
    

    In this case it is pretty easy to do something like:

    var entities: [Any]? {
        didSet {
            self.removeActivityIndicator()
        }
    }
    

    You can put all the logic into some base class for your view controller so you can easily reuse it.

    Sometimes though it is better to do these things statically. You can add a new window above everything that has an activity indicator. Basically like doing custom alert views. The a retain count system should work the best:

    class ActivityManager {
    
        private static var retainCount: Int = 0 {
            didSet {
                if(oldValue > 0 && newValue == 0) removeActivityWindow()
                else if(oldValue == 0 && newValue > 0) showActivityWindow()
            }
        }
    
        static func beginActivity() { retainCount += 1 }
        static func endActivity() { retainCount -= 1 }
    }
    

    In this case you can use the tool anywhere in your code. The rule is that every "begin" must have an "end". So for instance:

    func resolveData() {
        ActivityManager.beginActivity()
        doMagic {
            ActivityManager.endActivity()
        }
    }
    

    There are really many ways to do this and there is probably no "best solution" as it just depends on your case.

    An example of using a new window to show a dialog:

    As requested in comments I am adding an example on how to show a dialog in a new window. I am using a new storyboard "Dialog" that contains a view controller AlertViewController. This might as well be a controller with some activity indicator but more important part is how a window is generated, how controller is shown and how dismissed.

    class AlertViewController: UIViewController {
    
        @IBOutlet private var blurView: UIVisualEffectView?
        @IBOutlet private var dialogPanel: UIView?
        @IBOutlet private var titleLabel: UILabel? // Is in vertical stack view
        @IBOutlet private var messageLabel: UILabel? // Is in vertical stack view
        @IBOutlet private var okButton: UIButton? // Is in horizontal stack view
        @IBOutlet private var cancelButton: UIButton? // Is in horizontal stack view
    
        var titleText: String?
        var messageText: String?
        var confirmButtonText: String?
        var cancelButtonText: String?
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            setHiddenState(isHidden: true, animated: false) // Initialize as not visible
    
            titleLabel?.text = titleText
            titleLabel?.isHidden = !(titleText?.isEmpty == false)
    
            messageLabel?.text = messageText
            messageLabel?.isHidden = !(messageText?.isEmpty == false)
    
            okButton?.setTitle(confirmButtonText, for: .normal)
            okButton?.isHidden = !(confirmButtonText?.isEmpty == false)
    
            cancelButton?.setTitle(cancelButtonText, for: .normal)
            cancelButton?.isHidden = !(cancelButtonText?.isEmpty == false)
        }
    
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            setHiddenState(isHidden: false, animated: true)
        }
    
        private func setHiddenState(isHidden: Bool, animated: Bool, completion: (() -> Void)? = nil) {
            UIView.animate(withDuration: animated ? 0.3 : 0.0, animations: {
                self.blurView?.effect = isHidden ? UIVisualEffect() : UIBlurEffect(style: .light)
                self.dialogPanel?.alpha = isHidden ? 0.0 : 1.0
            }) { _ in
                completion?()
            }
        }
    
        @IBAction private func okPressed() {
            AlertViewController.dismissAlert()
        }
        @IBAction private func cancelPressed() {
            AlertViewController.dismissAlert()
        }
    
    
    }
    
    // MARK: - Window
    
    extension AlertViewController {
    
        private static var currentAlert: (window: UIWindow, controller: AlertViewController)?
    
        static func showMessage(_ message: String) {
    
            guard currentAlert == nil else {
                print("An alert view is already shown. Dismiss this one to show another.")
                return
            }
    
            let controller = UIStoryboard(name: "Dialog", bundle: nil).instantiateViewController(withIdentifier: "AlertViewController") as! AlertViewController
            controller.confirmButtonText = "OK"
            controller.messageText = message
    
            let window = UIWindow(frame: UIApplication.shared.windows[0].frame)
            window.windowLevel = .alert
            window.rootViewController = controller
            window.makeKeyAndVisible()
    
            self.currentAlert = (window, controller)
        }
    
        static func dismissAlert() {
            if let currentAlert = self.currentAlert {
                currentAlert.controller.setHiddenState(isHidden: true, animated: true) {
                    self.currentAlert?.window.isHidden = true
                    self.currentAlert = nil
                }
            }
        }
    
    }
    

    I added the whole class just in case but the important part is showing a new window:

    let window = UIWindow(frame: UIApplication.shared.windows[0].frame) // Create a window
    window.windowLevel = .alert // Define which level it should be in
    window.rootViewController = controller // Give it a root view controller
    window.makeKeyAndVisible() // Show the window
    

    And removing the window:

    window.isHidden = true
    

    Simply hiding your window is enough. Assuming you don't have any strong reference to it it will be removed from application stack. To confirm this make sure that UIApplication.shared.windows.count has an appropriate value which in most cases should be 2 when alert is shown and 1 otherwise.

    My test usage of the code above was simply:

    AlertViewController.showMessage("A test message. This is testing of alert view in a separate window.")