Using CAShapeLayer to Create a Switch (Objective-C)

Torrey Betts / Wednesday, October 16, 2013

Introduction

CALayers are extremely powerful for rendering primitives and geometry, then animating out changes to the geometry or visual properties such as color. In this blog post we'll expand on the flat indicator view post that uses a CAShapeLayer to create a switch similar to UISwitch found in iOS 7. Our switch will take advantage of the automatic animations to transition colors and also reposition the switch to the on/off position. With very little code you'll have a control that's great for visualizing on and off states. The illustration below demonstrates the example project when ran. When the switch has been tapped by a user, the indicator changes state, position and color. The image below is also a GIF image, quality of animation and color may appear degraded.

Introduction

Differences from the Indicator View Post

To expand on the flat indicator view post we'll add another CAShapeLayer under the circle to serve as the switch track. The positioning and size has been of the circle in the previous post has been adjusted to fit perfectly in the track shape. A soft and small 1 pixel shadow has been added under the circle to provide a hint of depth. Finally, the on/off states animate the color of the track and the position of the circle. The total amount of code necessary to simulate the iOS 7 switch from this post is roughly 120 lines of code.

Creating the Switch View

The first step to create our switch view is to create a class that derives from UIView. In the header file for this new class, we'll add an exposed property that determines the on/off state.

@interface CASwitchView : UIView

@property (nonatomic, assign) BOOL on;

@end

In the implementation file (.m) create a category for the class' private properties.

@interface CASwitchView ()
{
    CAShapeLayer *_track;
    CAShapeLayer *_circle;
    UITapGestureRecognizer *_tapGestureRecognizer;
    BOOL _hasInitialized;
}
@end

Next, in the implementation for the indicator view we'll override initWithFrame: and also the setFrame: method. These methods will allow us to initialize the control once a frame not equal to CGRectZero has been set. If the control has previously been initialized but the frame is being set, the indicator path and position are updated.

- (void)setFrame:(CGRect)frame
{
    [super setFrame:frame];

    if (!_hasInitialized && !CGRectEqualToRect(frame, CGRectZero))
        [self initializeControl];
    else if (_hasInitialized && !CGRectEqualToRect(frame, CGRectZero))
    {
        [self adjustControl];
    }
}

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self)
    {
        if (!_hasInitialized && !CGRectEqualToRect(frame, CGRectZero))
            [self initializeControl];
    }

    return self;
}

In setFrame: we need to adjust the geometry of the switch layers since they are not automatically fitted like when using the autoResizingMask of a UIView. To do this, we create a adjustControl method to update the path and position of the track and circle layers.

-(void)adjustControl
{
    _track.path = [self generateTrackPath];

    CGRect trackRect = CGPathGetBoundingBox(_track.path);
    int radius = trackRect.size.width / 3.35;

    _circle.path = [self generateCirclePathWithRadius:radius trackRect:trackRect];
    if (_circle.position.x == 5)
    {
        _circle.position = CGPointMake(5, CGRectGetMidY(self.bounds) - (trackRect.size.height / 2));
    }
    else
    {
        CGRect circleRect = CGPathGetBoundingBox(_circle.path);
        _circle.position = CGPointMake(self.bounds.size.width - (circleRect.size.width + 5), CGRectGetMidY(self.bounds) - (trackRect.size.height / 2));
    }
}

To initialize the control, we'll create a method called initalizeControl that creates a CAShapeLayer that uses a rounded rectangle to represent the track of our switch, and a CAShapeLayer that uses a circle path to represent our indicator. A gesture recognizer is also added to the control for toggling the on/off state. It's important to note that userInteractionEnabled must be set to YES for the UIView to receive the tap.

-(void)initializeControl
{
    _track = [CAShapeLayer layer];
    _track.path = [self generateTrackPath];
    _track.fillColor = [UIColor colorWithWhite:0.95 alpha:1.0].CGColor;
    _track.strokeColor = [UIColor colorWithWhite:0.4 alpha:1.0].CGColor;
    _track.lineWidth = 1;
    [self.layer addSublayer:_track];

    CGRect trackRect = CGPathGetBoundingBox(_track.path);
    int radius = trackRect.size.width / 3.35;

    _circle = [CAShapeLayer layer];
    _circle.path = [self generateCirclePathWithRadius:radius trackRect:trackRect];
    _circle.position = CGPointMake(5, CGRectGetMidY(self.bounds) - (trackRect.size.height / 2));
    _circle.fillColor = [UIColor colorWithWhite:0.99 alpha:1.0].CGColor;
    _circle.strokeColor = [UIColor colorWithWhite:0.5 alpha:1.0].CGColor;
    _circle.lineWidth = 1.0;
    _circle.shadowOffset = CGSizeMake(0, 2);
    _circle.shadowOpacity = 0.5;
    [self.layer addSublayer:_circle];

    _tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(toggleOnOff)];
    [self addGestureRecognizer:_tapGestureRecognizer];

    self.userInteractionEnabled = YES;
    _hasInitialized = YES;
}

-(CGPathRef)generateCirclePathWithRadius:(CGFloat)radius trackRect:(CGRect)trackRect
{
    return [UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, 2.0 * radius, trackRect.size.height) cornerRadius:radius].CGPath;
}

-(CGPathRef)generateTrackPath
{
    CGRect pathRect = CGRectInset(self.bounds, 10, 20);
    UIBezierPath *bezierPath = [UIBezierPath bezierPathWithRoundedRect:pathRect cornerRadius:self.bounds.size.width / 4];
    return bezierPath.CGPath;
}

-(void)toggleOnOff
{
    self.on = !self.on;
}

To finish the indicator we need to override the setOn: property setter. Setting this property animates into the next state color and repositions the switch to the on/off position. This switch uses a green color for the on state and off white for the off state. When the position and fillColor properties the CAShapeLayers animate to these to the next value automatically, so no extra code is necessary.

- (void)setOn:(BOOL)on
{
    _on = on;

    CGRect trackRect = CGPathGetBoundingBox(_track.path);
    CGRect circleRect = CGPathGetBoundingBox(_circle.path);

    if (_on)
    {
        _circle.position = CGPointMake(self.bounds.size.width - (circleRect.size.width + 5), CGRectGetMidY(self.bounds) - (trackRect.size.height / 2));
        _track.fillColor = [UIColor greenColor].CGColor;
    }
    else
    {
        _circle.position = CGPointMake(5, CGRectGetMidY(self.bounds) - (trackRect.size.height / 2));
        _track.fillColor = [UIColor colorWithWhite:0.95 alpha:1.0].CGColor;
    }
}

Implementing the Switch View

With the switch view now complete, we can implement it in our view controller. In this example, we're placing the switch directly in the center of the view controller. If you choose to use this switch in other projects, any size or placement would work without altering switch's code.

_switchView = [[CASwitchView alloc] init];
_switchView.autoresizingMask = UIViewAutoresizingFlexibleHeight|UIViewAutoresizingFlexibleWidth|UIViewAutoresizingFlexibleBottomMargin|UIViewAutoresizingFlexibleLeftMargin|UIViewAutoresizingFlexibleRightMargin|UIViewAutoresizingFlexibleTopMargin;
_switchView.frame = CGRectMake(0, 0, 100, 75);
_switchView.center = self.view.center;
[self.view addSubview:_switchView];

Download the Example Project

The Xcode project source code for this quick tip can be downloaded by clicking this link.

By Torrey Betts