Back to blog

Monday, October 21, 2024

Custom Spinning Activity Indicator in UIKit

Read time: 7 min

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 as soon as 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

Explanation:

  • Initializing the Spinner: We create an instance of Spinner of size 50x50.
  • Positioning: We center the spinner within the main view.
  • Adding to View: We add the spinner to the view hierarchy with view.addSubview(spinner) so it becomes visible.

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 (Command + Shift + 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.
  • Dynamic Color Adaptation: By updating the stroke color when the trait changes, we ensure the spinner adapts to theme changes in real-time.

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 CA Animations are removed from layers when the app goes to the background. But we can easily 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 our SwiftComponents library, which already includes a custom loader. This loader offers extensive customization options and is easy to integrate into your project.

Why Choose SwiftComponents 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.
  • Consistent 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 SwiftComponents
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 SwiftComponents 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 SwiftComponents 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!