UI Throwback: Creating a Retro Progress Bar for iOS using CALayers

Written by maxkalik | Published 2023/12/17
Tech Story Tags: ios-app-development | macos | ios | uikit | calayer | ui | frontend-development | hackernoon-top-story

TLDRThis guide explores the creation of a retro-style progress bar in iOS, using CALayers for a nostalgic touch. It covers the step-by-step process, from designing each layer to implementing animations, offering insights into combining old-school aesthetics with modern iOS development. Ideal for developers and UI designers, the article provides both technical depth and creative inspiration for unique UI components. Source Code: https://github.com/maxkalik/RetroProgressBarvia the TL;DR App

I love building UI components. It gives me a kind of aesthetic pleasure to see and use them after making a build. This also combines with my love for the MacOS/iOS ecosystem overall. Recently, I was tasked with creating a custom progress bar using UIKit. It’s not a big deal, but probably 99% of iOS developers have made one from scratch at some point in their careers. In my case, I wanted to do a bit more than just create a couple of CALayers with animations based on a value range from 0 to 1. I was aiming for a solution that would be suitable for many scenarios where a progress bar is needed.

I want to throw myself back to the days when UI components in the Apple world looked like this:

Reflecting on the evolution of UI design, it’s been almost a decade since UI elements in the Apple world adopted a flat design with the introduction of iOS 7 in 2014. So, let’s indulge in nostalgia for the earlier versions of Mac OS and iOS and create a beautiful progress bar together.

If you enjoy using UIKit but are still unsure about how to manipulate CALayers, this article will be useful for you. Here, we will go through typical layout, animation, and drawing challenges, providing insights and solutions to help you along the way.

From UIView deep into CALayers API.

The progress bar might seem straightforward, but if we dive deep into the details, you’ll understand that it requires a lot of sublayers.

I’m not going to delve into CALayersit in detail here. You can check out this article where I briefly described what CALayers are and how they work: https://hackernoon.com/rolling-numbers-animation-using-only-calayers.

So, let’s break it into small layers:

  1. General background Layer
  2. Mask layer (Animatable part)
  3. Progress background Layer
  4. Glare Layer
  5. Shimmering Layer

The background layer has two additional child layers:

  1. Inner shadow Layer
  2. Border Gradient Layer

Let’s structure it properly:

RetroProgressBar [UIView]
  |--General background [CALayer]
     |--Inner Shadow [CALayer]
     |--Border Gradient [CALayer]
  |--Progress Background Layer [CALayer]
  |--Animated Mask Layer [CALayer]
  |--Glare [CALayer]
  |--Shimering [CALayer]

Briefly, here’s how it should work: The Progress Bar should react to a value ranging from 0.0 to 1.0. When the value changes, we need to adjust the width of a layer based on this value:

bounds.width * value

Using this simple calculation, we can modify the width using CATransaction. But before we do this, we need to understand which layers should change when the value changes. Let’s revisit the structure and identify the layers that need to be responsive to value changes.

RetroProgressBar [UIView]
  |--General background [CALayer]
     |--Inner Shadow [CALayer]
     |--Border Gradient [CALayer]
  |--Progress Background Layer [CALayer]
  |--Animated Mask Layer [CALayer] : [Animated Width]
  |--Glare [CALayer] : [Animated Width]
  |--Shimering [CALayer] : [Animated Width]

It appears that we need to develop a protocol that can be adopted by the layers that need animated width based on value.

ProgressAnimatable protocol

In protocol, we need just two methods: change the layer’s width with animation and without.

protocol ProgressAnimatable: CALayer {
    
    /// Sets the layer's width instantly to the specified value.
    func setToWidth(_ width: CGFloat)
    
    /// Animates the layer's width to the specified value.
    func animateToWidth(_ width: CGFloat,
                        duration: TimeInterval,
                        animationType: CAMediaTimingFunctionName,
                        completion: (() -> Void)?)
}

Implementation:

extension ProgressAnimatable {

    func setToWidth(_ width: CGFloat) {
        guard width >= 0 else { return }
        
        removeAllAnimations()
        
        CATransaction.begin()
        CATransaction.setDisableActions(true)
 
        self.bounds.size.width = width

        CATransaction.commit()
    }
    
    func animateToWidth(_ width: CGFloat,
                        duration: TimeInterval,
                        animationType: CAMediaTimingFunctionName = .easeInEaseOut,
                        completion: (() -> Void)? = nil) {
        guard width >= 0, width != self.bounds.width else {
            completion?()
            return
        }
        
        let animation = CABasicAnimation(keyPath: "bounds.size.width")
        animation.fromValue = bounds.width
        animation.toValue = width
        animation.duration = duration
        animation.fillMode = .forwards
        animation.isRemovedOnCompletion = false
        animation.timingFunction = CAMediaTimingFunction(name: animationType)
        
        CATransaction.begin()
        CATransaction.setCompletionBlock {
            self.bounds.size.width = width
            completion?()
        }
        
        self.add(animation, forKey: "widthChange")

        CATransaction.commit()
    }
}

Both methods use CATransaction. The first method might seem odd because we only need to change a value without animation. So, why is CATransaction necessary?

Try creating a basic CALayer and changing its width or height without any animation. When you run a build, you’ll notice the CALayer changes with an animation. How is this possible? In Core Animation, changes to layer properties (such as bounds, position, opacity, etc.) are typically animated based on the default action associated with that property. This default behavior is intended to provide smooth visual transitions without the need for explicit animations for every property change.

This means that we need to explicitly disable actions in CALayer. By setting CATransaction.setDisableActions(true), you ensure that the layer's width is instantly updated to the new value without any intermediate animated transition.

The second method offers a flexible and reusable way to animate the width of any layer conforming to the ProgressAnimatable protocol. It provides control over the animation's duration and pacing and includes an option for a completion action. This makes it well-suited for various animation needs within the UIKit framework.

Our candidates to conform to this protocol will be three layers: Animated Mask Layer, Glare Layer, and Shimmering Layer. Let’s go ahead and implement it.

ProgressMaskLayer

It’s time to conform to our protocol, and the most important layer in this setup will be our general CAShapeLayer. Why do we need this mask? Why can't it just be a CALayer with an animated width? As an iOS Engineer, especially one focusing on front-end development, you often need to anticipate potential use cases for your components. In my instance, there is a Progress Background Layer. This isn't an animated CALayer, but what if it were animated with something like CAReplicatorLayer? If you're concerned about performance, you'd likely consider that such a layer should be rendered once and then simply perform its animation. That's why this mask can be extremely useful. It allows for efficient rendering while still offering the flexibility needed for various animation scenarios.

final class ProgressMaskLayer: CAShapeLayer, ProgressAnimatable {

    override init() {
        super.init()
        
        backgroundColor = UIColor.black.cgColor
        anchorPoint = CGPoint(x: 0, y: 0.5)
    }

    override init(layer: Any) {
        super.init(layer: layer)
    }

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

Let’s take a look at this line:

anchorPoint = CGPoint(x: 0, y: 0.5)

This line is crucial for our Progress Bar. By default, CALayers are centered, with the anchorPoint located at the center of the layer, not on the middle of the left edge as commonly assumed. The anchorPoint is the point around which all transformations of the layer occur. For a layer used as a progress bar, this setting typically means that when the layer's width changes, it expands equally in both directions from the anchorPoint. However, if we adjust the anchorPoint to the middle of the left edge, the layer will expand only to the right while the left edge remains fixed in place. This behavior is essential for a progress bar, ensuring that the growth of the bar appears from one side rather than both.

private let progressMaskLayer = ProgressMaskLayer()
private let progressBackgroundLayer = ProgressBackgroundLayer()

public init() {
        super.init(frame: .zero)

        layer.addSublayer(progressMaskLayer)
        layer.addSublayer(progressBackgroundLayer)
        
        progressBackgroundLayer.mask = progressMaskLayer

}

In the snippet above, we initialize two main parts of the Progress Bar: ProgressMaskLayer and ProgressBackgroundLayer. Let’s take a look at the second one.

final class ProgressBackgroundLayer: CAGradientLayer {
    
    override init() {
        super.init()

        locations = [0, 0.5, 1]
    }

    override init(layer: Any) {
        super.init(layer: layer)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSublayers() {
        super.layoutSublayers()

        colors = [
            backgroundColor?.lightened(by: 0.3),
            backgroundColor,
            backgroundColor?.darkened(by: 0.5)
        ].compactMap({ $0 })
    }
}

As you can see, it’s a CAGradientLayer featuring three colors. This implementation transforms an injected color into a vertical gradient, enhancing the visibility of the progress bar and giving it a more voluminous appearance.

To make this possible, I prepared two methods in a CGColor extension. For fun, I chose not to convert CGColor back to UIColor, but to play solely within the realm of CGColor:

extension CGColor {
    func darkened(by value: CGFloat) -> CGColor {
        guard let colorSpace = self.colorSpace,
              let components = self.components,
              colorSpace.model == .rgb,
              components.count >= 3 else {
            return self
        }

        let multiplier = 1 - min(max(value, 0), 1)
        let red = components[0] * multiplier
        let green = components[1] * multiplier
        let blue = components[2] * multiplier
        let alpha = components.count > 3 ? components[3] : 1.0

        return CGColor(
            colorSpace: colorSpace,
            components: [red, green, blue, alpha]
        ) ?? self
    }
    
    func lightened(by value: CGFloat) -> CGColor {
        guard let colorSpace = self.colorSpace,
              let components = self.components,
              colorSpace.model == .rgb,
              components.count >= 3 else {
            return self
        }

        let red = min(components[0] + value, 1.0)
        let green = min(components[1] + value, 1.0)
        let blue = min(components[2] + value, 1.0)
        let alpha = components.count > 3 ? components[3] : 1.0
        
        return CGColor(
            colorSpace: colorSpace,
            components: [red, green, blue, alpha]
        ) ?? self
    }
}

As you can see, it’s not too complicated. We just need to getcolorSpace, components (red, green, blue, and alpha), and model. This means that based on the value, we can calculate a new color: either darker or lighter.

Glare Layer

Let’s move towards making our Progress Bar more 3D-ish. Now, we need to add another layer, which we’ll call Glare.

It’s a straightforward implementation, but it should conform to the ProgressAnimatable protocol.

final class ProgressGlareLayer: CALayer, ProgressAnimatable {
    
    override init() {
        super.init()

        backgroundColor = UIColor.white.withAlphaComponent(0.3).cgColor

        anchorPoint = CGPoint(x: 0, y: 0.5)
    }

    override init(layer: Any) {
        super.init(layer: layer)
    }

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

To keep things simple, we only need the background color here to be white with an alpha of 0.3.

Little Nice Shimmering (Shimmering Layer)

I know some might say that MacOS X or iOS before version 6 didn’t have UI components with shimmering effects like this. But, as I said, this is like a movie inspired by a real story. This shimmering accentuates the 3D effect and makes it more visible.

final class ShimmeringLayer: CAGradientLayer, ProgressAnimatable {

    let glowColor: UIColor = UIColor.white
    
    private func shimmerAnimation() -> CABasicAnimation {
        let animation = CABasicAnimation(keyPath: "locations")
        animation.fromValue = [-1.0, -0.5, 0.0]
        animation.toValue = [1.0, 1.5, 2.0]
        animation.duration = 1.5
        animation.repeatCount = Float.infinity
        return animation
    }
    
    private func opacityAnimation() -> CABasicAnimation {
        let animation = CABasicAnimation(keyPath: "opacity")
        animation.fromValue = 1.0
        animation.toValue = 0.0
        animation.duration = 0.1 // Quick transition to transparent
        animation.beginTime = shimmerAnimation().duration
        animation.fillMode = .forwards
        animation.isRemovedOnCompletion = false
        return animation
    }
    
    private func animationGroup() -> CAAnimationGroup {
        let pauseDuration = 3.0 // Duration of pause between shimmering
        let shimmerAnimation = shimmerAnimation()
        let opacityAnimation = opacityAnimation()
        let animationGroup = CAAnimationGroup()
        
        animationGroup.animations = [shimmerAnimation, opacityAnimation]
        animationGroup.duration = shimmerAnimation.duration + pauseDuration
        animationGroup.repeatCount = Float.infinity
        
        return animationGroup
    }

    override init() {
        super.init()

        setupLayer()
    }

    override init(layer: Any) {
        super.init(layer: layer)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSublayers() {
        super.layoutSublayers()

        startAnimation()
    }
    
    private func setupLayer() {
        let lightColor = UIColor.white.withAlphaComponent(0.7).cgColor
        let darkColor = UIColor.white.withAlphaComponent(0).cgColor
        
        colors = [
            darkColor,
            lightColor,
            darkColor
        ]
        
        anchorPoint = CGPoint(x: 0, y: 0.5)
        locations = [0.0, 0.5, 1.0]
        startPoint = CGPoint(x: 0.0, y: 0.5)
        endPoint = CGPoint(x: 1.0, y: 0.5)

        shadowColor = glowColor.cgColor
        shadowRadius = 5.0
        shadowOpacity = 1
        shadowOffset = .zero
    }
    
    private func startAnimation() {
        if animation(forKey: "shimmerEffect") == nil {
            let animationGroup = animationGroup()
            add(animationGroup, forKey: "shimmerEffect")
        }
    }
}

Here, we need two animations (Opacity and Location) combined into a CAAnimationGroup. Also, using CAAnimationGroup allows us to arrange a pause between animation sessions. This feature is quite useful because clients might want to control this property as well.

As you can see in the picture, this adds more 3D effects, but it’s not quite enough. Let’s move forward.

Background Layer

We need to work with our static background layer, which lies beneath the animated Progress Line. If you recall, we have two additional sublayers there: the Inner Shadow and the Border Gradient.

|--General background [CALayer]
     |--Inner Shadow [CALayer]
     |--Border Gradient [CALayer]

I would say these two layers were quite popular in search queries several years ago, possibly due to UI trends. Let’s create our own versions and immortalize them in this article for future generations :)

Inner Shadow Layer

The class is designed to create a layer with an inner shadow effect, which can be a visually appealing way to add depth to UI elements.

final class InnerShadowLayer: CAShapeLayer {

    override init() {
        super.init()
        
        masksToBounds = true
        shadowRadius = 3
        shadowColor = UIColor.black.cgColor
        shadowOffset = CGSize(width: 0.0, height: 1.0)
        shadowOpacity = 0.5
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSublayers() {
        super.layoutSublayers()
        
        // Creates a path for the shadow that extends slightly outside the bounds of the layer.
        let shadowPath = UIBezierPath(roundedRect: bounds.insetBy(dx: -5, dy: -5), cornerRadius: cornerRadius)
        // Creates a cutout path that is the inverse of the layer's bounds.
        let cutoutPath = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius).reversing()

        shadowPath.append(cutoutPath)

        self.shadowPath = shadowPath.cgPath
    }
}

We need a CAShapeLayer because we have to set shadowPath. For that, we need to create a path for the shadow that extends slightly outside the bounds of the layer using insetBy from bounds. And then another UIBezierPath — a cutout path that is the inverse of the layer’s bounds.

In the illustration, it’s already almost there. But we need the last layer, which will bring us back to MacOS X Leopard.

Border Gradient Layer

It’s a subclass of CAGradientLayer designed to create a gradient border around a layer. It uses a CAShapeLayer as a mask to achieve the border effect. The shapeLayer is configured with a stroke color (initially black) and no fill color and is used as the mask for the BorderGradientLayer. This setup allows the gradient to be visible only where the shapeLayer has a stroke, effectively creating a gradient border.

final class BorderGradientLayer: CAGradientLayer {
    
    let shapeLayer: CAShapeLayer = {
        let layer = CAShapeLayer()

        layer.strokeColor = UIColor.black.cgColor // Temporary color
        layer.fillColor = nil
        return layer
    }()
    
    init(borderWidth: CGFloat, colors: [UIColor]) {
        super.init()

        self.mask = shapeLayer
        self.colors = colors.map { $0.cgColor }
        self.setBorderWidth(borderWidth)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSublayers() {
        super.layoutSublayers()
        
        shapeLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius).cgPath
    }
    
    func setBorderWidth(_ borderWidth: CGFloat) {
        self.shapeLayer.lineWidth = borderWidth
    }
}

The initializer of BorderGradientLayer accepts a borderWidth and an array of UIColors for the gradient. It sets up the gradient colors, applies the border width to the shapeLayer, and uses the shapeLayer as a mask to restrict the gradient to the border area.

The layoutSublayers method ensures that the path of shapeLayer is updated to match the layer's bounds and corner radius, making sure the border fits the layer correctly. The setBorderWidth method allows dynamic adjustment of the border's width after initialization.

Way better. Ok. Let’s stop making visual stuff, and let’s work on the logic.

All together

Now, it’s time to combine all these layers and let them into life.

private lazy var backgroundLayer = BackgroundLayer(
        borderWidth: borderWidth,
        colors: borderColors
)
    
private let glareLayer = ProgressGlareLayer()
private let shimmeringLayer = ShimmeringLayer()
private let progressMaskLayer = ProgressMaskLayer()
private let progressBackgroundLayer = ProgressBackgroundLayer()
    
public init() {
        super.init(frame: .zero)
        
        layer.backgroundColor = UIColor.white.cgColor
        layer.masksToBounds = true
        
        layer.addSublayer(progressMaskLayer)
        layer.addSublayer(progressBackgroundLayer)
        layer.insertSublayer(backgroundLayer, at: 0)
        
        
        progressBackgroundLayer.addSublayer(shimmeringLayer)
        progressBackgroundLayer.addSublayer(glareLayer)
        
        progressBackgroundLayer.mask = progressMaskLayer
}

Let’s take a look at how we going to deal with all layers which should change their width with animation:

public func setValueWithAnimation(_ value: Double,
                               duration: TimeInterval = 1.0,
                               animationType: CAMediaTimingFunctionName = .easeInEaseOut,
                               completion: (() -> Void)? = nil) {
        
        let newWidth = calculateProgressWidth(value)
        let animationGroup = DispatchGroup()

        animationGroup.enter()
        shimmeringLayer.animateToWidth(
            newWidth - WIDTH_ANIMATABLE_INSET,
            duration: duration,
            animationType: animationType,
            completion: animationGroup.leave
        )
        
        animationGroup.enter()
        progressMaskLayer.animateToWidth(
            newWidth,
            duration: duration,
            animationType: animationType,
            completion: animationGroup.leave
        )
        
        animationGroup.enter()
        glareLayer.animateToWidth(
            newWidth - WIDTH_ANIMATABLE_INSET,
            duration: duration,
            animationType: animationType,
            completion: animationGroup.leave
        )

        animationGroup.notify(queue: .main) {
            completion?()
        }
}

First, we need to prepare a width from a value. The value should be less than 0 and greater than 1. Plus we need to take into consideration a possible border width.

fileprivate func normalizeValue(_ value: Double) -> Double {
    max(min(value, 1), 0)
}

private func calculateProgressWidth(_ value: Double) -> CGFloat {
        let normalizedValue = normalizeValue(value)
        let width = bounds.width * normalizedValue - borderWidth
        return width
}

For the correct 3D effect, glare and shimmering layers should be with a little padding from the right and left sides:

newWidth - WIDTH_ANIMATABLE_INSET

And finally, the method uses an animation group (DispatchGroup) to synchronize the animations of three layers: shimmeringLayer, progressMaskLayer, and glareLayer. Each layer's width is animated to the calculated width (adjusted by WIDTH_ANIMATABLE_INSET for shimmeringLayer and glareLayer to fit the design) over the specified duration and with the specified animation curve.

The animations are coordinated using the DispatchGroup, ensuring that all animations start simultaneously and the completion closure is called only after all animations have finished. This method provides a visually appealing way to update the progress bar's value with a smooth, synchronized animation across its various decorative layers.

Source Code

The latest version exists as a Swift Package and Pod. So you can grab it from here Retro Progress Bar repository. Btw, contributions are welcome!

Usage

Create an instance of RetroProgressBar. You can add it to your view hierarchy as you would with any UIView.

let progressBar = RetroProgressBar()

Customization:

progressBar.progressColor = UIColor.systemBlue
progressBar.cornerRadius = 5.0
progressBar.borderWidth = 2.0
progressBar.borderColors = [UIColor.white, UIColor.gray]

To change value with animation:

progressBar.setValueWithAnimation(0.75, duration: 1.0, animationType: .easeInEaseOut) {
    print("Animation Completed")
}

Without animation:

progressBar.setValue(0.5)

Final Thoughts

Recreating this old-style progress bar has been a nostalgic dive into the aesthetics I deeply miss. Working with UIKit and CALayer reminded me of the versatility and power of our tools as developers. This project wasn't just about technical implementation; it was a journey back to a beloved design era, proving that we can still craft anything, including the timeless charm of older designs.

This experience has been a poignant reminder that in UI design, simplicity and nostalgia can coexist beautifully, bridging past and present.

Happy coding!


Written by maxkalik | Senior iOS Engineer at Triumph Labs. Launched WordDeposit and SimpleRuler Apps. Tech writer and public speaker.
Published by HackerNoon on 2023/12/17