Replicating the App Store download button


With the release of iOS 11, Apple introduced a completely redesigned App Store app with greatly improved UI. Among many features and improvements, one thing that I found interesting was the redesigned download/purchase/update button.
I’ve been working in my spare time on implementing the download button. After a bit of experimenting, I implemented a working version. I only needed a couple more days to clean up the code and package it into a CocoaPod. You can take a look at the source code on the AHDownloadButton GitHub repo. The final result can be seen below:

Download button

The README section describes how to use the library. In this blog post, I will provide some details on how I implemented the download button as well as discuss some interesting points about the implementation.


Download state implementation

The download button has 4 different states:

  1. startDownload
  2. pending
  3. downloading
  4. downloaded

Each of these states is implemented as a separate view class.

The startDownload and downloaded state use the same class - HighlightableRoundedButton. It is a simple subclass of UIButton that has rounded corners and highlightable background and title. To implement highlighting I’ve overridden the isHighlighted property of UIButton:

override var isHighlighted: Bool {
    didSet {
        backgroundColor = isHighlighted ? highlightedBackgroundColor : nonhighlightedBackgroundColor
        let titleColor = isHighlighted ? highlightedTitleColor : nonhighlightedTitleColor
        setTitleColor(titleColor, for: .normal)
    }
}


The pending state uses a CircleView. It is a UIView subclass that has startAngleRadians and endAngleRadians properties that define the start and end angle of the circle. The drawing is implemented by adding a CAShapeLayer with a circular path. The path is defined by overriding layoutSubviews method:

override func layoutSubviews() {
    super.layoutSubviews()
    let radius = min(frame.width / 2, frame.height / 2) - lineWidth / 2
    let center = CGPoint(x: frame.width / 2, y: frame.height / 2)
    circleLayer.path = UIBezierPath(arcCenter: center,
                                    radius: radius,
                                    startAngle: startAngleRadians,
                                    endAngle: endAngleRadians,
                                    clockwise: true).cgPath
}


The downloading state is presented using the ProgressButton class. It is a UIControl subclass that uses a ProgressCircleView to show download progress. Whenever the user updates the progress property of the ProgressButton, the ProgressCircleView animates the progress change. The progress property is implemented in the following way:

var progress: CGFloat = 0 {
    didSet {
        if progress == 1 && isAnimating {
            if let currentAnimatedProgress = circleView.circleLayer.presentation()?.strokeEnd {
                circleView.circleLayer.strokeEnd = currentAnimatedProgress
                animateProgress(from: currentAnimatedProgress, to: progress)
            }
        }

        guard !isAnimating else { return }
        animateProgress(from: circleView.circleLayer.strokeEnd, to: progress)
    }
}

Whenever the progress is set to a value lower than 1, the isAnimating property is checked to make sure that the last change is not currently animating. If it’s not, it will animate the progress change to the newest value. If the progress is set to 1 and the last change is still animating then the running animation is overridden and progress is animated from the current presentation value to 1. This is the code that animates the progress change:

private func animateProgress(from startValue: CGFloat, to endValue: CGFloat) {
    isAnimating = true
    circleView.circleLayer.strokeEnd = endValue
    let animation = CABasicAnimation(keyPath: "strokeEnd")
    animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
    animation.fromValue = startValue
    animation.duration = progressAnimationDuration
    animation.delegate = self
    circleView.circleLayer.add(animation, forKey: "strokeEnd")
}


State transition animations

Transition from one state to another is animated whenever the state property of the download button is changed. The initial implementation was very simple:

public var state: State = .startDownload {
    didSet {
        self.animateTransition(from: oldValue, to: state)
    }
}

After a bit of testing, I noticed an edge case that I missed here. What would happen if the state is changed during an animation of the previous state change? The animation for the latest state update would override the previous animation and it wouldn’t look nice. I needed to make sure that, whenever the state is changed, the animation for previous change completes before I animate the latest change. To do that, I used a background DispatchQueue in combination with a DispatchGroup:

public var state: State = .startDownload {
    didSet {
       // 1
       animationQueue.async { [currentState = state] in
           // 2
           self.animationDispatchGroup.enter()

           // 3
           var delay: TimeInterval = 0
           if oldValue == .downloading && currentState == .downloaded && self.downloadingButton.progress == 1 {
               delay = self.downloadingButton.progressCircleView.progressAnimationDuration
           }

           // 4
           DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
               self.animateTransition(from: oldValue, to: currentState)
           }
           // 5
           self.animationDispatchGroup.wait()
       }
    }
}


There are a few things to go over here:

  1. I used a background queue to chain one transition animation after another. I am doing this because I do not want to block the main thread while waiting for the previous animation to finish. Also, note that I’ve captured the value of the state property inside a capture list. I have to do that because I want the closure to capture the state value at the time of creation, not at the time of execution.

  2. I used a DispatchGroup for synchronization. I call animationDispatchGroup.enter() to indicate that I am going to animate a transition and that other animations need to wait for the current one to finish.

  3. If the transition happens from downloading to downloaded state with a complete progress, then a delay has to be introduced to let the final progress animation finish before the transition animation starts.

  4. Since animations need to be done on the main thread I have to dispatch the animation on the main queue.

  5. In the last step, I call animationDispatchGroup.wait(). This will cause the background queue to be blocked until the animateTransition(from: oldValue, to: currentState) finishes execution and calls animationDispatchGroup.leave().

Using this approach I have synchronized the animations and I didn’t block the main thread while doing that. To see how animateTransition(from: oldValue, to: currentState) works I suggest that you take a look at the source code.


Conclusion

This was an interesting and fun project to make. In this post, I’ve described the more interesting parts of the implementation. If you want to know how I implemented it in more detail, how I separated and organized the classes and how I used them, head over to my GitHub repo. You can also find the documentation that describes how you can use and customize the download button.