Spark Camera's recording meter Deconstructing a minimal camera control that combines form and funct

来源:互联网 发布:r语言 数据挖掘 编辑:程序博客网 时间:2024/05/17 07:52

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.

Spark Camera Revealed

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.

Fancy circle

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 CAShapeLayer2. 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:

  1. For each CAShapeLayer we have representing our progress segments, set the strokeStartand strokeEnd values to the values held by the layers presentationLayer.

  2. 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.

  1. Full disclosure: I work for Itty Bitty Apps, developers of Reveal. ↩

  2. Ole Begemann did a great writeup (3+ years ago!) on this technique ↩

  3. 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. ↩

0 0
原创粉丝点击