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
calledSpinner
and initialize aCAShapeLayer
calledshapeLayer
. - Setting up the shape layer: In
setupShapeLayer()
, we configure the appearance ofshapeLayer
by setting itslineWidth
,strokeColor
,fillColor
, andlineCap
. ThestrokeEnd
property is set to0.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 toshapeLayer.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 theshapeLayer
around the z-axis from0
to2π
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)
}
}
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 updateshapeLayer.strokeColor
toUIColor.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!