Finally (?) Understanding Bounds, Frames, and Positions Thanks to Misunderstanding CAShapeLayers, CGPaths and Lines

I will try to keep this short so that I can get this post out.

TL;DR #

CAShapeLayer is a canvas upon which you can draw CGPath. It’s bounds, and position must be set manually.
The easiest way to do that is

    CGPathGetBoundingBox(path);
    layer.frame = layer.bounds;

If the lineWidth property is non-zero, the bounds should be adjusted to contain the entire path plus the additional width of the stroke.

Longer Version #

It turns out that I had really misunderstood CAShapeLayers and had mostly misunderstood some key issues about the bounds property of CALayer/UIView.

In most regards, the misunderstandings are so basic and fundamental, that I am embarrassed to share them, but on the other hand, I would have loved a “talk to me like I’m stupid” explanation of both of these topics. But still, pretty embarrassed.

Discovery of My First Mistake #

CAShapeLayers are a nice way to draw a CGPath and add it straight to a view’s layer. The way it works is that just like any other CALayer it provides you a canvas, defined by its frame and bounds upon which to stroke the a CGPath. It also provides a number of properties and methods to set the properties of the stroked path.

So I wanted to draw a line from one point to another. Here is what I wrote

    // define cgpath using UIBezierPath convenience methods
    UIBezierPath *path = [UIBezierPath bezierPath];
    [path moveToPoint:point];
    [path addLineToPoint:CGPointMake(point.x + 50, point.y +100)];
    [path closePath];
    // set the layer's path to the previously created CGPath as well as the line and stroke properties.
    CAShapeLayer *layer = [CAShapeLayer layer];
    layer.path = path;
    layer.lineWidth = 10;
    layer.strokeColor = [UIColor brownColor].CGColor;

    [self.view.layer addSublayer:layer];

and voilá, I get lines, as I expect, where I tap:

taps.png

But, I have already messed up at this point, even though things worked

Here’s how I finally discovered my mistake[containsPoint]:

What if I wanted to add a background to this path that I drew? It should be as simple as adding the following line:

    layer.backgroundColor = [UIColor lightGreyColor].cgcolor;

Weird. Nothing showed up. Let’s log it:

bounds {{0, 0}, {0, 0}}

position {0, 0}

frame {{0, 0}, {0, 0}}

Ok, so the only reason this works, is that our path is being drawn relative to the origin of the main view.[1]

so we can conveniently find the correct bounds with this function:

    CGPathGetBoundingBox(path);

resulting in:

bounds {{243, 208.5}, {50, 100}}

So, then my misunderstandings really kicked in…
First I just flailed a bit, first setting it this way

    layer.bounds = CGPathGetBoundingBox(path);

but then, oddly, no matter where I tap I see this result.

boundsOnly.png

Let’s log the results again:

position {0, 0}

bounds {{63.5, 258}, {50, 100}}

frame {{-25, -50}, {50, 100}}

Right! I realized that the frame is a property that is actually calculated from bounds and position, and since I hadn’t set the position, it was {0,0}, and the frame’s origin was set accordingly. Though I was still not quite clueing into why the path that had previously ignored the constant origin of the frame was now showing up in the same {0,0} position.

So, knowing that for CALayers, the frame is just based on the bounds and positions, I decided to set them each individually, as I usually do for a CALayer, setting bounds origin to {0,0} and position for the center of the bounds size:

    CGPoint p = [sender locationInView:self.view];
    CGRect correctBounds = CGPathGetBoundingBox(layer.path);
    layer.bounds = CGRectMake(0, 0, correctBounds.size.width,correctBounds.size.height);
    layer.position = CGPointMake(p.x + 25, p.y + 50);

This resulted in:
frameNoPath.png

so, progress!? I have the frame where I tapped, but my path has disappeared!![2]

logs show:

frame {{191.5, 223.5}, {50, 100}}

bounds {{0, 0}, {50, 100}}

position {216.5, 273.5}

which to my eyes, looked correct. I could not understand what was wrong.

A Deeper Understanding Emerges #

Then I was reminded of an Ole Begemann [blog post][olePost] discussing UIScrollView and a helpfully detailed discussion of bounds versus frames with regards to offsetting content in a frame.


…a lot of struggle against my continuing ignorance and misperceptions later…


I finally figured out that all I needed to do was this:

    layer.bounds = CGPathGetBoundingBox(path);
    layer.frame = layer.bounds;

That really did take me a while to understand. This is how it works for all the dummies like me:

For example, consider a two-point path from {120,120}-{250, 250}.

Setting it as above, would shift the path, relative to the layer’s frame to {0,0}-{130,130}, and then the frame’s origin would be set to {120,120}, moving the path to {120,120}-{250, 250} relative to the superLayer’s frame.

A key thing to note is that setting either the layer.position or the layer.frame values will have no impact on the bounds.origin value.

Now I was close to getting the CAShapeLayer correct. I now get this as the result:

correctNoBuffer.png

As you can see, the path hangs over the background’s frame, even though we used the CGPathGetBoundingBox(path). Which gets to the heart of a second two-part thing I did not really understand about paths.

  1. A path is effectively a set of dimensionless points describing the curve. It is NOT the same as the line, which has the visual properties of interest, such as width, color, join shapes, etc.
  2. The primary purpose of a path is to describe the outline of custom shapes. It is, typically, NOT the focus of what is being drawn.

These two misunderstanding are at the root of why I never really considered the frame, bounds, etc. of a CAShapeLayer. I thought the CAShapeLayer was the path, or possibly stroked line itself. But as I stated at the beginning of this post, the CAShapeLayer is the canvas upon with the path, or shape described by the path is drawn.

So if I draw the thinnest line that I can in the same place as this thicker line, you can see that the path is completely encompassed by the frame, but that the thickness of the line is drawn outward from the path. I also set .masksToBounds = YES, so that you can see what gets cut off. In fact, this realization was helped along by getting walkthrough of [this CALayer animation code][kenWatch] by [ken Ferry][@kongtomorrow], which specifically raised the issue of the canvas size needing to be adjusted for the CAShapeLayers lineWidth[3]

looks like this.

noBufferMask.png

If I want all of the line to show up, I need to widen and offset the frame by the width of the line that hangs over.

    CGRect pathBounds = CGPathGetBoundingBox(path);
    CGRect correctedBounds = CGRectMake(pathBounds.origin.x - lineWidth/2, pathBounds.origin.y - lineWidth/2, pathBounds.size.width + lineWidth, pathBounds.size.height + lineWidth);
    layer.bounds = correctedBounds;
    layer.frame = layer.bounds;

extraBuffer.png

Depending on how good your eyesight is, you can see that there is a bit of extra space, because the path is at 45º angle, and the path only needs sqrt(2)/2 of extra space, because geometry. So If I adjust the bounds by that value:

noExtraBuffer.png

So, there is my overly long explanation of something that should probably have been clear to me by this point, but writing this down has really helped me understand both CAShapeLayer, as well as the role of the bounds.origin in a layer/view much more clearly.

[olePost]: http://oleb.net/blog/2014/04/understanding-uiscrollview/

[kenWatch]: https://github.com/kongtomorrow/WatchTransition

[1]: and despite no frame, it is still showing up, because by default, CALayer has its .masksToBounds property set to NO, thus exposing the entire canvas, regardles of the frame. If you set .masksToBounds = YES, it disappears.

[2]: clicking closer to the origin would have revealed where the path was going.

[3]: Despite walking through this code, my misperceptions regarding CAShapeLayer remained firmly in place.

[containsPoint]: Actually, coloring the background was the confirmation. I actually was confronted with my misunderstanding when I tried to use CGPathContainsPoint to detect a tap on the stroked path. That of course did not work, because the function calculates if a point is inside the shape described by the closed path.

 
18
Kudos
 
18
Kudos

Now read this

Partial vs. Complete Functions

Whoo boy, stick with this seemingly complicated article (linked in the title), because it directly addresses that subtle issue that I face regularly when functions or methods that I write can’t properly map directly all input to your... Continue →