Swift – Simple full screen loader

Hello,

resuming an old post, that help you to create an UIAlert extension to show a modal popup, https://www.albertopasca.it/whiletrue/objective-c-modal-view-in-navigation-and-tabbar-controller-projects/ , I’ve created a new modern implementation that use UIWindow.

The scope is to show a spinner (or a Lottie spinner, or whatever you prefer), centered in the screen with automatic or manually dismission. Background can be blurred or colored, like this screen:

Show me the code 🚧:

Create a new SpinnerManager class and make it singleton (singleton because logically, no you can show only one spinner each time…).

class SpinnerManager {
    static let shared: SpinnerManager = { SpinnerManager() }()
    
    func showAlert() {

    }
        
    func dismiss() {

    }
    
}

Now you can show or hide the spinner, calling SpinnerManager.shared.showAlert() and dismiss using SpinnerManager.shared.dismiss().


Create the real Spinner 🚀:

Add a new UIViewController, in the way you prefer, using Storyboards or programmatically.

In this example we create a simple UIViewController programmatically.

The next class adds using constraints, two views, a rounded white view and an UIActivityIndicator on top. Nothing else.

class SpinnerViewController: UIViewController {

    override func loadView() {
        super.loadView()
        
        let spinner = UIView(frame: .zero)
        spinner.backgroundColor = .white
        spinner.layer.cornerRadius = 18
        spinner.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner]
        spinner.layer.shadowOffset = CGSize(width: 0, height: 0)
        spinner.layer.shadowRadius = 6
        spinner.layer.shadowOpacity = 0.1

        let activity = UIActivityIndicatorView(frame: spinner.bounds)
        activity.color = .red
        activity.style = .large
        activity.startAnimating()
        spinner.addSubview(activity)

        self.view.addSubview( spinner )
        
        spinner.translatesAutoresizingMaskIntoConstraints = false
        let horizontalContainerConstraint = NSLayoutConstraint(item: spinner, attribute: .centerX, relatedBy: NSLayoutConstraint.Relation.equal, toItem: view, attribute: .centerX, multiplier: 1, constant: 0)
        let verticalContainerConstraint = NSLayoutConstraint(item: spinner, attribute: .centerY, relatedBy: NSLayoutConstraint.Relation.equal, toItem: view, attribute: .centerY, multiplier: 1, constant: 0)
        let widthContainerConstraint = NSLayoutConstraint(item: spinner, attribute: .width, relatedBy: NSLayoutConstraint.Relation.equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: 100)
        let heightContainerConstraint = NSLayoutConstraint(item: spinner, attribute: .height, relatedBy: NSLayoutConstraint.Relation.equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: 100)
        NSLayoutConstraint.activate([
            horizontalContainerConstraint,
            verticalContainerConstraint,
            widthContainerConstraint,
            heightContainerConstraint
        ])

        activity.translatesAutoresizingMaskIntoConstraints = false
        let horizontalActivityConstraint = NSLayoutConstraint(item: activity, attribute: .centerX, relatedBy: NSLayoutConstraint.Relation.equal, toItem: view, attribute: .centerX, multiplier: 1, constant: 0)
        let verticalActivityConstraint = NSLayoutConstraint(item: activity, attribute: .centerY, relatedBy: NSLayoutConstraint.Relation.equal, toItem: view, attribute: .centerY, multiplier: 1, constant: 0)
        NSLayoutConstraint.activate([
            horizontalActivityConstraint,
            verticalActivityConstraint
        ])
    }

}

Now we have the basic SpinnerManager and a new view-controller.

Let’s add some logic in our manager.

class SpinnerManager {
    [...]

    private lazy var spinnerVC: SpinnerViewController = SpinnerViewController()
    private var blankWindow: UIWindow?

    func showAlert() {      
        if let currentWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
            blankWindow = UIWindow(windowScene: currentWindowScene)
            blankWindow?.backgroundColor = UIColor.black.withAlphaComponent(0.25)
            blankWindow?.windowLevel = .alert
            blankWindow?.rootViewController = spinnerVC
            blankWindow?.makeKeyAndVisible()
        }
    }
    
    func dismiss() {
        blankWindow = nil
    }
    
}

the blankWindow is on “windowLevel” = .alert, this mean that the spinner is shown at the same layer of the system modal alert, or better, on top of all other windows.

Basically this code works, you have a new SpinnerViewController, shown or hidden by the two relative functions.

But we want to add more features, like automatic dismission (timeout).


Adding timeout dismission ⏰

We need now to create a Timer and a callback closure:

class SpinnerManager {
    [...]
    private var timeoutCallback: (() -> Void)?
    private var timeoutHandlerTimer: Timer?
    [...]

and pass it to the showAlert function, using if you prefer a default value (in my case 30 seconds):

func showAlert(
    autodismissTimeout: Double = 30.0,
    timeoutCallback: (() -> Void)? = nil
) {
    [...]
}

Before using the Timer, we need to know if the Spinner is visible on screen or not. To reach this, you can add a isVisible() method:

[...]
var isVisible: Bool {
    get {
       (UIApplication.topMostKeyWindow?.rootViewController as? SpinnerViewController) != nil
    }
}
[...]

But you need a simple extension to get the topMostKeyWindow, as also explained here:

extension UIApplication {
    /// The top most keyWindow
    static var topMostKeyWindow: UIWindow? {
        UIApplication.shared.connectedScenes
            .first(where: { $0.activationState == .foregroundActive })
            .map({ $0 as? UIWindowScene })
            .flatMap({ $0 })?.windows
            .first(where: { $0.isKeyWindow })
    }
}

Well, all is ready, let’s add the timer:

[...]
    func showAlert(
        autodismissTimeout: Double = 30.0, // both optionals
        timeoutCallback: (() -> Void)? = nil // both optionals
    ) {

        if isVisible {
            if autodismissTimeout > 0 { // handle timeout
                timeoutHandlerTimer?.invalidate()
                timeoutHandlerTimer = Timer.scheduledTimer(
                    timeInterval: autodismissTimeout, target: self, selector: #selector(dispatchTimeoutHandlerAndDismiss), userInfo: nil, repeats: false
                )
            }
            return
        }
        
        if autodismissTimeout > 0 {
            self.timeoutCallback = timeoutCallback
        }
       
        if let currentWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
            blankWindow = UIWindow(windowScene: currentWindowScene)
            blankWindow?.backgroundColor = UIColor.black.withAlphaComponent(0.25)
            blankWindow?.windowLevel = .alert
            blankWindow?.rootViewController = spinnerVC
            blankWindow?.makeKeyAndVisible()
            
            if autodismissTimeout > 0 {
                timeoutHandlerTimer?.invalidate()
                timeoutHandlerTimer = Timer.scheduledTimer(
                    timeInterval: autodismissTimeout, target: self, selector: #selector(dispatchTimeoutHandlerAndDismiss), userInfo: nil, repeats: false
                )
            }
        }
    }
    
    @objc private func dispatchTimeoutHandlerAndDismiss() {
        timeoutCallback?()
        self.dismiss()
    }
    
    func dismiss() {
        timeoutHandlerTimer?.invalidate()
        timeoutHandlerTimer = nil
        timeoutCallback = nil
        blankWindow = nil
    }


[...]

Testing code 💥

Let’s see if it works, so go back in your main View-Controller and make an example test:

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // add this if you want to test the manual dismission
        Timer.scheduledTimer(
            timeInterval: 5,
            target: self,
            selector: #selector(closeSpinner),
            userInfo: nil,
            repeats: false
        )
    }

    // example on how to show a spinner
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        SpinnerManager.shared.showAlert(autodismissTimeout: 15) {
            print( "closed after timeout" )
        }
    }

    // the timer callback
    @objc func closeSpinner() {
        SpinnerManager.shared.dismiss()
    }

}

Perfect! Work is now complete.

Nope, I want also a blurred background, that is more cool!


Adding blurred background 🌫

The last part of this tutorial is about configuring and adding an optional blurred view as background of our spinner:

As a commodity, you can reuse this snippet (not mine, founded online), that help you to create a custom UIVisualEffectView in a few row of code:

class CustomVisualEffectView: UIVisualEffectView {
    private let theEffect: UIVisualEffect
    var customIntensity: CGFloat
    private var animator: UIViewPropertyAnimator?

    init(effect: UIVisualEffect, intensity: CGFloat) {
        theEffect = effect
        customIntensity = intensity
        super.init(effect: nil)
    }
    
    required init?(coder aDecoder: NSCoder) { nil }    
    deinit { animator?.stopAnimation(true) }
    
    override func draw(_ rect: CGRect) {
        super.draw(rect)
        effect = nil
        animator?.stopAnimation(true)
        animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [unowned self] in
            effect = theEffect
        }
        animator?.fractionComplete = customIntensity
    }
}

After that, edit a little bit the previous SpinnerManager code, adding the blurred view.

In particular, declare the CustomVisualEffectView

private var blurEffectView: CustomVisualEffectView?

and add as background:

    [...]
    func showAlert(
        blurBackground: Bool = false,
        autodismissTimeout: Double = 30.0,
        timeoutCallback: (() -> Void)? = nil
    ) {
        blurEffectView?.removeFromSuperview()
        [...]
        blankWindow?.rootViewController = spinnerVC

        // check if we need to add a blurred view
        if blurBackground {
            // play with effects
            blurEffectView = CustomVisualEffectView(effect: UIBlurEffect(style: .dark), intensity: 0.0)
            blurEffectView?.frame = blankWindow!.rootViewController!.view.bounds
            blurEffectView?.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            
            UIView.animate(withDuration: 0.25) {
                self.blurEffectView!.customIntensity = 0.15 // play with intensity
            }
            
            blankWindow?.rootViewController?.view.insertSubview(blurEffectView!, at: 0)
        }

        blankWindow?.makeKeyAndVisible()
        [...]

We’re really complete now.


🎉 You can call the new code using these two functions:

SpinnerManager.shared.showAlert(blurBackground: true, autodismissTimeout: 15) {
    print( "closed after timeout" )
}
SpinnerManager.shared.dismiss()

With or without blurred background:

Have fun.

As usually, for lazy people, the code is on GitHub.

 

Alberto Pasca

Software engineer @ Pirelli & C. S.p.A. with a strong passion for mobile  development, security, and connected things.