0

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

jimbobuk
  • 1,211
  • 12
  • 25

1 Answers1

1

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.")
Matic Oblak
  • 16,318
  • 3
  • 24
  • 43
  • Thanks so much for such a detailed answer. Sorry it took me so long to reply, I ended up getting too busy and unable to work on this project. I'll have another look next week though at following your advice. The only thing I'm curious about is "adding a new window above everything". I've never attempted to add views outside of standard VCs and containing views. Any tips on the best approach to injecting such a view ontop of everything would be really appreciated. Cheers! – jimbobuk Jan 19 '19 at 12:21
  • 1
    @jimbobuk I have added an example of showing a new window. I hope this helps You. – Matic Oblak Jan 21 '19 at 07:13
  • Thank you so much for being so thorough. I’ve not yet done too much away from standard view management, as I’m sure a lot of others haven’t either, so your detailed answer will be really helpful. I look forward to trying to work on this in a few days time! – jimbobuk Jan 21 '19 at 08:59
  • 1
    @jimbobuk Actually this window creation is unfortunately (or fortunately) not used very often. I wanted to actually give you a link to some other post but could not find any good one that is up to date. So I created a new one here. It is all tested and works like a charm. – Matic Oblak Jan 21 '19 at 09:11
  • sorry to resurrect this question. I've only just got around to trying to follow your answer in my own app after nearly a year of being away from developing my app. I've got it mostly working but I don't quite know what is going on with regards to my extra view. By default all i can see is my loading spinner and not the underlining view underneath. I can alpha out my Visual Effect View itself and start to see the underlining view appear but there's no noticeable blur. I can't seem to see how to make the blur work outside of its own window and effect the main app window contents – jimbobuk Dec 17 '19 at 09:37
  • @MatikOblak - Is there anything special i need to do with my storyboard setup for my activity view. I can share details but I guess I'd need a full post to it it, perhaps under another answer though I do feel like its related to your already detailed answer and getting it working. Thanks again for your time back then and any time you have now. Cheers – jimbobuk Dec 17 '19 at 09:55
  • 1
    @jimbobuk it should work OK. Remember that you need to set the background color of your `ViewController.view` to `clear` as it is `white` by default. Also IF you are using the iOS 13 pattern you will need to use a new constructor for window using `UIWindow(windowScene: scene)`. – Matic Oblak Dec 17 '19 at 11:20
  • I will have a look this evening. I was pretty sure that I tried setting the background colour to clear on the effects view, it’s content view and had no effect. The only way I could get the view visible was to fade the visual effects view out with some alpha. It is now in iOS 13, so perhaps it’s the constructor causing the issue. I am in ObjectiveC land with this project as it’s a few years old. If I’m still stuck I’ll try to get more details out to you but in this comment stream may not be the best way to do that if I want to send screenshots etc. Cheers for your time and help! – jimbobuk Dec 17 '19 at 11:46
  • 1
    @jimbobuk not on the effects view. You can leave that one alone. There is a view on every view controller. That view is white by default. – Matic Oblak Dec 17 '19 at 11:49
  • Ahh. A bit odd but fair enough. I’ve updated my app for dark mode too. I presume setting to clear won’t cause any problem with that either? So every single controller in my storyboard must have its view background colour set to clear to ensure the overlaying blur effect is able to be applied to it? Using the view debugger it was confusing that each window is shown individually making it hard to see where my blur view sits in one window relative to another. Window level must control it I guess but I wished it could show you everything layered so it made sense – jimbobuk Dec 17 '19 at 11:53
  • the blur view is on top of the views underneath it though I guess. It’s strange to think that background clear on the views underneath has some bearing on the view above it being able to apply its blur to it. Do we have any idea why this is required as from an alpha POV it is a bit confusing being this way around – jimbobuk Dec 17 '19 at 11:57
  • 1
    @jimbobuk In the example I have made there is a view controller which is used as a root view controller for the window that is on top of the whole thing. So the window is first placed on top of your scene (window being a subclass of UIView) then there is the main view of view controller, then there is the blur view... – Matic Oblak Dec 17 '19 at 13:24
  • ok. is there any chance you could share your example project somewhere online to refer to? I’ve got a large (for me) app with 10s of controllers already setup in the storyboard. I’m not sure I’ll be able to add a new root view controller easily. If I have to set the background on every view I won’t mind. I’m already having to commit to adding presenting of the loading spinner in each view as doing it early in the app delegate was too early and didn’t work. Again I’m sure there are better ways of working. Cheers, looking forward to trying this out! – jimbobuk Dec 17 '19 at 13:39
  • thanks that’s great!!! Definitely looking forward to playing with this some more now, whereas I felt a bit like I was hitting a brick wall before! Cheers! – jimbobuk Dec 17 '19 at 13:51
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/204452/discussion-between-jimbobuk-and-matic-oblak). – jimbobuk Dec 17 '19 at 23:29