PaintCode Tutorial: Bezier Paths

In the third and final part of our PaintCode tutorial series, learn how to create dynamic, movable arrows with curved bezier paths! By Felipe Laso-Marsetti.

Leave a rating/review
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Hooking up the Bezier Arrow Code

Switch back to PaintCode and make sure the code view has the platform set to iOS > Objective-C, the OS version as iOS 5+, origin set to Custom Origin, and memory management as ARC, as illustrated in the screenshot below:

Dynamic arrow PaintCode settings

Paste all of the code from PaintCode into drawRect: in BezierView.m. Your method should now appear as follows:

-(void)drawRect:(CGRect)rect {
    // General Declarations
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    CGContextRef context = UIGraphicsGetCurrentContext();
    
    // Color Declarations
    UIColor *fillColor = [UIColor colorWithRed:0.22 green:0.267 blue:0.384 alpha:1];
    CGFloat fillColorRGBA[4];
    [fillColor getRed:&fillColorRGBA[0] green:&fillColorRGBA[1] blue:&fillColorRGBA[2] alpha:&fillColorRGBA[3]];
    
    UIColor* strokeColor = [UIColor colorWithRed:(fillColorRGBA[0] * 0.3)
                                           green:(fillColorRGBA[1] * 0.3)
                                            blue:(fillColorRGBA[2] * 0.3)
                                           alpha:(fillColorRGBA[3] * 0.3 + 0.7)];
    UIColor *fillColor2 = [UIColor colorWithRed:0.549 green:0.627 blue:0.753 alpha:1];
    UIColor *shadowColor2 = [UIColor colorWithRed:1 green:1 blue:1 alpha:1];
    UIColor *shadowColor3 = [UIColor colorWithRed:0 green:0 blue:0 alpha:0.243];
    
    // Gradient Declarations
    NSArray *gradientColors = @[(id)fillColor2.CGColor, (id)fillColor.CGColor];
    CGFloat gradientLocations[] = {0, 1};
    CGGradientRef gradient = CGGradientCreateWithColors(colorSpace, (__bridge CFArrayRef)gradientColors, gradientLocations);
    
    // Shadow Declarations
    UIColor *innerShadow = shadowColor2;
    CGSize innerShadowOffset = CGSizeMake(0.1, 1.1);
    CGFloat innerShadowBlurRadius = 2;
    UIColor *shadow = shadowColor3;
    CGSize shadowOffset = CGSizeMake(0.1, 1.1);
    CGFloat shadowBlurRadius = 2;
    
    // Frames
    CGRect frame = CGRectMake(0, 0, 30, 38);
    CGRect frame2 = CGRectMake(170, 0, 30, 38);
    
    // Bezier Drawing
    UIBezierPath *bezierPath = [UIBezierPath bezierPath];
    [bezierPath moveToPoint:CGPointMake(CGRectGetMinX(frame) + 20, CGRectGetMinY(frame))];
    [bezierPath addLineToPoint:CGPointMake(CGRectGetMinX(frame), CGRectGetMinY(frame) + 19)];
    [bezierPath addLineToPoint:CGPointMake(CGRectGetMinX(frame) + 20, CGRectGetMinY(frame) + 38)];
    [bezierPath addCurveToPoint:CGPointMake(CGRectGetMinX(frame) + 20, CGRectGetMinY(frame) + 25)
                  controlPoint1:CGPointMake(CGRectGetMinX(frame) + 20, CGRectGetMinY(frame) + 38)
                  controlPoint2:CGPointMake(CGRectGetMinX(frame) + 20, CGRectGetMinY(frame) + 29.88)];
    [bezierPath addCurveToPoint:CGPointMake(CGRectGetMinX(frame2) + 10, CGRectGetMinY(frame2) + 25)
                  controlPoint1:CGPointMake(CGRectGetMinX(frame) + 50.73, CGRectGetMinY(frame) + 25)
                  controlPoint2:CGPointMake(CGRectGetMinX(frame2) - 23.76, CGRectGetMinY(frame2) + 25)];
    [bezierPath addCurveToPoint:CGPointMake(CGRectGetMinX(frame2) + 10, CGRectGetMinY(frame2) + 38)
                  controlPoint1:CGPointMake(CGRectGetMinX(frame2) + 10, CGRectGetMinY(frame2) + 30.36)
                  controlPoint2:CGPointMake(CGRectGetMinX(frame2) + 10, CGRectGetMinY(frame2) + 38)];
    [bezierPath addLineToPoint:CGPointMake(CGRectGetMinX(frame2) + 30, CGRectGetMinY(frame2) + 19)];
    [bezierPath addLineToPoint:CGPointMake(CGRectGetMinX(frame2) + 10, CGRectGetMinY(frame2))];
    [bezierPath addCurveToPoint:CGPointMake(CGRectGetMinX(frame2) + 10, CGRectGetMinY(frame2) + 13)
                  controlPoint1:CGPointMake(CGRectGetMinX(frame2) + 10, CGRectGetMinY(frame2))
                  controlPoint2:CGPointMake(CGRectGetMinX(frame2) + 10, CGRectGetMinY(frame2) + 7.35)];
    [bezierPath addCurveToPoint:CGPointMake(CGRectGetMinX(frame) + 20, CGRectGetMinY(frame) + 13)
                  controlPoint1:CGPointMake(CGRectGetMinX(frame2) - 25.01, CGRectGetMinY(frame2) + 13)
                  controlPoint2:CGPointMake(CGRectGetMinX(frame) + 50.92, CGRectGetMinY(frame) + 13)];
    [bezierPath addCurveToPoint:CGPointMake(CGRectGetMinX(frame) + 20, CGRectGetMinY(frame))
                  controlPoint1:CGPointMake(CGRectGetMinX(frame) + 20, CGRectGetMinY(frame) + 7.39)
                  controlPoint2:CGPointMake(CGRectGetMinX(frame) + 20, CGRectGetMinY(frame))];
    [bezierPath closePath];
    CGContextSaveGState(context);
    CGContextSetShadowWithColor(context, shadowOffset, shadowBlurRadius, shadow.CGColor);
    CGContextBeginTransparencyLayer(context, NULL);
    [bezierPath addClip];
    CGRect bezierBounds = CGPathGetPathBoundingBox(bezierPath.CGPath);
    CGContextDrawLinearGradient(context, gradient,
                                CGPointMake(CGRectGetMidX(bezierBounds), CGRectGetMinY(bezierBounds)),
                                CGPointMake(CGRectGetMidX(bezierBounds), CGRectGetMaxY(bezierBounds)),
                                0);
    CGContextEndTransparencyLayer(context);
    
    // Bezier Inner Shadow
    CGRect bezierBorderRect = CGRectInset([bezierPath bounds], -innerShadowBlurRadius, -innerShadowBlurRadius);
    bezierBorderRect = CGRectOffset(bezierBorderRect, -innerShadowOffset.width, -innerShadowOffset.height);
    bezierBorderRect = CGRectInset(CGRectUnion(bezierBorderRect, [bezierPath bounds]), -1, -1);
    
    UIBezierPath *bezierNegativePath = [UIBezierPath bezierPathWithRect:bezierBorderRect];
    [bezierNegativePath appendPath: bezierPath];
    bezierNegativePath.usesEvenOddFillRule = YES;
    
    CGContextSaveGState(context);
    {
        CGFloat xOffset = innerShadowOffset.width + round(bezierBorderRect.size.width);
        CGFloat yOffset = innerShadowOffset.height;
        CGContextSetShadowWithColor(context,
                                    CGSizeMake(xOffset + copysign(0.1, xOffset), yOffset + copysign(0.1, yOffset)),
                                    innerShadowBlurRadius,
                                    innerShadow.CGColor);
        
        [bezierPath addClip];
        CGAffineTransform transform = CGAffineTransformMakeTranslation(-round(bezierBorderRect.size.width), 0);
        [bezierNegativePath applyTransform:transform];
        [[UIColor grayColor] setFill];
        [bezierNegativePath fill];
    }
    
    CGContextRestoreGState(context);
    CGContextRestoreGState(context);
    
    [strokeColor setStroke];
    bezierPath.lineWidth = 1;
    [bezierPath stroke];
    
    // Cleanup
    CGGradientRelease(gradient);
    CGColorSpaceRelease(colorSpace);
}

Note: As noted in the previous parts of this tutorial, the above code has been slightly modified to use modern Objective-C notation. Otherwise, the code should be fairly similar to what PaintCode generates except for a few values such as the positioning of the bezier point handles.

First, you’ll need a view to display your bezier arrow.

Open BezierViewController.m and add the following code directly below the existing #import line:

#import "BezierView.h"

Still working in BezierViewController.m, add the following code between the @implementation and @end lines:

#pragma mark - View Lifecycle
-(void)viewDidLoad {
    [super viewDidLoad];
    BezierView *midArrow = [[BezierView alloc] initWithLeftArrowTipPoint:CGPointMake(20, 20)
                                                      rightArrowTipPoint:CGPointMake(300, 130)
                                                          releaseHandler:nil];
    [self.view addSubview:midArrow];
}

The above two bits of code simply create a new BezierView instance with the specified positions for the arrowheads and a nil release handler. The nil release handler means that no action is carried out when the right arrow is dragged and released. You then add the new view as a subview of the main view.

Build and run the project and switch to the Bezier tab. You should see your arrow drawn on the screen as follows:

Arrow first run

Hey, there’s your arrow…but wait a minute. You specified the coordinates (20,20) and (300,130) for your arrowheads, and those two points definitely aren’t in the same Y-axis. What’s going on here?

Once again, this comes down to the frames used in drawRect: for the custom drawing calls; they’re using the original coordinates as defined in PaintCode.

Go back to BezierView.m, and locate the section in drawRect: that starts with the //Frames comment. Modify that section of code as follows:

- (void)drawRect:(CGRect)rect
{
    ...

    // Frames
    CGRect frame = CGRectMake(_leftArrowTip.x,
                              _leftArrowTip.y - kArrowFrameHeightHalf,
                              kArrowFrameWidth,
                              kArrowFrameHeight);
    CGRect frame2 = CGRectMake(_rightArrowTip.x - kArrowFrameWidth,
                               _rightArrowTip.y - kArrowFrameHeightHalf,
                               kArrowFrameWidth,
                               kArrowFrameHeight);

    ...
}

Instead of the default frames that PaintCode provided, you now have your own frames calculated using the left and right arrowhead positions, as well as the width and height of the arrow frame.

Build and run again and behold the result:

Arrow second run

Hey, that looks a lot better — the arrows are now drawn exactly where you wanted them, and the bezier curve is calculated according to the supplied arrowhead coordinates.

Adding Touch Handlers for the Bezier Arrow

Okay, so you can control where the arrowheads sit on the screen from code, but you need to allow the user to drag the arrowhead around.

Drag all the things

Just before you go all crazy adding touch methods to your code, add a few supporting elements to your code.

First, add the following property to the class extension in BezierView.m:

@property (assign, nonatomic, readonly) CGRect rightArrowFrame;

Next, add the following custom getter for the new property to BezierView.m:

-(CGRect)rightArrowFrame {
    return CGRectMake(_rightArrowTip.x - kArrowFrameWidth, 
                      _rightArrowTip.y - kArrowFrameHeightHalf, 
                      kArrowFrameWidth, 
                      kArrowFrameHeight);
}

This code returns a rectangle containing the size and coordinates of the right arrow frame.

Still working in BezierView.m, update the //Frames section of drawRect: to reference the custom getter you created in the previous step:

- (void)drawRect:(CGRect)rect
{
    ...

    // Frames
    CGRect frame = CGRectMake(_leftArrowTip.x,
                              _leftArrowTip.y - kArrowFrameHeightHalf,
                              kArrowFrameWidth,
                              kArrowFrameHeight);
    CGRect frame2 = self.rightArrowFrame;

    ...
}

A UIView subclass on its own is not inherently interactive, but it can instantly respond to touches because it’s also a subclass of UIResponder. There’s four methods to override in order to detect touches and to make the right arrow draggable:

  • -(void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
  • -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
  • -(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
  • -(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event

Add the code below to BezierView.m:

-(void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
    _touchState = TouchStateInvalid;
}

Since the above method is called when a touch is cancelled, the method simply sets the _touchState variable to indicate that no dragging is taking place.

Now add the following code to BezierView.m:

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    CGPoint touchPoint = [[touches anyObject] locationInView:self];
    
    if (CGRectContainsPoint(self.rightArrowFrame, touchPoint)) {
        _touchState = TouchStateRightArrow;
    }
}

This method retrieves the point where the user touched the view and then checks to see if the point is within the bounds of the right arrowhead’s frame. If so, then _touchState is set to indicate that the user has tapped the right arrowhead and can begin dragging.

So far you’ve handled the cases where a user begins and cancels touches on your arrow. But the most complex logic comes in as a user drags the arrow around.

Add the following code to BezierView.m:

-(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    // 1
    if (_touchState == TouchStateRightArrow) {
        // 2
        CGPoint touchPoint = [[touches anyObject] locationInView:self];
        
        // 3
        if (touchPoint.y >= _leftArrowTip.y) {
            // 4
            _leftArrowTip = CGPointMake(0, kArrowFrameHeightHalf);
            _rightArrowTip = CGPointMake(_rightArrowTip.x, touchPoint.y);
            
            self.frame = CGRectMake(_initialOrigin.x, _initialOrigin.y,
                                    self.frame.size.width, touchPoint.y + kArrowFrameHeightHalf);
        } else {
            // 5
            CGFloat newYPosition = [self convertPoint:touchPoint 
                                         toView:self.superview].y - kArrowFrameHeightHalf;
            CGFloat newHeight = _initialLeftArrowTipY - newYPosition + kArrowFrameHeightHalf;
            
            // 6
            self.frame = CGRectMake(_initialOrigin.x, newYPosition, self.frame.size.width, newHeight);
            _rightArrowTip = CGPointMake(_rightArrowTip.x, kArrowFrameHeightHalf);
            _leftArrowTip = CGPointMake(_leftArrowTip.x, newHeight - kArrowFrameHeightHalf);
        }
        
        // 7
        [self setNeedsDisplay];
    }
}

Here’s a review of the above code, comment by comment:

  1. Check that you are in the TouchStateRightArrow state, meaning the user tapped within the right arrow frame. If so, the arrow head can be moved.
  2. Acquire the touch point and convert it to local view coordinates.
  3. If the touch point is higher than the left arrowhead’s Y position, then the right arrow head is located above the left arrowhead and you enter the if block. Otherwise, it must be below the left arrowhead’s Y position, and you enter the else block.
  4. If the right arrow is above the left arrow, calculate the new positions for the left and right arrowheads. Next, set the view’s frame using _initialOrigin for the X and Y values, the view’s current width for the new width since the right arrowhead can only be dragged up and down, and half of the frame’s height plus the touch point’s Y value for the new height of the frame.
  5. If the right arrowhead is lower than the left arrowhead, there’s a bit more code. Retrieve the new Y position of the view’s frame using convertPoint:toView:. The new height of the frame is calculated by subtracting the new Y position from the initial left arrowhead’s Y position, and then adding half of the frame height of an arrowhead.
  6. Set the view’s frame and the new coordinates for the left and right arrowheads.
  7. Call setNeedsDisplay to indicate that the view has changed and needs to be redrawn.

There’s one small method left to add before you can finish this part off.

Add the following method to BezierView.m:

-(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
    _touchState = TouchStateInvalid;
    
    if (self.releaseHandler) {
        CGPoint superViewPosition = [self convertPoint:_rightArrowTip
                                                toView:self.superview];
        
        self.releaseHandler(superViewPosition);
    }
}

The above method first sets _touchState to indicate that no dragging is taking place. It then checks to see if there’s a release handler. If so, the method calls it and passes the release point of the right arrowhead after converting the position to the parent view’s coordinate system.

If you didn’t convert the coordinates, then your values would be relative to the coordinate system of the BezierView, which would cause problems when drawing your arrow!

The time has come to test your work!

Build and run the project, switch to the Bezier tab, tap the right arrow and try dragging it up and down:

Dragging the right arrow

Your dynamic, draggable arrow is fully functional. However, creating a custom component is only half the fun — using your custom control creatively in an app takes it to the next level!