Facebook Music Stories Spinning Artwork Teardown 1/2: circular progress bar

Few months ago Facebook implemented the feature of sharing a song from an external streaming app, like Spotify, Deezer or Apple Music.

Facebook Music Stories article on 9TO5Mac

Leaving aside all the considerations about what it could mean for sharing music, the thing I liked the most was the smooth animation that transforms the artwork in a spinning record-like; also, the button presents a circular progress bar to indicate the progress of the song.

Goal

We want to replicate in full the Facebook Music Stories animation, and, because it would be too long do it in the same tutorial, we’ll split into two blog posts: in the current one we’ll implement the skeleton of the app and the circular progress bar, in the next one we’ll implement the transformation into a blurred spinning record and back.

Observations

As said, we’ll concentrate in this post to haves a basic app to host the animation and to implement the progress bar.

As you can see in the video above, the play buttons become a pause button and a circular progress grows as the song plays.

Blueprint

Project Setup

Let’s fire up Xcode and create a new project using the template Single View Application:

After that we give the name, SpinningArtwork or whatever you want, without forgetting to select Swift as language:

As you can see the template creates an empty ViewController embedded in a Main.storyboard. To make it clearer, let’s rename it as SpinningArtworkViewController, and change it in the storyboard, after selecting the ViewController:

Assets

Let’s now add the assets to the project: download the zip from here After selecting the Assets:

we drag the resources in the right sidebar, and we move the artwork in the @2x place:

For the button assets, because they are pdf they can be automatically resized by Xcode during the compile fase, but we need to specify that they are vectorial and then universal:

Blurred Background

Although not completely related to the component we want to implement, having a blurred background behind the artwork gives an idea of completeness to our example, and it reminds the original component from Facebook. Let’s start adding a UIImageView as a subview of the main view in the Storyboard, and then we configure the constraints to make it big as the mainview. The process is quite straightforward, but notably don’t forget to:

  • add AbbeyRoadArtwork as Image of the UIImageView
  • select Aspect Fill in the view mode, otherwise, the image will be stretched
  • uncheck Constrain to margins to have the image covering the whole View
  • add 0 to all of the boundary constraints
  • select Update Frames: Items of new Constraints, so that the view in Interface Builder will be updated accordingly to the new layout

Let’s now add a Visual Effect View to the hierarchy:

Similarly, we add the constraints to the view:

Running the app we have a nice blurred background effect:

Spinnable Artwork

Having finally a skeleton of the app, let’s implement a custom view for our component. To make more interesting, we’ll make it as Designable, which means that it can be seen in Interface Builder. To make it designable, we need to prepend the keyword @IBDesignable to the class, and because we want to be able to set the image of the artwork interactively in Interface Builder, we create a property imageName and we decorate it with @IBInspectable. Usually designable means that the view has a way to draw itself in the code, but it can be also be defined using a nib file: to do this, we need to add a view as a subview of custom view itself and add some magic to load the class from the nib. The following is the public interface of the class:

import UIKit

@IBDesignable
class SpinnableArtwork: UIView {
    @IBOutlet var view: UIView!
    @IBOutlet var artworkImageView: UIImageView!
    @IBOutlet var playerButton: PlayerButton!

    @IBInspectable var imageName: String! {
        didSet {
            artworkImageView.image = UIImage(named: imageName)
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }
}

As you can see, in both the initializers we are calling a setup() function that is defined in a private extension:

private extension SpinnableArtwork {
    func setup() {
        view = loadViewFromNib(theClassName)
        view.frame = bounds
        view.autoresizingMask = [UIViewAutoresizing.FlexibleWidth, 
                                 UIViewAutoresizing.FlexibleHeight]
        addSubview(view)
    }
    
    func loadViewFromNib(nibName: String) -> UIView {        
        let bundle = NSBundle(forClass: self.dynamicType)
        let nib = UINib(nibName: nibName, bundle: bundle)
        let view = nib.instantiateWithOwner(self, options: nil)[0] 
                    as! UIView        
        return view
    }
    
    var theClassName: String {
        return NSStringFromClass(self.dynamicType)
               .componentsSeparatedByString(".").last!
    }
}

As you can imagine from the previous code, we need to create a nib file with the same name as the class: SpinnableArtwork in this case. In Interface Builder we then set the File’s Owner as the class we have just created:

Before adding the views, we create a skeleton of button for the play/pause, which inherits from UIImageView:

import UIKit

class PlayerButton: UIImageView {
}

This will change the image from play to pause and it will contain the circular progress bar.

As you can see in the following image, the custom view is composed by a container view, which is loaded in the setup() function in the code, a full screen image that contains the artwork, and the Player Button in the center:

The constraints are really simple: basically all the view are centered in the parent view, and the size is equal to the parent for the container and the artwork image view, and smaller for the button:

To define that the player button is always proportionally smaller than the parent view, we defined a equal relationship between its width and the width of the parent, and then we changed the multiplier constants to 0.4:

Now let’s add our custom view in the Main.storyboard. To do this we add a plain UIView and then we change the Custom Class to SpinnableArtwork. The constrains are basically to center it, setting an aspect ratio 1:1, and fix the width to 240:

After selecting the Spinnable Artwork as custom class, we can see in the Attributes Inspector that a new Image Name attribute is appeared: as you can guess, it is our inspectable variable that assigns the image to the custom class:

Setting “AbbeyRoadArtwork” as value and running the app, we can see that our new component works as expected. However, wasn’t IBDesignable supposed to show in the Storyboard? Why we still have a white view? This is because when the app runs, the image is retrieved from the main bundle, but when the app runs inside Interface Builder, the bundle is different and the image is not found. To make it appear in the storyboard we need to add a function int he SpinnableArtwork class, to prepare the view for the storyboard:

extension SpinnableArtwork {
    override func prepareForInterfaceBuilder() {
        let image = UIImage(named: "AbbeyRoadArtwork", inBundle: NSBundle(forClass: self.dynamicType), compatibleWithTraitCollection: nil)
        artworkImageView.image = image
    }
}

Now the artwork is visible in the storyboard as well.

Play and Pause

Let’s move on and make the button changing when we tap on the view. First of all, we add the IBAction in the SpinningArtwork class:

@IBDesignable
class SpinnableArtwork: UIView {
    //...    
    private var playing = false {
        didSet {
            updateUI()
        }
    }
    
    @IBAction func artworkDidTap(sender: AnyObject) {
        playing = !playing
    }
    //...
}

Where the private function updateUI() changes the image in the button:

private extension SpinnableArtwork {
    func setup() {
        //...
        updateUI()
    }
    //...
    func updateUI() {
        if playing {
            playerButton.image = UIImage(named: "large_pause")
        } else {
            playerButton.image = UIImage(named: "large_play")
        }
    }
}

As you can see we have also added a call to updateUI() during the setup, so that the player button is correctly initialized. Finally, we add a TapGesture and we connect the action to the function artworkDidTap:

Player

A play/pause button is quite useless, so that let’s implement a Fake Player that just update the progress every second. To be ready to use a real player, we define a protocol Player:

protocol Player {
    func play()
    func stop()
}

We define also a protocol for every object interest in receiving the progress of the player:

protocol PlayerObserver: class {
    func progress(progress: Int)
}

Finally, let’s implement a Fake Player:

class FakePlayer: Player {
    private var timer: NSTimer!
    private var progress = 0
    weak var playerObserver: PlayerObserver?

    func play() {
        timer = NSTimer.scheduledTimerWithTimeInterval(0.5,
            target: self,
            selector: Selector("timerDidFire"),
            userInfo: nil,
            repeats: true)
        progress = 0
    }
    
    func stop() {
        timer.invalidate()
    }
    
    @objc func timerDidFire() {
        progress += 1
        if progress > 100 {
            timer.invalidate()
            return
        }
        
        playerObserver?.progress(progress)
    }
}

The SpinnableArtwork, being interested in the progress of the player, must implement the observer protocol:

extension SpinnableArtwork: PlayerObserver {
    func progress(progress: Int){
        print("Progress: \(progress)")
    }
}

To be able to play and stop the player, we add a player property, and we change the tap action:

@IBDesignable
class SpinnableArtwork: UIView {
    //...
    var player: Player?
    
    @IBAction func artworkDidTap(sender: AnyObject) {
        if playing {
            player?.stop()
        } else {
            player?.play()
        }
        playing = !playing
    }
    //...
}

In the SpinningArtworkViewController we create an outlet to connect the view in the storyboard, and we set the player in it:

class SpinningArtworkViewController: UIViewController {
    @IBOutlet var spinnableArtwork: SpinnableArtwork! {
        didSet {
            let player = FakePlayer()
            player.playerObserver = spinnableArtwork
            spinnableArtwork.player = player
        }
    }
}

Running the app, we can see that tapping on the cover, the button changes and the progress appears in the Xcode console:

Circular Progress Bar

Finally, we are set everything for the grand finale. Most important class of this first part of the project is the ProgressLayer, which is a full circle Shape Layer, and it exposes a progress() function that draws the percentage of the circle, being 0 at the beginning and 100 at the end:

import UIKit

class ProgressLayer: CAShapeLayer {
    private struct Constants {
        static let startAngle = -CGFloat(M_PI_2)
        static let endAngle: CGFloat = CGFloat(2*M_PI) + startAngle
    }
    
    func computePath(rect: CGRect) {
        strokeColor = UIColor.whiteColor().CGColor
        lineWidth = 8
        lineCap = kCALineCapButt;
        strokeEnd = 0.00;
        fillColor = UIColor.clearColor().CGColor
        
        let side = rect.width / 2.0
        let radius = side - lineWidth
        
        let path = CGPathCreateMutable()
        CGPathAddArc(path, nil, side, side, radius, Constants.startAngle, Constants.endAngle, false)
        self.path = path
    }
    
    func progress(progress: Int) {
        if strokeEnd >= 1 {
            strokeStart = (CGFloat(progress)-1)/100.0
        } else {
            strokeStart = 0
        }
        
        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.duration = 0.5
        animation.fillMode = kCAFillModeForwards
        animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
        animation.removedOnCompletion = false
        strokeEnd = CGFloat(progress)/100.0

        addAnimation(animation, forKey: "strokeEnd animation")
    }
}

Actually, the code is straightforward. Probably the only notable thing is the calculation of the initial and final angle: being the starting angle of an arc in iOS at the right horizontal, and we want to make it start at the central vertical, we need the subtract PI/2.

We then set this layer as the default layer in the PlayerButton:

class PlayerButton: UIImageView {
    override class func layerClass() -> AnyClass {
        return ProgressLayer.self
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        (layer as! ProgressLayer).computePath(bounds)
    }
}

extension PlayerButton: PlayerObserver {
    func progress(progress: Int){
        (layer as! ProgressLayer).progress(progress)
    }
}

In the SpinnableArtwork we just need to call the progress function in the playable button:

extension SpinnableArtwork: PlayerObserver {
    func progress(progress: Int){
        print("Progress: \(progress)")
        playerButton.progress(progress)
    }
}

Et Voilà, we implemented a Circular Progress Bar!

Conclusions

The post was longer than I thought, but it permits to have a clear idea on how to build an interactive custom component. We saw how to have a Designable View, how to implement a layer, and how to animate it. The code for this part can be found here on Github




Share this story