The wealth and calibre of podcast apps on the App Store is impressive (read: intimidating). Offerings like Pocket Casts by Shifty Jelly and Downcast by Jamawkinaw Enterprises have done an amazing job at raising the bar, and with Apple as a fellow player in the category, standing out is no mean feat.
Castro is an exercise in bold restraint. It eschews buttons wherever possible in favour of gestural interactions allowing the screen to defer to the content, rather than the controls.
While Apple provided the interactive pop gesture in iOS 7, it has largely been used as an auxiliary way of popping view controllers off the navigation stack. Castro takes opposite tact and does away with the back button altogether, incorporating gestures to naturally switch between contexts.
When listening to a podcast, the playback controls follow you around the app, morphing between states to give you only what you need. When you need to do more than just pause and play, the playback controls give you a fluid way to scrub through your podcast.
That’s what we’ll be looking at today.
Castro’s playback controls are built atop the traditional model of device playback controls: play/pause, fast forward and rewind. Scrubbing through content is where these controls really shine.
The standard pattern in iOS (and many platforms) is to utilize a playhead to convey elapsed time, as well as be the control with which to scrub through the playback. Castro takes a modern, touch optimized approach where the entire playback bar becomes one big slider to move the playhead throughout the content. This creates a responsive and natural way to adjust the playback position.
Having a control that manipulates playback reside in the primary touch area of the device is fraught with potential issues. Having a control that can skip through content with a swipe increases these issues exponentially. How often have you accidentally swiped the screen of a device when you’re holding it in one hand?
Castro overcomes these issues by not committing the change to the playback until the control has finished animating to its editing state, a change that takes ~0.2 seconds. This provides just enough time to cancel any touches before you accidentally skip halfway through that hour long podcast.
The attention to detail in this control is phenomenal. Just when you think you’ve got it figured out there’s another little subtlety ready to impress.
If you’ll indulge me for a moment, it may be interesting to dig a bit deeper into why we might need such fine control over the playback of podcast content.
It’s reasonable to say that controls for audio playback on mobile devices are generally geared toward music playback. While podcasts are delivered in the same format, consumption typically differs from music.
Podcasts are primarily delivering content through verbal language. When the meaning of the content is reliant on the preceding context, the ability to quickly move backward and forward becomes more important (this is where we might go off on a tangent about music and its construction also being reliant on the preceding context, but that may be out of the scope of this post).
At the time of writing, the top 10 songs on iTunes had an average length of 3:34, whereas the top 10 podcasts had an average length of 43:56. If we couple the nature of the content with the increased average duration, it becomes apparent that the resolution of the controls to manipulate playback should be adjusted accordingly.
At first glance, it might seem like our friend
UIScrollView would be a good candidate to provide the foundation of the scrubbing mechanism, but if we look closely, there’s more than immediately meets the eye.
Notice that slight bounce when the controls bar returns to its original position after the dragging interaction? That’s not something we normally see with
UIScrollView, at least not out of the box. Normally we’d expect to see
UIScrollView have an ease out curve when returning to its original position. This control overshoots its mark and snaps back.
If we do some sleuthing, iTunes tells us that the minimum iOS version for Castro is iOS 7. To draw a long bow, we could interpret this evidence of the use of UIKit Dynamics.
To add a bit more weight to this hypothesis, we need only look to the hint we get when we tap on the edges of the control.
Notice that the view again exceeds its original position slightly before coming to a rest?
This is a subtle but significant detail. It’s part of what makes the control really great. All animations share the same physical properties.
Looks like a good reason to get our feet wet with UIKit Dynamics.
Before we get started with UIKit Dynamics (AKA the fun stuff), we’re going to need a way of moving our controls horizontally to initiate the scrubbing.
To do this, we’ll add a
UIPanGestureRecognizer to our control’s view to track the location of our finger on the screen and translate that to the horizontal center of our control’s view.
Now that we’re able to move our controls around, on to the fun stuff.
At a basic level, UIKit Dynamics is a collection of high level APIs that provide developers a way to imbue user interface elements with properties that mimic the physical world. What this means for those using the device is a more cohesive and realistic feel across all apps that incorporate dynamics.
To mimic the force applied to the control’s view to snap it back to its original position, we’ll be using the
We create a
UISnapBehavior instance with the
initWithItem:snapToPoint: initializer where
self.controlsView is the view that we want to drag and
point is the original center point that we want to snap back to.
We also provide a
damping value which will control how much bounce/oscillation we see once the view returns to its origin.
This will ensure that if our view moves (for instance by a user gesture), UIKit Dynamics will kick in and snap it back to its original position with a slight bounce at the end, however we’re not going to add our
UISnapBehavior to our
UIDynamicAnimator just yet.
Instead we’re going to use our
panGestureRecognized: method we defined earlier and the state of our
UIPanGestureRecognizer to determine when we add/remove our
By removing our
UISnapBehavior when we detect the start of a pan gesture and only re-adding it once the gesture has ended, we avoid having UIKit Dynamics interfere with our control’s view while we’re tracking touches.
But there’s a problem that reveals itself once we build and run. We haven’t told UIKit Dynamics anything about how we want our view to react to the force of the
By default, a dynamic item (
UIView is one these) will allow rotation, so when the force of our
UISnapBehavior is applied, it will rotate based on the force.
To disable rotation, we need to create an instance of
UIDynamicItemBehavior, associate it with our control’s view and use it to override the default rotation.
Now that we have multiple behaviors added to our
UIDynamicAnimator, it’s a good time to talk about composite behaviors.
UIKit Dynamics ships with a few concrete subclasses of
UIDynamicBehavior that we can use out of the box:
UISnapBehavior. But we can also subclass
UIDynamicBehavior and add child behaviors to create our own composite behaviors.
We can turn the two behaviors we use to recreate the scrubbing interaction into a composite behavior which will clean up our code, as well as make it easy for us to add/remove the behaviors with one call.
Now that we’ve got our control tracking our touches and returning to its original position on release, we need a view that can represent the elapsed time relative to the total duration.
In previous articles, we’ve handled displaying progress by using
CAShapeLayer. For our case, we don’t need the low level flexibility of
CAShapeLayer so we’ll be implementing our progress view with old reliable
We’ll start by making a subclass of
UIView which will be responsible for displaying our progress as both the white timeline and the text label showing the elapsed time.
Now we’ll need a way to update the views based on what time has elapsed and how that relates to the overall duration of the podcast.
This is the point where we’ll need an object that models these two properties. To do this, we’ll create an
NSObject subclass with an
totalTime property represented as
All the pieces are in place, so now we need a way of notifying our timeline view that it needs to update its subviews based on the state of our
SCPlaybackItem object. For this, we’ll provide a public interface with which we can pass in our
SCPlaybackItem object to our
SCTimelineView and allow it to update its subviews accordingly.
Updating our timeline progress is as simple as modifying the width of the
progressView based on the elapsed time vs the overall duration.
Updating our elapsed time label has a little bit more to it.
If we look at Castro’s timeline view, there’s a lovely little touch where the elapsed time text switches from the right to the left of the playhead based on whether there is enough space to do so. This allows the eye to follow the playhead and see both the relative elapsed time as well as the actual elapsed time in hours/minutes/seconds.
When we update our elapsed time label, we’ll first size our label to the appropriate width, then position it depending on whether it is narrow enough to fit in the width of the
Castro’s timeline view follows the same restrained ethos found throughout the app. When you’re scrubbing through the content it expands vertically, borrowing the elapsed time label from the controls to give you just the information you need at that time. When you’ve finished scrubbing, it collapses down into a slim ~2 point view.
When we slow down this process, we can see that the elapsed time label appears to grow as the view expands.
To mimic this, we’ll use a
CGAffineTransform on our
SCTimelineView to adjust both the scale and positioning of the view.
While it might seem initially counter intuitive, we’ll have our default (
CGAffineTransformIdentity) be the expanded state and our collapsed state be the transformed state. This will allow us to effectively restore our elapsed time label to its default transform when we expand out and “shrink” it down when we collapse our timeline view.
In addition to the scale transformation, we’re also transforming the position of the timeline view. This gives the appearance of the timeline view collapsing to become part of the controls view and expanding out to become its own, distinct control.
Expanding the view is as simple as setting our
SCTimelineView’s transform back to
We spoke briefly about the potential pitfalls of using gestures to control playback of long content, namely accidental swiping, and how Castro gets around this is by not committing playback scrubbing until the timeline has completed its expansion animation.
The great thing about this approach is it keeps the control feeling responsive while still providing a sense of security that you won’t accidentally skip through the podcast.
To implement this behavior, we’ll need to keep a reference to the elapsed time before it has been modified, set a flag (
commitTimelineScrubbing) in our expansion animation completion block to indicate that we assume the scrubbing was intentional, and restore the elapsed time if the flag is set to
NO when we collapse our timeline view.
In the callback from our
UIPanGestureRecognizer, we’ll store our reference to the elapsed time when the
UIPanGestureRecognizer is in the
UIGestureRecognizerStateBegan state, as well as expanding the timeline view.
When we expand the timeline view, we set the
commitTimelineScrubbing flag to
YES in our animation completion block.
Finally, when we collapse our timeline view, we rollback the changes if the
commitTimelineScrubbing flag is set to
Behaviors, Behaviors, Behaviors
Another detail mentioned briefly was the hint the control gives about the scrubbing interaction when you tap on an empty part of it, much like that of the iOS 7 lockscreen camera.
An immediate reaction is to implement this using a combination of
UIPushBehavior to propel the controls outward,
UIGravityBehavior to bring the control back to its original position and
UICollisionBehavior to keep the control within the superview bounds.
This is roughly how we’re going to implement this behavior, but with a bit of a twist.
If we were to use
UICollisionBehavior exclusively to bring the view to a rest, it would collide with the superview bounds before coming to a stop at its original position. This would be in contrast to the physical properties we’ve already defined in our scrubbing behavior, breaking the illusion that it exists in the physical world.
To maintain this illusion, we’re still going to use the combination of
UICollisionBehavior, however we’ll supplement it with our existing
SCScrubbingBehavior composite behavior.
To implement the basis of the tap hint behavior, we’ll create another
UIDynamicItem subclass to serve as a composite behavior.
When we create an instance of our
SCTapHintBehavior, we’ll become the
collisionDelegate of the
UICollisionBehavior so that we can respond to collision events, as well as disabling the
translatesReferenceBoundsIntoBoundary flag as we’ll be managing the boundaries ourselves.
To respond to touch events, we’ll add a
UITapGestureRecognizer to our controls view and handle touches in the selector we provide.
Now we’re going to be doing some pretty funky looking behavior juggling, but the core logic is fairly straightforward.
When we receive a tap, remove our
SCScrubbingBehavior and add our
Check the location of the tap to decide the direction of the bounce.
Trigger an instantaneous push on our
UIPushBehavior in that direction.
Adjust the angle of our
UIGravityBehavior to bring the view back to its original position.
Create a boundary for our
UICollisionBehavior that is slightly outside its origin.
Then when we receive our delegate callback from the
UICollisionBehavior that it has collided with its boundary, we remove our
SCTapHintBehavior and add our
SCScrubbingBehavior, triggering the
UISnapBehavior to snap the controls back to their original position.
Admittedly this turned into a much longer article than was initially planned, thanks for sticking with it!
Good design is thorough down to the last detail.
You can checkout this project on Github.