shannoga
shannoga

Reputation: 19879

Get CGPath total length

I have many UIBezierPath that I am animating with CALyerAnimation @"StrokeStart" and @"strokeEnd".

I wish that the animation will have the same speed for all the paths so I thought I might use the length of the path in:

DISTANCE / TIME = SPEED

Is there a way to calculate the path "length"?

Upvotes: 16

Views: 7491

Answers (2)

Mojtaba Hosseini
Mojtaba Hosseini

Reputation: 120093

✅ A Simple Extension For Swift

This is NOT a converted version of the other Objective-C answer.

You need to iterate over all the path elements. calculate the length for each path element and make sure to calculate the curves with good approximation.

Here is a simple extension on the CGPath itself so you can easily call .length on any path you want:

import CoreGraphics

extension CGPath {
    var length: CGFloat {
        var totalLength: CGFloat = 0
        var previousPoint: CGPoint?

        self.applyWithBlock { element in
            let points = element.pointee.points
            guard let prev = previousPoint else {
                return element.pointee.type == .moveToPoint ? previousPoint = points[0] : ()
            }

            switch element.pointee.type {
            case .addLineToPoint:
                totalLength += distance(from: prev, to: points[0])
                previousPoint = points[0]

            case .addQuadCurveToPoint:
                totalLength += quadCurveLength(from: prev, to: points[1], control: points[0])
                previousPoint = points[1]

            case .addCurveToPoint:
                totalLength += cubicCurveLength(from: prev, to: points[2], control1: points[0], control2: points[1])
                previousPoint = points[2]

            default: break
            }
        }
        return totalLength
    }

    private func distance(from p1: CGPoint, to p2: CGPoint) -> CGFloat { hypot(p2.x - p1.x, p2.y - p1.y) }

    // Approximate the quadratic Bezier curve length
    private var subdivisions: Int { 50 }

    private func quadCurveLength(from start: CGPoint, to end: CGPoint, control: CGPoint) -> CGFloat {
        var length: CGFloat = 0.0
        var previousPoint = start

        for i in 1...subdivisions {
            let t = CGFloat(i) / CGFloat(subdivisions)
            let x = (1.0 - t) * (1.0 - t) * start.x + 2.0 * (1.0 - t) * t * control.x + t * t * end.x
            let y = (1.0 - t) * (1.0 - t) * start.y + 2.0 * (1.0 - t) * t * control.y + t * t * end.y
            let currentPoint = CGPoint(x: x, y: y)
            length += distance(from: previousPoint, to: currentPoint)
            previousPoint = currentPoint
        }
        return length
    }

    private func cubicCurveLength(from start: CGPoint, to end: CGPoint, control1: CGPoint, control2: CGPoint) -> CGFloat {
        var length: CGFloat = 0.0
        var previousPoint = start

        for i in 1...subdivisions {
            let t = CGFloat(i) / CGFloat(subdivisions)
            let x = pow(1.0 - t, 3) * start.x + 3.0 * pow(1.0 - t, 2) * t * control1.x + 3.0 * (1.0 - t) * pow(t, 2) * control2.x + pow(t, 3) * end.x
            let y = pow(1.0 - t, 3) * start.y + 3.0 * pow(1.0 - t, 2) * t * control1.y + 3.0 * (1.0 - t) * pow(t, 2) * control2.y + pow(t, 3) * end.y
            let currentPoint = CGPoint(x: x, y: y)
            length += distance(from: previousPoint, to: currentPoint)
            previousPoint = currentPoint
        }
        return length
    }
}

Upvotes: 0

Bartosz Ciechanowski
Bartosz Ciechanowski

Reputation: 10333

While you can calculate Bezier path's length by integrating it numerically, it can be done much easier by dividing path into linear segments. If the segments are small enough the approximation error should be neglectable, especially that you are just trying to animate it.

I'll show you function for quad curves, but you can easily incorporate the solution for cubic curves as well:

- (float) bezierCurveLengthFromStartPoint: (CGPoint) start toEndPoint: (CGPoint) end withControlPoint: (CGPoint) control
{
    const int kSubdivisions = 50;
    const float step = 1.0f/(float)kSubdivisions;

    float totalLength = 0.0f;
    CGPoint prevPoint = start;

    // starting from i = 1, since for i = 0 calulated point is equal to start point
    for (int i = 1; i <= kSubdivisions; i++)
    {
        float t = i*step;

        float x = (1.0 - t)*(1.0 - t)*start.x + 2.0*(1.0 - t)*t*control.x + t*t*end.x;
        float y = (1.0 - t)*(1.0 - t)*start.y + 2.0*(1.0 - t)*t*control.y + t*t*end.y;

        CGPoint diff = CGPointMake(x - prevPoint.x, y - prevPoint.y);

        totalLength += sqrtf(diff.x*diff.x + diff.y*diff.y); // Pythagorean

        prevPoint = CGPointMake(x, y);
    }

    return totalLength;
}

EDIT

If you don't have access to path control points (say you created path using arcs) you can always access underlying Bezier curves using CGPathApply function:

- (void) testPath
{
    UIBezierPath *path = [UIBezierPath bezierPath];
    [path moveToPoint:CGPointZero];
    [path addQuadCurveToPoint:CGPointMake(1, 1) controlPoint:CGPointMake(-2, 2)];

    CGPathRef p = path.CGPath;

    CGPathApply(p, nil, pathFunction);
}

void pathFunction(void *info, const CGPathElement *element)
{
    if (element->type == kCGPathElementAddQuadCurveToPoint)
    {
        CGPoint p;
        p = element->points[0]; // control point
        NSLog(@"%lg %lg", p.x, p.y);

        p = element->points[1]; // end point
        NSLog(@"%lg %lg", p.x, p.y);
    }
    // check other cases as well!
}

Note that it doesn't provide the path's start point, but it's easy to keep track of it on your own.

Upvotes: 10

Related Questions