Custom Spinning Activity Indicator in UIKit

Custom Spinning Activity Indicator in UIKit

In today's iOS applications, providing users with visual feedback during data loading or processing tasks is crucial for a smooth user experience. While UIKit offers a default UIActivityIndicatorView, it might not always be the best choice. If you want to have a unique design and stand out from your competitors, you might need a custom implementation. In this tutorial, we'll show how such a custom implementation might look.

Designing the Custom Activity Indicator

First, we'll create a custom UIView subclass that draws a shape. This view will act as our activity indicator.

import UIKit

final class Spinner: UIView {
    private let shapeLayer = CAShapeLayer()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupShapeLayer()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setupShapeLayer() {
        shapeLayer.lineWidth = 5
        shapeLayer.strokeColor = UIColor.label.cgColor
        shapeLayer.fillColor = UIColor.clear.cgColor
        shapeLayer.lineCap = .round
        shapeLayer.strokeEnd = 0.75

        let side = min(bounds.width, bounds.height)
        let radius = (side - shapeLayer.lineWidth) / 2
        shapeLayer.path = UIBezierPath(
            arcCenter: center,
            radius: radius,
            startAngle: 0,
            endAngle: 2 * .pi,
            clockwise: true
        ).cgPath
        shapeLayer.frame = bounds

        layer.addSublayer(shapeLayer)
    }
}

Explanation:

  • Initialization: We create a subclass of UIView called Spinner and initialize a CAShapeLayer called shapeLayer.
  • Setting up the shape layer: In setupShapeLayer(), we configure the appearance of shapeLayer by setting its lineWidth, strokeColor, fillColor, and lineCap. The strokeEnd property is set to 0.75 to create a 75% complete circle.
  • Drawing the path: We calculate the radius based on the minimum of the view's width and height to ensure the spinner fits within the view. A circular UIBezierPath is created and assigned to shapeLayer.path.
  • Adding the layer: The configured shapeLayer is added as a sublayer to the view's main layer.

Next, we'll add rotation animation to make the indicator spin.

override init(frame: CGRect) {
    super.init(frame: frame)
    setupShapeLayer()
    addSpinnerAnimation()
}

private func addSpinnerAnimation() {
    let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
    rotationAnimation.fromValue = 0
    rotationAnimation.toValue = CGFloat.pi * 2
    rotationAnimation.duration = 1.0
    rotationAnimation.repeatCount = .infinity
    rotationAnimation.timingFunction = CAMediaTimingFunction(name: .linear)
    shapeLayer.add(rotationAnimation, forKey: "rotationAnimation")
}

Explanation:

  • Adding animation in initializer: We call addSpinnerAnimation() within the initializer to start the animation when the view is created.
  • Creating rotation animation: A CABasicAnimation is configured to rotate the shapeLayer around the z-axis from 0 to radians, resulting in a full rotation.
  • Animation properties: The animation lasts 1.0 second per rotation, repeats infinitely, and uses a linear timing function for consistent speed.
  • Starting the animation: The animation is added to the shapeLayer with the key "rotationAnimation" to identify it.

Integrating into a View Controller

Our spinner is ready! Now we can use it in the project. Let's add it to a view controller.

import UIKit

class ViewController: UIViewController {
    private let spinner = Spinner(frame: CGRect(x: 0, y: 0, width: 50, height: 50))

    override func viewDidLoad() {
        super.viewDidLoad()
        
        spinner.center = view.center
        view.addSubview(spinner)
    }
}
Spinner inside a view controller

Fixing Bugs

Since we work with layers and Core Animation, we have to handle some corner cases.

Interface Style Changes

Try changing the theme in the simulator (A). You'll notice that the spinner's color doesn't change.

To fix this, we need to register for interface style changes. Let's add a method that handles the changes and register for changes in the initializer.

override init(frame: CGRect) {
    super.init(frame: frame)
    setupShapeLayer()
    addSpinnerAnimation()
    registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: Self, _: UITraitCollection) in
        view.handleTraitChanges()
    }
}

private func handleTraitChanges() {
    shapeLayer.strokeColor = UIColor.label.cgColor
}

Explanation:

  • Registering for trait changes: We call registerForTraitChanges with [UITraitUserInterfaceStyle.self] to listen for changes in the user interface style (light or dark mode).
  • Updating stroke color: In handleTraitChanges(), we update shapeLayer.strokeColor to UIColor.label.cgColor to match the current interface style.

Close / Open App

If we try to close the app and open it again, we'll see that the animation has disappeared. This is because all Core Animations are removed from layers when the app goes to the background. So, we need to fix it by adding the animation back when the app becomes active. To do this, let's add an observer in the initializer and a handler method.

override init(frame: CGRect) {
    super.init(frame: frame)
    setupShapeLayer()
    addSpinnerAnimation()
    registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: Self, _: UITraitCollection) in
        view.handleTraitChanges()
    }
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(handleAppMovedFromBackground),
        name: UIApplication.didBecomeActiveNotification,
        object: nil
    )
}

deinit {
    NotificationCenter.default.removeObserver(self)
}

@objc private func handleAppMovedFromBackground() {
    addSpinnerAnimation()
}

Explanation:

  • Adding observer: We add an observer for UIApplication.didBecomeActiveNotification to detect when the app becomes active again.
  • Handling app activation: The handleAppMovedFromBackground() method is called when the notification is received, and it restarts the spinner animation.
  • Cleaning up: In deinit, we remove the observer to prevent memory leaks or unexpected behavior.

Alternatives

Instead of developing a spinner from scratch, you can use the ComponentsKit library, which already includes a custom loader. This loader offers extensive customization options and is easy to integrate into your project.

Why choose ComponentsKit loader?

  • Easy customization: Adjust colors, sizes, and styles to match your app's design seamlessly.
  • Time-saving: Implementing a pre-built loader saves you hours of coding and debugging.
  • Frequent updates: Regularly maintained to ensure compatibility with the latest iOS versions.
  • User-friendly API: Designed with simplicity in mind for a smooth developer experience.

Here's how you can use the loader in your view controller:

import ComponentsKit
import UIKit

class ViewController: UIViewController {
    private let spinner = UKLoading(model: .init {
        $0.color = .accent
        $0.size = .large
    })

    override func viewDidLoad() {
        super.viewDidLoad()
        
        spinner.center = view.center
        view.addSubview(spinner)
    }
}

By using the ComponentsKit loader, you can focus on building your app's core features without worrying about the intricacies of custom animations and UI components. It's a ready-to-use solution that significantly streamlines your development process with minimal effort.

Conclusion

It's relatively straightforward to create an animated spinner, but there might be difficulties and unexpected behavior since we work with layers and Core Animation.

If you don't want to handle all the corner cases, consider using the ComponentsKit library that already has a spinner that is easy to use and customize. Check out all the components that we have and save hours of coding!