There’s no denying that camera apps are in vogue. At the time of writing, a third of the apps in the Best New Apps section of the App Store were in the Photo & Video category and with good reason; camera apps are a fantastic way to express creativity for both the budding filmmaker and the developer behind them. We’re going to be looking at Spark Camera, one of the standouts of great user interface and awesome user experience.
Spark Camera was built by design firm IDEO (pronounced “eye-dee-oh”) who have a pretty illustrious history. It has an elegant and simple look which combines form and function. In particular we’re going to be dissecting and rebuilding the circular progress view.
Spark Camera’s recording circle is an aesthetically beautiful control that provides a wealth of information about the current state of the app with little mental overhead.
In one circle of colour it conveys whether the app is recording, the length of the current recording, the length and number of scenes that make up the recording, as well as providing a viewfinder to frame the scene.
The recording circle embraces the idea pushed in iOS 7 that design should be skewed toward functionality rather than ornamentality while maintaining an aesthetically pleasing facade.
We can see that the view hierarchy is as minimal as the interface. The view we’re looking at building (
CaptureProgressView) has one subview (
RecordingTimeIndicatorView) which in turn has a
UIView. This tells us that the circular progress view is likely composed of one or more
CALayers with their contents drawn rather than subviews.
We can also see that there’s no great big
UIButton added to over the top so we’re likely looking to use a
UIGestureRecognizer or overriding
touchesEnded:WithEvent: on the view to start and stop the recording.
Laying the foundations
To kick things off, we’re going to create a subclass of
UIView and call it something meaningful (in this case
We can see that we’ll need a light grey circle to display as the “track” for the progress, so we’ll need to create a
CAShapeLayer and provide it a
CGPathRef for the circle. To create the
CGPathRef, we need to first create a
UIBezierPath for the circle using the method
To provide the coloured segments that represent the recording progress we’ll likely be using additional
CAShapeLayers with the same
CGPathRef as the background layer, so we can go ahead and store this
UIBezierPath as a property so we don’t have to recreate it each time.
You may have noticed we’re creating the
UIBezierPath to be drawn anti-clockwise from the startAngle: of
-M_PI. This is to match the behaviour of Spark Camera where the recording progress starts at 270° and moves counter clockwise around the circle.
Now that we have the
UIBezierPath created, we can create the
CAShapeLayer that’ll provide the background circle and pass it the
CGPathRef we get from the
Then we just add it as a sublayer of our
If we build and run now we’ll see that we’ve got our fancy light grey circle sitting in the middle of our view.
Now we’re going to need a way of starting and stopping the progress circle. If we look back at Spark Camera, to start recording we need to touch down with our finger and to pause we just let go. This rules out
UITapGestureRecognizer as it fires on
UIControlEventTouchUpInside, meaning we’d have to touch down and touch up before we register an event.
While we could use a
UIButton and the control event
UIControlEventTouchDown, we’ve seen in the dissection with Reveal that Spark Camera doesn’t do this, so why should we? Instead we’ll tap into the power of
UIResponder (of which
UIView is a subclass) and override
touchesEnded:WithEvent: on our
Now that we’ve got a way of starting and stopping the progress circle, we need to start drawing the progress.
To display the progress segments we’ll be using the same technique that we used to display the background circle layer, but with a slight twist. To give the appearance of the segment growing over time, we’re going to animate the
strokeEnd property on
CAShapeLayer2. We can use our circle
UIBezierPath that we stored away earlier to create a full circle for the segment and use
strokeEnd to draw only the portion of the segment that has elapsed.
We set the
strokeEnd to 0 initially so that we can animate it later on.
Then we add it as a sublayer of our
…but we also want to keep a reference to it so we can animate the
strokeEnd property. We know that we could potentially be using multiple
CAShapeLayers (one for each progress segment) so storing each segment in it’s own property wouldn’t be feasible. Instead we’ll create an
NSMutableArray property on our
RecordingCircleOverlayView and add each of our progress segment layers to that.
…but we can also see that we might need a reference to the current segment. If we look back to Spark Camera, the current segment is the one that grows, the rest maintain their size and shift their offset based on the next segment. We could be clever and deduce that the last item in our
progressLayers array is the current segment, but it’s often nicer to be explicit.
So at the end of all that, we might end up with a method that looks a bit like this
Now that we have a reference to our current progress segment, as well as all preceding segments, we need a way to animate the progress of the current segment and the position of the existing segments. We also need a way to pause the animation when we receive
This raises a few interesting challenges.
The first challenge is how we’re going to adjust the position of the existing segments. We could apply a rotation transform to the layer, but we could also take advantage of another
strokeStart, an animatable property which when combined with
strokeEnd can define the region of the path to stroke.
The second challenge is how we might pause the animation. Usually when interacting with an animation, it’s very much a set and forget scenario; we tell the animation what to do and how long to take and it will diligently go off and perform it. To effectively ‘pause’ the animation, we’re going to have to do some trickery involving taking a snapshot of the current state of the layer and remove the animation altogether.
To achieve this, we’ll be using
CABasicAnimation and the
presentationLayer property on
Let’s take a look at the entirety of our animation method and step through the bits of interest.3
We can see we’re going to be looping over our collection of progress segment layers and adding a
CABasicAnimation to animate the
strokeEnd of each. We’ll also be adding a
CABasicAnimation to animate the
strokeStart of all layers that aren’t the current layer. This translates into the current segment appearing to grow while maintaining its starting position, while each of the previous segments will appear to maintain their size but move along the “track”.
But what’s with those
Let’s start with
duration. If we imagine that we want the entire animation to take 45 seconds we can pass that in, but what about when we pause and start the animation again? We don’t want that to take another 45 seconds, we want it to take however long the previous animation had left before we paused it. To maintain a persistent duration across all animations, we need a way of keeping track of how far along we’ve progressed. We know that
strokeEnd is a value between 0 and 1 so we can easily use the
strokeEnd of the first segment that was added to determine an overall percentage of how far along we are.
Now what about this
strokeEndFinal. If we imagine we have multiple progress segments, then we wouldn’t want them to all animate to the end, instead we would want them to take into account the region of the full circle that has already elapsed. To do this we initialize
strokeEndFinal with 1.0 and deduct from it the percentage of the full circle that each preceding segment occupies. In other words if we have multiple segments, the first should finish at the end of the circle, the second should finish at the start of the first and so on..
As Hjalti Jakobsson pointed out on Twitter, we’ll also need to update our background layer to be the full circle minus the length of the segments. This is to prevent drawing of coloured segments atop the background layer which could lead to visual artefacts.
Now that we have our animation logic in place, we need to figure out how we’re going to pause/stop the animation when we receive
While our progress segment layer models have been updated to reflect what could potentially be their final state, when we release our finger we effectively want to stop the animation and update the models to reflect the state of their presentation layers.
To accomplish this, we’ll need to do the following two steps:
CAShapeLayerwe have representing our progress segments, set the
strokeEndvalues to the values held by the layer’s
Remove all animations from the
presentationLayer of a
CALayer represents the current visual state of the layer and, to quote the iOS 7 docs:
While an animation is in progress, you can retrieve this object and use it to get the current values for those animations.
If we put these two together, we might end up with something like this
Drawing within the lines
So we can now start and stop our animations, awesome! But there’s one more piece of the puzzle. We need to make sure once we have completed our animation we don’t keep adding layers and updating animations when we receive
To do this, we’ll set our
RecordingCircleOverlayView instance to become the delegate of all the
strokeEnd animations we create, implement the delegate callback
animationDidStop:finished: and check for any animations that have finished. If any animation has finished, we can assume that all have finished and the circle is complete! Then we store the completion state in a flag (
circleComplete) and check it before we start or stop any further animations.
As you can see there’s not a lot of code behind a control like this. There are a few edge cases here and there but the real work is coming up with such an ingeniously simple and elegant way to solve a problem like this on a mobile device.
You can checkout this project on Github.
The original implementation required the animation to be removed manually before updating the model. David Rönnqvist posted a terrific explanation on why this is considered a bad pattern for Core Animation and provided a much better implemention. ↩