Background
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 Spark Camera, one of the standouts of great interface and awesome user experience.
Spark Camera was built by design firm IDEO (pronounced “eye-dee-oh”) who have a prettyillustrious 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.
Analysis
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.
Dissecting
To get a feel for what’s going on behind the scenes, we’re going to use a neat trick where we can inject the Reveal library1 into any third party app running on a device.
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 UILabel
and 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 touchesBegan:WithEvent:
andtouchesEnded: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 RecordingCircleOverlayView
).
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 theCGPathRef
, we need to first create a UIBezierPath
for the circle using the methodbezierPathWithArcCenter:radius:startAngle:endAngle:clockwise:
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.
CGPoint arcCenter = CGPointMake(CGRectGetMidY(self.bounds), CGRectGetMidX(self.bounds));
CGFloat radius = CGRectGetMidX(self.bounds) - insets.top - insets.bottom;
self.circlePath = [UIBezierPath bezierPathWithArcCenter:arcCenter
radius:radius
startAngle:M_PI
endAngle:-M_PI
clockwise:NO];
You may have noticed we’re creating the UIBezierPath
to be drawn anti-clockwise from the startAngle: of M_PI
and endAngle:
of -M_PI
. This is to match the behaviour of Spark Camerawhere 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 UIBezierPath
.
CAShapeLayer *backgroundLayer = [CAShapeLayerlayer];
backgroundLayer.path = self.circlePath.CGPath;
backgroundLayer.strokeColor = [[UIColor lightGrayColor] CGColor];
backgroundLayer.fillColor = [[UIColorclearColor] CGColor];
backgroundLayer.lineWidth = self.strokeWidth;
Then we just add it as a sublayer of our RecordingCircleOverlayView
’s layer.
[self.layer addSublayer:backgroundLayer];
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 atSpark Camera, to start recording we need to touch down with our finger and top 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 overridetouchesBegan:WithEvent:
and touchesEnded:WithEvent:
on our UIView
subclass,RecordingCircleOverlayView
.
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 CAShapeLayer
2. We can use our circle UIBezierPath
that we stored away earlier to create a full circle for the segment and usestrokeEnd
to draw only the portion of the segment that has elapsed.
CAShapeLayer *progressLayer = [CAShapeLayerlayer];
progressLayer.path = self.circlePath.CGPath;
progressLayer.strokeColor = [[selfrandomColor] CGColor];
progressLayer.fillColor = [[UIColorclearColor] CGColor];
progressLayer.lineWidth = self.strokeWidth;
progressLayer.strokeEnd = 0.f;
We set the strokeEnd
to 0 initially so that we can animate it later on.
Then we add it as a sublayer of our RecordingCircleOverlayView
’s layer.
[self.layeraddSublayer:progressLayer];
.. but we also want to keep a reference to it so we can animate the strokeEnd
property. We also 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.
[self.progressLayersaddObject:progressLayer];
.. but we can also see that we might need a reference to the current segment. If we look back toSpark 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 ourprogressLayers
array is the current segment, but it’s often nicer to be explicit.
self.currentProgressLayer = progressLayer;
So at the end of all that, we might end up with a method that looks a bit like this
- (void)addNewLayer
{
CAShapeLayer *progressLayer = [CAShapeLayer layer];
progressLayer.path = self.circlePath.CGPath;
progressLayer.strokeColor = [[self randomColor] CGColor];
progressLayer.fillColor = [[UIColor clearColor] CGColor];
progressLayer.lineWidth = self.strokeWidth;
progressLayer.strokeEnd = 0.f;
[self.layer addSublayer:progressLayer];
[self.progressLayers addObject:progressLayer];
self.currentProgressLayer = progressLayer;
}
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 receivetouchesEnded:withEvent:
.
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 anotherCAShapeLayer
property, strokeStart
, an animatable property which when combined withstrokeEnd
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 onCALayer
.
Animating
Let’s take a look at the entirety of our animation method and step through the bits of interest.3
- (void)updateAnimations
{
CGFloat duration = self.duration * (1.f - [[self.progressLayers firstObject] strokeEnd]);
CGFloat strokeEndFinal = 1.f;
for (CAShapeLayer *progressLayer in self.progressLayers)
{
CABasicAnimation *strokeEndAnimation = nil;
strokeEndAnimation = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
strokeEndAnimation.duration = duration;
strokeEndAnimation.fromValue = @(progressLayer.strokeEnd);
strokeEndAnimation.toValue = @(strokeEndFinal);
strokeEndAnimation.autoreverses = NO;
strokeEndAnimation.repeatCount = 0.f;
CGFloat previousStrokeEnd = progressLayer.strokeEnd;
progressLayer.strokeEnd = strokeEndFinal;
[progressLayer addAnimation:strokeEndAnimation forKey:@"strokeEndAnimation"];
strokeEndFinal -= (previousStrokeEnd - progressLayer.strokeStart);
if (progressLayer != self.currentProgressLayer)
{
CABasicAnimation *strokeStartAnimation = nil;
strokeStartAnimation = [CABasicAnimation animationWithKeyPath:@"strokeStart"];
strokeStartAnimation.duration = duration;
strokeStartAnimation.fromValue = @(progressLayer.strokeStart);
strokeStartAnimation.toValue = @(strokeEndFinal);
strokeStartAnimation.autoreverses = NO;
strokeStartAnimation.repeatCount = 0.f;
progressLayer.strokeStart = strokeEndFinal;
[progressLayer addAnimation:strokeStartAnimation forKey:@"strokeStartAnimation"];
}
}
CABasicAnimation *backgroundLayerAnimation = nil;
backgroundLayerAnimation = [CABasicAnimation animationWithKeyPath:@"strokeStart"];
backgroundLayerAnimation.duration = duration;
backgroundLayerAnimation.fromValue = @(self.backgroundLayer.strokeStart);
backgroundLayerAnimation.toValue = @(1.f);
backgroundLayerAnimation.autoreverses = NO;
backgroundLayerAnimation.repeatCount = 0.f;
backgroundLayerAnimation.delegate = self;
self.backgroundLayer.strokeStart = 1.0;
[self.backgroundLayer addAnimation:backgroundLayerAnimation forKey:@"strokeStartAnimation"];
}
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 aCABasicAnimation
to animate the strokeStart
of all layers that aren’t the current layer. This translates into the current segment appearing to grow while maintaining it’s starting position, while each of the previous segments will appear to maintain their size but move along the “track”.
But what’s with those duration
and strokeEndFinal
variables?
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 the 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 initializestrokeEndFinal
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 or coloured segments atop the background layer which could lead to visual artefacts.
Pausing
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 touchesEnded:withEvent:
While our progress segment layers 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 step:
-
For each
CAShapeLayer
we have representing our progress segments, set thestrokeStart
andstrokeEnd
values to the values held by the layerspresentationLayer
. -
Remove all animations from the
CAShapeLayer
.
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
- (void)updateLayerModelsForPresentationState
{
for (CAShapeLayer *progressLayer in self.progressLayers)
{
progressLayer.strokeStart = [progressLayer.presentationLayer strokeStart];
progressLayer.strokeEnd = [progressLayer.presentationLayer strokeEnd];
[progressLayer removeAllAnimations];
}
self.backgroundLayer.strokeStart = [self.backgroundLayer.presentationLayer strokeStart];
[self.backgroundLayer removeAllAnimations];
}
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 touchesBegan:withEvent:
.
To do this, we’ll set our RecordingCircleOverlayView
instance to become the delegate of all the strokeEnd
animations we create, implement the delegate callbackanimationDidStop: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.
- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag
{
if (self.isCircleComplete == NO && flag)
{
self.circleComplete = flag;
}
}
Wrapping up
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.
-
Full disclosure: I work for Itty Bitty Apps, developers of Reveal. ↩
-
Ole Begemann did a great writeup (3+ years ago!) on this technique ↩
-
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. ↩