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.