Custom UI controls are extremely useful when you need some new functionality in your app — especially when they’re generic enough to be reusable in other apps. Colin Eberhart has an excellent tutorial providing an introduction to custom UI Controls, in which you walk through the creation of a custom control derived from UISlider
which allows a range to be selected, as opposed to a single value.
This custom UI tutorial for iOS 7 takes that concept a bit further and covers the creation of a control related to UISlider
, by creating a circular version, inspired by a control knob, such as those found on a sound desk:
UIKit
provides the UISlider
control, which lets you set a floating point value within a specified range. If you’ve used any iOS device, then you’ve probably used a UISlider
to set volume, brightness, or any one of a multitude of other variables. The project you’ll build today will have exactly the same functionality, but instead of being a straight line, will instead be of circular form – like the aforementioned knob.
Getting Started
First, download the starter project here. This is a simple single-view application. The storyboard has a few controls that are wired up to the main view controller. You’ll use these controls later in the tutorial to demonstrate the different features of the knob control.
Build and run your project just to get a sense of how everything looks before you dive into the coding portion; it should look like the screenshot below:
To create the class for the knob control, click File\New\File… and select iOS\Cocoa Touch\Objective-C class. On the next screen, specify the name as RWKnobControl
and have the class inherit from UIControl
. Click Next, choose the “KnobControl” directory and click Create.
Before you can write any code for the new control, you must first add it to the view controller so you can see how it evolves visually.
Open up RWViewController.m and add the following import to the top of the file:
#import "RWKnobControl.h" |
In the same file, just after the @interface
private extension, add the following instance variable:
@interface RWViewController () { RWKnobControl *_knobControl; } @end |
This variable maintains a reference to the knob control.
Now replace viewDidLoad:
with the following code:
- (void)viewDidLoad { [super viewDidLoad]; _knobControl = [[RWKnobControl alloc] initWithFrame:self.knobPlaceholder.bounds]; [self.knobPlaceholder addSubview:_knobControl]; } |
This creates the knob control and adds it to the placeholder on the storyboard. The knobPlaceholder
property is already wired up as a IBOutlet
.
Open RWKnobControl.m and replace the boiler-plate initWithFrame:
with the following code:
- (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { // Initialization code self.backgroundColor = [UIColor blueColor]; } return self; } |
This sets the background color of the knob control so that you can see it on the screen.
Build and run your app and you’ll see the following:
Okay, you have the basic building blocks in place for your app. Time to work on the API for your control!
Designing Your Control’s API
Your main reason for creating a custom UI control is to create a handy and reusable component. It’s worth taking a bit of time up-front to plan a good API for your control; developers using your component should understand how to use it from looking at the API alone, without any need to open up the source code. This means that you’ll need to document your API as well!
The header file associated with your custom control represents the API. In this case, it’s RWKnobControl.h.
Open RWKnobControl.h and add the following code between the @interface
and @end
statements:
#pragma mark - Knob value /** Contains the current value */ @property (nonatomic, assign) CGFloat value; /** Sets the value the knob should represent, with optional animation of the change. */ - (void)setValue:(CGFloat)value animated:(BOOL)animated; #pragma mark - Value Limits /** The minimum value of the knob. Defaults to 0. */ @property (nonatomic, assign) CGFloat minimumValue; /** The maximum value of the knob. Defaults to 1. */ @property (nonatomic, assign) CGFloat maximumValue; #pragma mark - Knob Behavior /** Contains a Boolean value indicating whether changes in the value of the knob generate continuous update events. The default value is `YES`. */ @property (nonatomic, assign, getter = isContinuous) BOOL continuous; |
value
,minimumValue
andmaximumValue
simply set the basic operating parameters of your control.setValue:animated:
andcontinuous
are copied directly from the API ofUISlider
; since your knob control will function similarly toUISlider
, the API should match as well.setValue:animated:
lets you set the value of the control programmatically, while the additionalBOOL
parameter indicates whether or not the change in value is to be animated.- If
continuous
is set toYES
, then the control calls back repeatedly as the value changes; if it is set toNO
, the the control only calls back once after the user has finished interacting with the control.
continuous
, for example, will be called isContinuous
You’ll ensure that these properties behave appropriately as you fill out the knob control implementation later on in this tutorial.
Although there are only five lines of code above, it looks much longer due to the additional comments in the code. These might seem superfluous, but Xcode can pick them up and show them in a tooltip, like so:
Code-completion tips like this are a huge time-saver for the developers that use your control, whether that’s you, your teammates or other people!
Open RWKnobControl.m and add the following method and property overrides after initWithFrame:
:
#pragma mark - API Methods - (void)setValue:(CGFloat)value animated:(BOOL)animated { if(value != _value) { // Save the value to the backing ivar // Make sure we limit it to the requested bounds _value = MIN(self.maximumValue, MAX(self.minimumValue, value)); } } #pragma mark - Property overrides - (void)setValue:(CGFloat)value { // Chain with the animation method version [self setValue:value animated:NO]; } |
You override the setter for the value
property in order to pass it’s value directly on to the setValue:animated:
method. This currently does nothing more than ensure that the value is bounded within the limits associated with the control.
Your API documentation comments specify several defaults; in order to implement them, update initWithFrame:
of RWKnobControl.m as follows:
- (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { // Initialization code self.backgroundColor = [UIColor blueColor]; _minimumValue = 0.0; _maximumValue = 1.0; _value = 0.0; _continuous = YES; } return self; } |
Now that you’ve defined the API of your control, it’s time to get cracking on the visual design.
Setting the Appearance of Your Control
Colin’s tutorial compares CoreGraphics and images as two potential methods to set the appearance of your custom control. However, that’s not an exhaustive list; in this custom UI tutorial for iOS 7, you’ll explore a third option to control the visuals of your control: CoreAnimation layers.
Whenever you use a UIView
, it’s backed by a CALayer
, which helps iOS optimize the rendering on the graphics chip. CALayer
objects manage visual content and are designed to be incredibly efficient for all types of animations.
Your knob control will be made up of two CALayer
objects: one for the track, and one for the pointer itself. This will result in extremely good performance for your animation, as you’ll see later.
The diagram below illustrates the basic construction of your knob control:
The blue and red squares represent the two CALayer
objects; the blue layer contains the track of the knob control, and the red layer the pointer. When overlaid, the two layers create the desired appearance of a moving knob. The difference in coloring above is only to illustrate the different layers of the control — you won’t do this in the control you’ll build.
The reason to use two separate layers becomes obvious when the pointer moves to represent a new value. All you need to do is rotate the layer containing the pointer, which is represented by the red layer in the diagram above.
It’s cheap and easy to rotate layers in CoreAnimation
. If you chose to implement this using CoreGraphics
and override drawRect:
, the entire knob control would be re-rendered in every step of the animation. This is a very expensive operation, and will likely result in sluggish animation, particularly if changes to the control’s value invoke other re-calculations within your app.
Make a new class to contain all of the code associated with rendering the control.
Click File\New\File… and select iOS\Cocoa Touch\Objective-C class. Name the class RWKnobRenderer
and ensure that it subclasses NSObject
. Click Next and save the file in the default location.
Open RWKnobRenderer.h and add the following code between the @interface
and @end
statements:
#pragma mark - Properties associated with all parts of the renderer @property (nonatomic, strong) UIColor *color; @property (nonatomic, assign) CGFloat lineWidth; #pragma mark - Properties associated with the background track @property (nonatomic, readonly, strong) CAShapeLayer *trackLayer; @property (nonatomic, assign) CGFloat startAngle; @property (nonatomic, assign) CGFloat endAngle; #pragma mark - Properties associated with the pointer element @property (nonatomic, readonly, strong) CAShapeLayer *pointerLayer; @property (nonatomic, assign) CGFloat pointerAngle; @property (nonatomic, assign) CGFloat pointerLength; |
Most of these properties deal with the visual appearance of the knob, with two CAShapeLayer
properties representing the two layers which make up the overall appearance of the control.
Switch to RWKnobRenderer.m and add the following method between the @implementation
and @end
statements:
- (id)init { self = [super init]; if (self) { _trackLayer = [CAShapeLayer layer]; _trackLayer.fillColor = [UIColor clearColor].CGColor; _pointerLayer = [CAShapeLayer layer]; _pointerLayer.fillColor = [UIColor clearColor].CGColor; } return self; } |
This creates the two layers and sets their appearance as transparent.
The two shapes which make up the overall knob will be created from CAShapeLayer
objects. These are a special subclass of CALayer
that draw a bezier path using anti-aliasing and some optimized rasterization. This makes CAShapeLayer
an extremely efficient way to draw arbitrary shapes.
Add the following two methods to RWKnobRenderer.m directly after init
:
- (void)updateTrackShape { CGPoint center = CGPointMake(CGRectGetWidth(self.trackLayer.bounds)/2, CGRectGetHeight(self.trackLayer.bounds)/2); CGFloat offset = MAX(self.pointerLength, self.lineWidth / 2.f); CGFloat radius = MIN(CGRectGetHeight(self.trackLayer.bounds), CGRectGetWidth(self.trackLayer.bounds)) / 2 - offset; UIBezierPath *ring = [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:self.startAngle endAngle:self.endAngle clockwise:YES]; self.trackLayer.path = ring.CGPath; } - (void)updatePointerShape { UIBezierPath *pointer = [UIBezierPath bezierPath]; [pointer moveToPoint:CGPointMake(CGRectGetWidth(self.pointerLayer.bounds) - self.pointerLength - self.lineWidth/2.f, CGRectGetHeight(self.pointerLayer.bounds) / 2.f)]; [pointer addLineToPoint:CGPointMake(CGRectGetWidth(self.pointerLayer.bounds), CGRectGetHeight(self.pointerLayer.bounds) / 2.f)]; self.pointerLayer.path = pointer.CGPath; } |
updateTrackShape:
creates an arc between the startAngle
and endAngle
values with a radius that ensures the pointer will fit within the layer, and positions it on the center of the trackLayer
. Once you create the UIBezierPath
, you then use the CGPath
property to set the path
on the appropriate CAShapeLayer
.
CGPathRef
is the CoreGraphics equivalent of UIBezierPath
. Since UIBezierPath
has a nicer Objective-C API, you use that to initially create the path, and then convert it to CoreGraphics.
updatePointerShape
creates the path for the pointer at the position where angle
is equal to zero. Again, you create a UIBezierPath
, convert it to a CGPathRef
and assign it to the path
property of your CAShapeLayer
. Since the pointer is a simple straight line, all you need to draw the pointer are moveToPoint:
and addLineToPoint:
.
Calling these methods redraws the two layers; this must happen when any of the properties used by these methods are modified. To do that, you’ll need to override some of the setters for the properties you added to the API for the renderer to use.
Add the following code after the updatePointerShape
method in RWKnobRenderer.m:
- (void)setPointerLength:(CGFloat)pointerLength { if(pointerLength != _pointerLength) { _pointerLength = pointerLength; [self updateTrackShape]; [self updatePointerShape]; } } - (void)setLineWidth:(CGFloat)lineWidth { if(lineWidth != _lineWidth) { _lineWidth = lineWidth; self.trackLayer.lineWidth = lineWidth; self.pointerLayer.lineWidth = lineWidth; [self updateTrackShape]; [self updatePointerShape]; } } - (void)setStartAngle:(CGFloat)startAngle { if(startAngle != _startAngle) { _startAngle = startAngle; [self updateTrackShape]; } } - (void)setEndAngle:(CGFloat)endAngle { if(endAngle != _endAngle) { _endAngle = endAngle; [self updateTrackShape]; } } |
setPointerLength:
and setLineWidth:
affect both the track and the pointer, so once the mechanics of saving the new value into the backing instance variable have been dealt with, you call both updateTrackShape
and updatePointerShape
. However, the start and end angles only matter to the track, so changes to these properties only call updateTrackShape
.
Okay — your knob updates appropriately once these properties are set. However, the color
property has yet to be hooked up into the CAShapeLayer
rendering.
Add the following method right after setEndAngle:
in RWKnobRenderer.m:
- (void)setColor:(UIColor *)color { if(color != _color) { _color = color; self.trackLayer.strokeColor = color.CGColor; self.pointerLayer.strokeColor = color.CGColor; } } |
This is very similar to the other property overrides; this time, rather than having to redraw the paths associated with each of the shape layers, you’ve set the strokeColor
of the track and pointer to match the one provided. CAShapeLayer
requires a CGColorRef
, not a UIColor
, so you use the CGColor
property instead.
You may have noticed that the two methods for updating the shape layer paths rely on one more property which has never been set — namely, the bounds of each of the shape layers. Since your CAShapeLayer
was created with alloc-init
, it currently has a zero-sized bounds
.
Add the following method signature to RWKnobRenderer.h before the @end
statement:
- (void)updateWithBounds:(CGRect)bounds; |
Switch to RWKnobRenderer.m and add the following implementation just before the @end
statement:
- (void)updateWithBounds:(CGRect)bounds { self.trackLayer.bounds = bounds; self.trackLayer.position = CGPointMake(CGRectGetWidth(bounds)/2.0, CGRectGetHeight(bounds)/2.0); [self updateTrackShape]; self.pointerLayer.bounds = self.trackLayer.bounds; self.pointerLayer.position = self.trackLayer.position; [self updatePointerShape]; } |
The above method takes a bounds rectangle, resizes the layers to match and positions the layers in the center of the bounding rectangle. As you’ve changed a property that affects the paths, you must call the update methods for each layer.
Although the renderer isn’t quite complete, there’s enough here to demonstrate the progress of your control. Switch to RWKnobControl.m and add the following import statement to the top of the file:
#import "RWKnobRenderer.h" |
Next, add an instance variable to hold an instance of your renderer by replacing the @implementation
line with the following:
@implementation RWKnobControl { RWKnobRenderer *_knobRenderer; } |
Still in the same file, add the following method before the @end
statement:
- (void)createKnobUI { _knobRenderer = [[RWKnobRenderer alloc] init]; [_knobRenderer updateWithBounds:self.bounds]; _knobRenderer.color = self.tintColor; _knobRenderer.startAngle = -M_PI * 11 / 8.0; _knobRenderer.endAngle = M_PI * 3 / 8.0; _knobRenderer.pointerAngle = _knobRenderer.startAngle; [self.layer addSublayer:_knobRenderer.trackLayer]; [self.layer addSublayer:_knobRenderer.pointerLayer]; } |
The above method creates an instance of RWKnobRenderer
, sets its size using updateWithBounds:
, then adds the two layers as sublayers of the control’s layer. You’ve temporarily set the startAngle
and endAngle
properties above just so that your view will render.
An empty view would be taking the iOS 7 design philosophy a step too far, so you need to make sure createKnobUI
is called when the knob control is being constructed. Add the following line to initWithFrame:
directly after _continuous = YES
:
[self createKnobUI]; |
You can also remove the following line from the same method since it is no longer required:
self.backgroundColor = [UIColor blueColor]; |
Your initWithFrame:
should now look like this:
- (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { _minimumValue = 0.0; _maximumValue = 1.0; _value = 0.0; _continuous = YES; [self createKnobUI]; } return self; } |
Build and run your app, and your control should look like the one below:
It’s not yet complete, but you can see the basic framework of your control taking shape.
Exposing Appearance Properties in the API
Currently, the developer has no way of changing the control’s appearance, since all of the properties which govern the look of the control are hidden away on the renderer and aren’t exposed on the control’s API.
To fix this, add the following code just before the @end
statement of RWKnobControl.h:
/** Specifies the angle of the start of the knob control track. Defaults to -11π/8 */ @property (nonatomic, assign) CGFloat startAngle; /** Specifies the end angle of the knob control track. Defaults to 3π/8 */ @property (nonatomic, assign) CGFloat endAngle; /** Specifies the width in points of the knob control track. Defaults to 2.0 */ @property (nonatomic, assign) CGFloat lineWidth; /** Specifies the length in points of the pointer on the knob. Defaults to 6.0 */ @property (nonatomic, assign) CGFloat pointerLength; |
Just as before, there are plenty of comments to assist developers via tooltips when they use the control. The four properties are fairly straightforward and simply proxy for the properties in the renderer. Since the control itself doesn’t actually need backing variables for these properties, it can rely on the renderer to store the values instead.
Switch to RWKnobControl.m and add the following just before initWithFrame:
:
@dynamic lineWidth; @dynamic startAngle; @dynamic endAngle; @dynamic pointerLength; |
The @dynamic
statement tells the compiler not to bother synthesizing the properties since getters and setters will be provided manually. To do that, add the following code after setValue:
:
- (CGFloat)lineWidth { return _knobRenderer.lineWidth; } - (void)setLineWidth:(CGFloat)lineWidth { _knobRenderer.lineWidth = lineWidth; } - (CGFloat)startAngle { return _knobRenderer.startAngle; } - (void)setStartAngle:(CGFloat)startAngle { _knobRenderer.startAngle = startAngle; } - (CGFloat)endAngle { return _knobRenderer.endAngle; } - (void)setEndAngle:(CGFloat)endAngle { _knobRenderer.endAngle = endAngle; } - (CGFloat)pointerLength { return _knobRenderer.pointerLength; } - (void)setPointerLength:(CGFloat)pointerLength { _knobRenderer.pointerLength = pointerLength; } |
The above code looks a little tedious, but it’s actually quite simple: for each of the visualization properties you created on the knob control, pass the setters and getters straight through to their equivalent properties on the renderer. Easy peasy! :]
Since you included defaults in the commented documentation for your API, you’ll need to be a good control developer and set them.
Update createKnobUI
to match the code below:
- (void)createKnobUI { _knobRenderer = [[RWKnobRenderer alloc] init]; [_knobRenderer updateWithBounds:self.bounds]; _knobRenderer.color = self.tintColor; // Set some defaults _knobRenderer.startAngle = -M_PI * 11 / 8.0; _knobRenderer.endAngle = M_PI * 3 / 8.0; _knobRenderer.pointerAngle = _knobRenderer.startAngle; _knobRenderer.lineWidth = 2.0; _knobRenderer.pointerLength = 6.0; // Add the layers [self.layer addSublayer:_knobRenderer.trackLayer]; [self.layer addSublayer:_knobRenderer.pointerLayer]; } |
The above code simply adds two lines to the set of defaults: lineWidth
and pointerLength
.
Build and run your project; the control looks a bit more useful now:
To test that the new API bits are working as expected, add the following code to the end of viewDidLoad
in RWViewController.m:
_knobControl.lineWidth = 4.0; _knobControl.pointerLength = 8.0; |
Build and run your project again; you’ll see that the line thickness and the length of the pointer have both increased, as shown below:
Changing Your Control’s Color
You may have noticed that you didn’t create any color properties on the public API of the control — and for good reason. iOS 7 provides a new property on UIView
: tintColor
. In fact, you’re already using it to set the color of the knob in the first place — check the _knobRenderer.color = self.tintColor;
line in createKnobUI
if you don’t believe us. :]
So you might expect that adding the following line to the end of viewDidLoad
inside RWViewController will change the color of the control:
self.view.tintColor = [UIColor redColor]; |
If you add the code above and build and run your project, you’ll quickly be disappointed. However, the UIButton
has updated appropriately, as demonstrated below:
Although you’re setting the renderer’s color when the UI is created, it won’t be updated when the tintColor
changes. Luckily, this is really easy to fix.
Add the following method to RWKnobControl.m, just after setValue:
:
- (void)tintColorDidChange { _knobRenderer.color = self.tintColor; } |
Whenever you change the tintColor
property of a view, the render calls tintColorDidChange
on all views beneath the current view in the view hierarchy that haven’t had their tintColor
property set manually. So to listen for tintColor
updates anywhere above the view in the current hierarchy, all you have to do is implement tintColorDidChange
in your code and update the view’s appearance appropriately.
Build and run your project; you’ll see that the red tint has been picked up by your control as shown below:
Setting the Control’s Value Programmatically
Although your knob looks pretty nice, it doesn’t actually do anything. In this next phase you’ll modify the control to respond to programmatic interactions — that is, when the value
property of the control changes.
At the moment, the value of the control is saved when the value
property is modified directly or when you call setValue:animated:
. However, there isn’t any communication with the renderer, and the control won’t re-render.
The renderer has no concept of value
; it deals entirely in angles. You’ll need to update setValue:animated:
in RWKnobControl
so that it converts the value to an angle and passes it to the renderer.
Open RWKnobControl.m and update setValue:animated:
so that it matches the following code:
- (void)setValue:(CGFloat)value animated:(BOOL)animated { if(value != _value) { // Save the value to the backing ivar // Make sure we limit it to the requested bounds _value = MIN(self.maximumValue, MAX(self.minimumValue, value)); // Now let's update the knob with the correct angle CGFloat angleRange = self.endAngle - self.startAngle; CGFloat valueRange = self.maximumValue - self.minimumValue; CGFloat angleForValue = (_value - self.minimumValue) / valueRange * angleRange + self.startAngle; _knobRenderer.pointerAngle = angleForValue; } } |
The code above now works out the appropriate angle for the given value by mapping the minimum and maximum value range to the minimum and maximum angle range and sets the pointerAngle
property on the renderer. Note you’re ignoring the value of animated
at the moment — you’ll fix this in the next section.
Although the pointerAngle
property is being updated, it doesn’t yet have any effect on your control. When the pointer angle is set, the layer containing the pointer should rotate to the specified angle to give the impression that the pointer has moved.
Add the following method before the @end
statement in RWKnobRenderer.m:
- (void)setPointerAngle:(CGFloat)pointerAngle { self.pointerLayer.transform = CATransform3DMakeRotation(pointerAngle, 0, 0, 1); } |
This simply creates a rotation transform which rotates the layer around the z-axis by the specified angle.
The transform
property of CALayer
expects to be passed a CATransform3D
, not a CGAffineTransform
like UIView
. This means that you can perform transformations in three dimensions.
CGAffineTransform
uses a 3×3 matrix and CATransform3D
uses a 4×4 matrix; transforming z-axis requires the extra values. At their core, 3D transformations are simply matrices being multiplied by other matrices. You can read more about matrix multiplication in this Wikipedia article.
To demonstrate that your transforms work, you’re going to link the UISlider
present in the starter project with the knob control in the view controller. As you adjust the control, the value will change appropriately.
The UISlider
has already been linked to handleValueChanged:
, so all you need to do is replace handleValueChanged:
in RWViewController.m with the following:
- (IBAction)handleValueChanged:(id)sender { _knobControl.value = self.valueSlider.value; } |
Build and run your project; change the value of the UISlider
and you’ll see the pointer on the knob control move to match as shown below:
There’s a little bonus here — your control is animating, despite the fact that you haven’t started coding any of the animations yet! What gives?
CoreAnimation is quietly calling implicit animations on your behalf. When you change certain properties of CALayer
— including transform
— the layer animates smoothly from the current value to the new value.
Usually this functionality is really cool; you get nice looking animations without doing any work. However, you want a little more control, so you’ll animate things yourself.
Update setPointerAngle:
in RWKnobRenderer.m as follows:
- (void)setPointerAngle:(CGFloat)pointerAngle { [CATransaction new]; [CATransaction setDisableActions:YES]; self.pointerLayer.transform = CATransform3DMakeRotation(pointerAngle, 0, 0, 1); [CATransaction commit]; } |
To prevent these implicit animations, you wrap the property change in a CATransaction
and disable animations for that interaction.
Build and run your app once more; you’ll see that as you move the UISlider
, the knob follows instantaneously.
Animating Changes in the Control’s Value
At the moment, setting animated = YES
has no effect on your control. To enable this bit of functionality, you need to add the concept of animating angle changes to the renderer.
Add the following method signature to RWKnobRenderer.h, just before the @end
statement:
- (void)setPointerAngle:(CGFloat)pointerAngle animated:(BOOL)animated; |
Add the following implementation before the @end
statement of RWKnobRenderer.m:
- (void)setPointerAngle:(CGFloat)pointerAngle animated:(BOOL)animated { [CATransaction new]; [CATransaction setDisableActions:YES]; self.pointerLayer.transform = CATransform3DMakeRotation(pointerAngle, 0, 0, 1); if(animated) { // Provide an animation // Key-frame animation to ensure rotates in correct direction CGFloat midAngle = (MAX(pointerAngle, _pointerAngle) - MIN(pointerAngle, _pointerAngle) ) / 2.f + MIN(pointerAngle, _pointerAngle); CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:@"transform.rotation.z"]; animation.duration = 0.25f; animation.values = @[@(_pointerAngle), @(midAngle), @(pointerAngle)]; animation.keyTimes = @[@(0), @(0.5), @(1.0)]; animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; [self.pointerLayer addAnimation:animation forKey:nil]; } [CATransaction commit]; _pointerAngle = pointerAngle; } |
This method looks complex, but it’s fairly simple when you break it down. If you ignore the if(animated)
section, then the method is identical to setAngle:
which you coded earlier.
The difference here is when animated
is equal to YES
; if you had left this section with its implicit animation, the direction of rotation would be chosen to minimize the distance travelled. This means that animating between 0.98 and 0.1 wouldn’t rotate your layer counter-clockwise, but instead rotate clockwise over the end of the track, and into the bottom, which is not what you want!
In order to specify the rotation direction, you need to use a key-frame animation. That’s simply an animation which has additional points to animate through — not just the start and end points.
CoreAnimation supports key frame animation; in the above method, you’ve created a new CAKeyFrameAnimation
and specified that the property to animate is the rotation around the z-axis with transform.rotation.z
as its keypath.
Next, you specify three angles through which the layer should rotate: the start point, the mid-point and finally the end point. Along with that, there’s an array specifying the normalized times at which to reach those values. Adding the animation to the layer ensures that once the transaction is committed then the animation will start.
Now you can use standard method chaining: replace setPointerAngle:
with the following:
- (void)setPointerAngle:(CGFloat)pointerAngle { [self setPointerAngle:pointerAngle animated:NO]; } |
Now that the renderer knows how to animate your control, you can update setValue:animated:
of RWKnobControl.m to use your renderer rather than the property.
Replace the line below:
_knobRenderer.pointerAngle = angleForValue; |
with the following:
[_knobRenderer setPointerAngle:angleForValue animated:animated]; |
In order to see this new functionality in action, you can use the “Random Value” button which is part of the app’s main view controller. This button causes the slider and knob controls to move to a random value, and uses the current setting of the animate UISwitch
to determine whether or not the change to the new value should be instantaneous or animated.
Update handleRandomButtonPressed:
of RWViewController.m to match the following:
- (IBAction)handleRandomButtonPressed:(id)sender { // Generate random value CGFloat randomValue = (arc4random() % 101) / 100.f; // Then set it on the two controls [_knobControl setValue:randomValue animated:self.animateSwitch.on]; [self.valueSlider setValue:randomValue animated:self.animateSwitch.on]; } |
The above method generates a random value between 0.00 and 1.00 and sets the value on both controls. It then inspects the on
property of animateSwitch
to determine whether or not to animate the transition to the new value.
Build and run your app; tap the Random Value button a few times with the animate switch toggled on, then tap the Random Value button a few times with the animate switch toggled off to see the difference the animated
parameter makes.
Using Key-value Observing
Key-value observing (or KVO for short) lets you receive notification of changes to properties of NSObject
instances. Although it’s not necessarily the interaction pattern of choice with a UI control, it’s certainly a viable option — with some interesting consequences!
To demonstrate this, you’re going to wire up the large label in the app’s main view controller to show the current value selected by the knob control.
Open up RWViewController.m and add the following to the end of viewDidLoad
:
[_knobControl addObserver:self forKeyPath:@"value" options:0 context:NULL]; |
This registers that the view controller would like to be informed whenever the value
property on the _knobControl
object changes.
To receive these notifications, add the following method before the @end
statement in RWViewController.m:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if(object == _knobControl && [keyPath isEqualToString:@"value"]) { self.valueLabel.text = [NSString stringWithFormat:@"%0.2f", _knobControl.value]; } } |
This method first checks that the notification is from the correct property on the knob control then updates the text label to display the new value.
Build and run your app; move the UISlider
control around and you’ll see the label’s value update as shown below:
That looks fine. However, click the Random Value button, and even though the slider and knob control both update, the value in the label doesn’t! What’s going on?
The controls in your app use different methods to set the value on the knob control. UISlider
uses the setValue:
property setter method, whereas the random value button uses the setValue:animated:
method of your API.
Key-value observation is part of NSObject
, true, but it’s only wired to the synthesized setter methods — that is, setValue:
. It’s not wired to the new method you added to deal with the animation parameter.
In order to fix this, you need to specify that the knob control will manage its own KVO notifications for the value
property.
Add the following method before the end of the @end
statement in RWKnobControl.m:
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { if ([key isEqualToString:@"value"]) { return NO; } else { return [super automaticallyNotifiesObserversForKey:key]; } } |
This method exists in NSObject
and lets you override the default behavior of the object. Here you check for a specific key, and if it’s equal to value
, then return NO
, specifying that the notifications will be handled manually.
NSObject
has a couple of methods to call that manually fire the notifications: willChangeValueForKey:
and didChangeValueForKey:
.
In RWKnobControl.m, update setValue:animated:
so that it matches the following:
- (void)setValue:(CGFloat)value animated:(BOOL)animated { if(value != _value) { [self willChangeValueForKey:@"value"]; // Save the value to the backing ivar // Make sure we limit it to the requested bounds _value = MIN(self.maximumValue, MAX(self.minimumValue, value)); // Now let's update the knob with the correct angle CGFloat angleRange = self.endAngle - self.startAngle; CGFloat valueRange = self.maximumValue - self.minimumValue; CGFloat angleForValue = (_value - self.minimumValue) / valueRange * angleRange + self.startAngle; [_knobRenderer setPointerAngle:angleForValue animated:animated]; [self didChangeValueForKey:@"value"]; } } |
The above code adds only two lines to the previous implementation: one call each to willChangeValueForKey:
and didChangeValueForKey:
. Before you set the value, you call willChangeValueForKey:
, and then didChangeValueForKey:
once the value has been set.
Build and run your app; tap the Random Value button, and you’ll see that the value of the label now updates as expected. The control is now sending KVO notifications for both of the value change methods.
Responding to Touch Interaction
The knob control you’ve built responds extremely well to programmatic interaction, but that alone isn’t terribly very useful for a UI control. In this final section you’ll see how to add touch interaction using a custom gesture recognizer.
When you touch the screen of an iOS device, a series of UITouch
events are delivered to appropriate objects by the operating system. When a touch occurs inside of a view with one or more gesture recognizers attached, the touch event is delivered to the gesture recognizers for interpretation. Gesture recognizers determine whether a given sequence of touch events matches a specific pattern; if so, they send an action message to a specified target.
Apple provides a set of pre-defined gesture recognizers, such as tap, pan and pinch. However, there’s nothing to handle the single-finger rotation you need for the knob control. Looks like it’s up to you to create your own custom gesture recognizer.
Create a new class by clicking File\New\File… and selecting iOS\Cocoa Touch\Objective-C class. On the next screen, specify the name as RWRotationGestureRecognizer, and that the class will inherit from UIPanGestureRecognizer
. Choose the KnobControl directory and click Create on the final screen of the wizard.
This custom gesture recognizer will behave like a pan gesture recognizer; it will track a single finger dragging across the screen and update the location as required. For this reason, it subclasses UIPanGestureRecognizer
.
Open RWRotationGestureRecognizer.h and update the interface with the following new property:
@interface RWRotationGestureRecognizer : UIPanGestureRecognizer @property (nonatomic, assign) CGFloat touchAngle; @end |
touchAngle
represents the angle of the line which joins the current touch point to the center of the view to which the gesture recognizer is attached, as demonstrated in the following diagram:
There are three methods of interest when subclassing UIGestureRecognizer
; they represent the time that the touches begin, the time they move and the time they end.
Add the following import statement to the top of RWRotationGestureRecognizer.m:
#import <UIKit/UIGestureRecognizerSubclass.h> |
You’re only interested when the gesture starts and when the user’s finger moves on the screen.
Add the following two methods between the @implementation
and @end
statements in RWRotationGestureRecognizer.m:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesBegan:touches withEvent:event]; [self updateTouchAngleWithTouches:touches]; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesMoved:touches withEvent:event]; [self updateTouchAngleWithTouches:touches]; } |
Both of these methods call through to their super
equivalent, and then call a utility function. Which you’ll add now, immediately below the methods you added above:
- (void)updateTouchAngleWithTouches:(NSSet *)touches { UITouch *touch = [touches anyObject]; CGPoint touchPoint = [touch locationInView:self.view]; self.touchAngle = [self calculateAngleToPoint:touchPoint]; } - (CGFloat)calculateAngleToPoint:(CGPoint)point { // Offset by the center CGPoint centerOffset = CGPointMake(point.x - CGRectGetMidX(self.view.bounds), point.y - CGRectGetMidY(self.view.bounds)); return atan2(centerOffset.y, centerOffset.x); } |
updateTouchAngleWithTouches:
takes the NSSet
of touches and extracts one using anyObject
. It then uses locationInView:
to translate the touch point into the coordinate system of the view associated with this gesture recognizer. It then updates the new touchAngle
property using calculateAngleToPoint:
, which uses some simple geometry to find find the angle as demonstrated below:
x
and y
represent the horizontal and vertical positions of the touch point within the control. The tangent of the touch angle is equal to h / w
, so to calculate the touchAngle
all you need to do is establish the following lengths:
h =
(since the angle should increase in a clockwise direction)y
- (view height) / 2w =
x
- (view width) / 2
calculateAngleToPoint:
performs this calculation for you, and returns the angle required.
Your gesture recognizer should only work with one touch at a time. Add the following constructor override immediately after the @implementation
statement:
- (id)initWithTarget:(id)target action:(SEL)action { self = [super initWithTarget:target action:action]; if(self) { self.maximumNumberOfTouches = 1; self.minimumNumberOfTouches = 1; } return self; } |
This constructor sets 1
as the the default number of touches to which the recognizer will respond.
Wiring Up the Custom Gesture Recognizer
Now that you’ve completed the custom gesture recognizer, you just need to wire it up to the knob control. Add the following import to the top of RWKnobControl.m:
#import "RWRotationGestureRecognizer.h" |
Next, add an instance variable to represent the gesture recognizer by updating the @implementation
statement to look like the following:
@implementation RWKnobControl { RWKnobRenderer *_knobRenderer; RWRotationGestureRecognizer *_gestureRecognizer; } |
Add the gesture recognizer inside the if
statement of initWithFrame:
as follows:
- (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { // Initialization code _minimumValue = 0.0; _maximumValue = 1.0; _value = 0.0; _continuous = YES; _gestureRecognizer = [[RWRotationGestureRecognizer alloc] initWithTarget:self action:@selector(handleGesture:)]; [self addGestureRecognizer:_gestureRecognizer]; [self createKnobUI]; } return self; } |
The two lines added create a gesture recognizer in the familiar way: simply create a recognizer, specify where it should callback when activated then add it to the view.
Still in RWKnobControl.m, add the following method before the @end
statement to handle the callbacks from the gesture you’ve just created:
- (void)handleGesture:(RWRotationGestureRecognizer *)gesture { // 1. Mid-point angle CGFloat midPointAngle = (2 * M_PI + self.startAngle - self.endAngle) / 2 + self.endAngle; // 2. Ensure the angle is within a suitable range CGFloat boundedAngle = gesture.touchAngle; if(boundedAngle > midPointAngle) { boundedAngle -= 2 * M_PI; } else if (boundedAngle < (midPointAngle - 2 * M_PI)) { boundedAngle += 2 * M_PI; } // 3. Bound the angle to within the suitable range boundedAngle = MIN(self.endAngle, MAX(self.startAngle, boundedAngle)); // 4. Convert the angle to a value CGFloat angleRange = self.endAngle - self.startAngle; CGFloat valueRange = self.maximumValue - self.minimumValue; CGFloat valueForAngle = (boundedAngle - self.startAngle) / angleRange * valueRange + self.minimumValue; // 5. Set the control to this value self.value = valueForAngle; } |
This method looks quite long and complicated, but the concept is pretty simple – it simply extracts the angle from the custom gesture recognizer, converts it to the value represented by this angle on the knob control, and then sets the value which triggers the UI updates.
Going through the commented sections of the code above, you’ll find the following:
- First, you calculate the angle which represents the ‘midpoint’ between the start and end angles. This is the angle which is not part of the knob track, and instead represents the angle at which the pointer should flip between the maximum and minimum values.
- The angle calculated by the gesture recognizer will be between -π and π, since it uses the inverse tangent function. However, the angle required for the track should be continuous between the
startAngle
and theendAngle
. Therefore, create a newboundedAngle
variable and adjust it to ensure that it remains within the allowed ranges. - Update
boundedAngle
so that it sits inside the specified bounds of the angles. - Convert the angle to a value, just as you converted it in the
setValue:animated:
method before. - Finally, set the knob control’s value to calculated value.
Build and run your app; play around with your knob control to see the gesture recognizer in action. The pointer will follow your finger as you move it around the control – how cool is that? :]
Sending Action Notifications
As you move the pointer around, you’ll notice that the UISlider
doesn’t update. You’ll wire this up to use the target-action pattern which is an inherent part of UIControl
.
Open RWViewController.m and add the following code to the end of viewDidLoad
:
// Hooks up the knob control [_knobControl addTarget:self action:@selector(handleValueChanged:) forControlEvents:UIControlEventValueChanged]; |
This is the standard code you’ve used before to add a listener to a UIControl
; here you’re listening for value-changed events.
handleValueChanged:
currently only handles changes in the value of valueSlider
. Update handleValueChanged:
as shown below:
- (IBAction)handleValueChanged:(id)sender { if(sender == self.valueSlider) { _knobControl.value = self.valueSlider.value; } else if(sender == _knobControl) { self.valueSlider.value = _knobControl.value; } } |
handleValueChanged:
now checks which control invoked it and sets the value on the other control to match. If the user changes the value on the knob control, then the slider updates appropriately, and vice versa.
Build and run your app; move the knob around and…nothing has changed. Whoops. You haven’t actually fired the event from within the knob control itself.
Time to fix that!
Open RWKnobControl.m and add the following code to the end of handleGesture:
:
// Notify of value change if (self.continuous) { [self sendActionsForControlEvents:UIControlEventValueChanged]; } else { // Only send an update if the gesture has completed if(_gestureRecognizer.state == UIGestureRecognizerStateEnded || _gestureRecognizer.state == UIGestureRecognizerStateCancelled) { [self sendActionsForControlEvents:UIControlEventValueChanged]; } } |
At the beginning of this tutorial you added the continuous
property to the API so that the knob control API would resemble that of UISlider
. This is the first and only place that you need to use it.
If continuous
is set to YES
, then the event should be fired every time that the gesture sends an update, so call sendActionsForControlEvents:
.
In continuous
is set to NO
, then the event should only fire when the gesture ends or is cancelled. Since the control is only concerned with value changes, the only event you need to handle is UIControlEventValueChanged
.
Build and run your app again; move the knob around once again and you’ll see the UISlider
move to match the value on the knob. Success!
Where to Go From Here?
Your knob control is now fully functional and you can drop it into your apps to enhance their look and feel. However, there’s a lot of ways that you could extend your control:
- Add extra configurability to the appearance of the control – perhaps you could allow an image to be used for the pointer.
- Integrate a label displaying the current value of the control into the center of the knob.
- Ensure that a user can only interact with the control if their first touch is on the pointer.
- At the moment, if you resize the knob control, the layers won’t be re-rendered. You can add this functionality with just a few lines of code.
These suggestions are quite good fun, and will help you hone your skills with the different features of iOS you’ve encountered in this custom UI tutorial for iOS 7. And the best part is that you can apply what you’ve learned in other controls that you build.
You can download a zip file of the completed project, or alternatively access the git repository on GitHub. The git repo has commits for every build-and-run step, so you can check your code as you go.
I’d love to hear your comments or questions in the forums below!
Custom Control for iOS Tutorial: A Reusable Knob is a post from: Ray Wenderlich
The post Custom Control for iOS Tutorial: A Reusable Knob appeared first on Ray Wenderlich.