Draw Graph curves with UIBezierPath

2019-01-10 08:28发布

问题:

I'm drawing a graph in my application. My problem is that I want to draw line joining vertex points as curves. Currently I'm drawing them with UIBezierPath's function addLineToPoint:. I want to draw them as curves. I'm well aware that UIBezierPath has following two functions to support this feature.

Cubic curve:addCurveToPoint:controlPoint1:controlPoint2: Quadratic curve:addQuadCurveToPoint:controlPoint:

But problem is that I don't have control points. All i have is two end points. Neither I found a method/formula to determine control points. Can anyone help me here? I will appreciate if someone can suggest some alternative...

回答1:

Expand on Abid Hussain's answer on Dec 5 '12.

i implemeted the code, and it worked but result looked like that:

With little adjustment, i was able to get what i wanted:

What i did was addQuadCurveToPoint to mid points as well, using mid-mid points as control points. Below is the code based on Abid's sample; hope it helps someone.

+ (UIBezierPath *)quadCurvedPathWithPoints:(NSArray *)points
{
    UIBezierPath *path = [UIBezierPath bezierPath];

    NSValue *value = points[0];
    CGPoint p1 = [value CGPointValue];
    [path moveToPoint:p1];

    if (points.count == 2) {
        value = points[1];
        CGPoint p2 = [value CGPointValue];
        [path addLineToPoint:p2];
        return path;
    }

    for (NSUInteger i = 1; i < points.count; i++) {
        value = points[i];
        CGPoint p2 = [value CGPointValue];

        CGPoint midPoint = midPointForPoints(p1, p2);
        [path addQuadCurveToPoint:midPoint controlPoint:controlPointForPoints(midPoint, p1)];
        [path addQuadCurveToPoint:p2 controlPoint:controlPointForPoints(midPoint, p2)];

        p1 = p2;
    }
    return path;
}

static CGPoint midPointForPoints(CGPoint p1, CGPoint p2) {
    return CGPointMake((p1.x + p2.x) / 2, (p1.y + p2.y) / 2);
}

static CGPoint controlPointForPoints(CGPoint p1, CGPoint p2) {
    CGPoint controlPoint = midPointForPoints(p1, p2);
    CGFloat diffY = abs(p2.y - controlPoint.y);

    if (p1.y < p2.y)
        controlPoint.y += diffY;
    else if (p1.y > p2.y)
        controlPoint.y -= diffY;

    return controlPoint;
}


回答2:

Using @user1244109's answer I implement a better algorithm in Swift 3.

var data: [CGFloat] = [0, 0, 0, 0, 0, 0] {
    didSet {
        setNeedsDisplay()
    }
}

func coordXFor(index: Int) -> CGFloat {
    return bounds.height - bounds.height * data[index] / (data.max() ?? 0)
}

override func draw(_ rect: CGRect) {

    let path = quadCurvedPath()

    UIColor.black.setStroke()
    path.lineWidth = 1
    path.stroke()
}

func quadCurvedPath() -> UIBezierPath {
    let path = UIBezierPath()
    let step = bounds.width / CGFloat(data.count - 1)

    var p1 = CGPoint(x: 0, y: coordXFor(index: 0))
    path.move(to: p1)

    drawPoint(point: p1, color: UIColor.red, radius: 3)

    if (data.count == 2) {
        path.addLine(to: CGPoint(x: step, y: coordXFor(index: 1)))
        return path
    }

    var oldControlP: CGPoint?

    for i in 1..<data.count {
        let p2 = CGPoint(x: step * CGFloat(i), y: coordXFor(index: i))
        drawPoint(point: p2, color: UIColor.red, radius: 3)
        var p3: CGPoint?
        if i == data.count - 1 {
            p3 = nil
        } else {
            p3 = CGPoint(x: step * CGFloat(i + 1), y: coordXFor(index: i + 1))
        }

        let newControlP = controlPointForPoints(p1: p1, p2: p2, p3: p3)

        path.addCurve(to: p2, controlPoint1: oldControlP ?? p1, controlPoint2: newControlP ?? p2)

        p1 = p2
        oldControlP = imaginFor(point1: newControlP, center: p2)
    }
    return path;
}

func imaginFor(point1: CGPoint?, center: CGPoint?) -> CGPoint? {
    guard let p1 = point1, let center = center else {
        return nil
    }
    let newX = 2 * center.x - p1.x
    let diffY = abs(p1.y - center.y)
    let newY = center.y + diffY * (p1.y < center.y ? 1 : -1)

    return CGPoint(x: newX, y: newY)
}

func midPointForPoints(p1: CGPoint, p2: CGPoint) -> CGPoint {
    return CGPoint(x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2);
}

func controlPointForPoints(p1: CGPoint, p2: CGPoint, p3: CGPoint?) -> CGPoint? {
    guard let p3 = p3 else {
        return nil
    }

    let leftMidPoint  = midPointForPoints(p1: p1, p2: p2)
    let rightMidPoint = midPointForPoints(p1: p2, p2: p3)

    var controlPoint = midPointForPoints(p1: leftMidPoint, p2: imaginFor(point1: rightMidPoint, center: p2)!)


    // this part needs for optimization

    if p1.y < p2.y {
        if controlPoint.y < p1.y {
            controlPoint.y = p1.y
        }
        if controlPoint.y > p2.y {
            controlPoint.y = p2.y
        }
    } else {
        if controlPoint.y > p1.y {
            controlPoint.y = p1.y
        }
        if controlPoint.y < p2.y {
            controlPoint.y = p2.y
        }
    }

    let imaginContol = imaginFor(point1: controlPoint, center: p2)!
    if p2.y < p3.y {
        if imaginContol.y < p2.y {
            controlPoint.y = p2.y
        }
        if imaginContol.y > p3.y {
            let diffY = abs(p2.y - p3.y)
            controlPoint.y = p2.y + diffY * (p3.y < p2.y ? 1 : -1)
        }
    } else {
        if imaginContol.y > p2.y {
            controlPoint.y = p2.y
        }
        if imaginContol.y < p3.y {
            let diffY = abs(p2.y - p3.y)
            controlPoint.y = p2.y + diffY * (p3.y < p2.y ? 1 : -1)
        }
    }

    return controlPoint
}

func drawPoint(point: CGPoint, color: UIColor, radius: CGFloat) {
    let ovalPath = UIBezierPath(ovalIn: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2))
    color.setFill()
    ovalPath.fill()
}


回答3:

If you only have two points then you cannot really extrapolate them out into a curve.

If you have more than two points (i.e. several points along a curve) then you can roughly draw a curve to follow them.

If you do the following...

OK, say you have 5 points. p1, p2, p3, p4, and p5. You need to work out the midpoint between each pair of points. m1 = midpoint of p1 and p2 (and so on)...

So you have m1, m2, m3, and m4.

Now you can use the mid points as the end points of the sections of the curve and the points as the control points for a quad curve...

So...

Move to point m1. Add quad curve to point m2 with p2 as a control point.

Add quad curve to point m3 with p3 as a control point.

Add quad curve to point m4 with p4 as a control point.

and so on...

This will get you all apart from the ends of the curve (can't remember how to get them at the moment, sorry).



回答4:

SO I found a work around based on @Fogmeister's answer.

    UIBezierPath *path = [UIBezierPath bezierPath];
    [path setLineWidth:3.0];
    [path setLineCapStyle:kCGLineCapRound];
    [path setLineJoinStyle:kCGLineJoinRound];

    // actualPoints are my points array stored as NSValue

    NSValue *value = [actualPoints objectAtIndex:0];
    CGPoint p1 = [value CGPointValue];
    [path moveToPoint:p1];

    for (int k=1; k<[actualPoints count];k++) {

        NSValue *value = [actualPoints objectAtIndex:k];
        CGPoint p2 = [value CGPointValue];

        CGPoint centerPoint = CGPointMake((p1.x+p2.x)/2, (p1.y+p2.y)/2);

        // See if your curve is decreasing or increasing
        // You can optimize it further by finding point on normal of line passing through midpoint 

        if (p1.y<p2.y) {
             centerPoint = CGPointMake(centerPoint.x, centerPoint.y+(abs(p2.y-centerPoint.y)));
        }else if(p1.y>p2.y){
             centerPoint = CGPointMake(centerPoint.x, centerPoint.y-(abs(p2.y-centerPoint.y)));
        }

        [path addQuadCurveToPoint:p2 controlPoint:centerPoint];
        p1 = p2;
    }

    [path stroke];


回答5:

I've done a bit of work on this and get pretty good results from the Catmull-Rom spline. Here is a method that takes an array of CGPoints (stored as NSValues) and adds the line to the given view.

- (void)addBezierPathBetweenPoints:(NSArray *)points
                        toView:(UIView *)view
                     withColor:(UIColor *)color
                andStrokeWidth:(NSUInteger)strokeWidth
{
    UIBezierPath *path = [UIBezierPath bezierPath];

    float granularity = 100;

    [path moveToPoint:[[points firstObject] CGPointValue]];

    for (int index = 1; index < points.count - 2 ; index++) {

        CGPoint point0 = [[points objectAtIndex:index - 1] CGPointValue];
        CGPoint point1 = [[points objectAtIndex:index] CGPointValue];
        CGPoint point2 = [[points objectAtIndex:index + 1] CGPointValue];
        CGPoint point3 = [[points objectAtIndex:index + 2] CGPointValue];

        for (int i = 1; i < granularity ; i++) {
            float t = (float) i * (1.0f / (float) granularity);
            float tt = t * t;
            float ttt = tt * t;

            CGPoint pi;
            pi.x = 0.5 * (2*point1.x+(point2.x-point0.x)*t + (2*point0.x-5*point1.x+4*point2.x-point3.x)*tt + (3*point1.x-point0.x-3*point2.x+point3.x)*ttt);
            pi.y = 0.5 * (2*point1.y+(point2.y-point0.y)*t + (2*point0.y-5*point1.y+4*point2.y-point3.y)*tt + (3*point1.y-point0.y-3*point2.y+point3.y)*ttt);

            if (pi.y > view.frame.size.height) {
                pi.y = view.frame.size.height;
            }
            else if (pi.y < 0){
                pi.y = 0;
            }

            if (pi.x > point0.x) {
                [path addLineToPoint:pi];
            }
        }

        [path addLineToPoint:point2];
    }

    [path addLineToPoint:[[points objectAtIndex:[points count] - 1] CGPointValue]];

    CAShapeLayer *shapeView = [[CAShapeLayer alloc] init];

    shapeView.path = [path CGPath];

    shapeView.strokeColor = color.CGColor;
    shapeView.fillColor = [UIColor clearColor].CGColor;
    shapeView.lineWidth = strokeWidth;
    [shapeView setLineCap:kCALineCapRound];

    [view.layer addSublayer:shapeView];
 }

I use it here https://github.com/johnyorke/JYGraphViewController



回答6:

A good solution would be to create a straight UIBezierPath through your points and then use a spline to curve the line. Check out this other answer which gives a category for UIBezierPath that performs a Catmull-Rom Spline. Drawing Smooth Curves - Methods Needed



回答7:

here's @user1244109 's answer converted to Swift 3

private func quadCurvedPath(with points:[CGPoint]) -> UIBezierPath {
   let path = UIBezierPath()
   var p1 = points[0]

   path.move(to: p1)

   if points.count == 2 {
     path.addLine(to: points[1])
     return path
   }

   for i in 0..<points.count {
    let mid = midPoint(for: (p1, points[i]))
     path.addQuadCurve(to: mid,
                     controlPoint: controlPoint(for: (mid, p1)))
     path.addQuadCurve(to: points[i],
                    controlPoint: controlPoint(for: (mid, points[i])))
     p1 = points[i]
   }
  return path
}


private func midPoint(for points: (CGPoint, CGPoint)) -> CGPoint {
   return CGPoint(x: (points.0.x + points.1.x) / 2 , y: (points.0.y + points.1.y) / 2)
}


private func controlPoint(for points: (CGPoint, CGPoint)) -> CGPoint {
   var controlPoint = midPoint(for: points)
   let diffY = abs(points.1.y - controlPoint.y)

   if points.0.y < points.1.y {
     controlPoint.y += diffY
   } else if points.0.y > points.1.y {
     controlPoint.y -= diffY
   }
   return controlPoint
}


回答8:

While analyzing the code from @roman-filippov, I simplified the code some. Here's a complete Swift playground version, and an ObjC version below that.

I found that if x increments are not regular, then the original algorithm creates some unfortunate retrograde lines when points are close together on the x axis. Simply constraining the control points to not exceed the following x-value seems to fix the problem, although I have no mathematical justification for this, just experimentation. There are two sections marked as //**added` which implement this change.

import UIKit
import PlaygroundSupport

infix operator °
func °(x: CGFloat, y: CGFloat) -> CGPoint {
    return CGPoint(x: x, y: y)
}

extension UIBezierPath {
    func drawPoint(point: CGPoint, color: UIColor, radius: CGFloat) {
        let ovalPath = UIBezierPath(ovalIn: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2))
        color.setFill()
        ovalPath.fill()
    }

    func drawWithLine (point: CGPoint, color: UIColor) {
        let startP = self.currentPoint
        self.addLine(to: point)
        drawPoint(point: point, color: color, radius: 3)
        self.move(to: startP)
    }

}

class TestView : UIView {
    var step: CGFloat = 1.0;
    var yMaximum: CGFloat = 1.0
    var xMaximum: CGFloat = 1.0
    var data: [CGPoint] = [] {
        didSet {
            xMaximum = data.reduce(-CGFloat.greatestFiniteMagnitude, { max($0, $1.x) })
            yMaximum = data.reduce(-CGFloat.greatestFiniteMagnitude, { max($0, $1.y) })
            setNeedsDisplay()
        }
    }

    func scale(point: CGPoint) -> CGPoint {
        return  CGPoint(x: bounds.width * point.x / xMaximum ,
                        y: (bounds.height - bounds.height * point.y / yMaximum))
    }

    override func draw(_ rect: CGRect) {
        if data.count <= 1 {
            return
        }
        let path = cubicCurvedPath()

        UIColor.black.setStroke()
        path.lineWidth = 1
        path.stroke()
    }

    func cubicCurvedPath() -> UIBezierPath {
        let path = UIBezierPath()

        var p1 = scale(point: data[0])
        path.drawPoint(point: p1, color: UIColor.red, radius: 3)
        path.move(to: p1)
        var oldControlP = p1

        for i in 0..<data.count {
            let p2 = scale(point:data[i])
            path.drawPoint(point: p2, color: UIColor.red, radius: 3)
            var p3: CGPoint? = nil
            if i < data.count - 1 {
                p3 = scale(point:data [i+1])
            }

            let newControlP = controlPointForPoints(p1: p1, p2: p2, p3: p3)
            //uncomment the following four lines to graph control points
            //if let controlP = newControlP {
            //    path.drawWithLine(point:controlP, color: UIColor.blue)
            //}
            //path.drawWithLine(point:oldControlP, color: UIColor.gray)

            path.addCurve(to: p2, controlPoint1: oldControlP , controlPoint2: newControlP ?? p2)
            oldControlP = imaginFor(point1: newControlP, center: p2) ?? p1
            //***added to algorithm
            if let p3 = p3 {
                if oldControlP.x > p3.x { oldControlP.x = p3.x }
            }
            //***
            p1 = p2
        }
        return path;
    }

    func imaginFor(point1: CGPoint?, center: CGPoint?) -> CGPoint? {
        //returns "mirror image" of point: the point that is symmetrical through center.
        //aka opposite of midpoint; returns the point whose midpoint with point1 is center)
        guard let p1 = point1, let center = center else {
            return nil
        }
        let newX = center.x + center.x - p1.x
        let newY = center.y + center.y - p1.y

        return CGPoint(x: newX, y: newY)
    }

    func midPointForPoints(p1: CGPoint, p2: CGPoint) -> CGPoint {
        return CGPoint(x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2);
    }

    func clamp(num: CGFloat, bounds1: CGFloat, bounds2: CGFloat) -> CGFloat {
        //ensure num is between bounds.
        if (bounds1 < bounds2) {
            return min(max(bounds1,num),bounds2);
        } else {
            return min(max(bounds2,num),bounds1);
        }
    }

    func controlPointForPoints(p1: CGPoint, p2: CGPoint, p3: CGPoint?) -> CGPoint? {
        guard let p3 = p3 else {
            return nil
        }

        let leftMidPoint  = midPointForPoints(p1: p1, p2: p2)
        let rightMidPoint = midPointForPoints(p1: p2, p2: p3)
        let imaginPoint = imaginFor(point1: rightMidPoint, center: p2)

        var controlPoint = midPointForPoints(p1: leftMidPoint, p2: imaginPoint!)

        controlPoint.y = clamp(num: controlPoint.y, bounds1: p1.y, bounds2: p2.y)

        let flippedP3 = p2.y + (p2.y-p3.y)

        controlPoint.y = clamp(num: controlPoint.y, bounds1: p2.y, bounds2: flippedP3);
        //***added:
        controlPoint.x = clamp (num:controlPoint.x, bounds1: p1.x, bounds2: p2.x)
        //***
        // print ("p1: \(p1), p2: \(p2), p3: \(p3), LM:\(leftMidPoint), RM:\(rightMidPoint), IP:\(imaginPoint), fP3:\(flippedP3), CP:\(controlPoint)")
        return controlPoint
    }

}

let u = TestView(frame: CGRect(x: 0, y: 0, width: 700, height: 600));
u.backgroundColor = UIColor.white

PlaygroundPage.current.liveView = u
u.data = [0.5 ° 1, 1 ° 3, 2 ° 5, 4 ° 9, 8 ° 15, 9.4 ° 8, 9.5 ° 10, 12 ° 4, 13 ° 10, 15 ° 3, 16 ° 1]

And the same code in ObjC (more or less. this doesn't include the drawing routines themselves, and it does allow the points array to include missing data

+ (UIBezierPath *)pathWithPoints:(NSArray <NSValue *> *)points open:(BOOL) open {
    //based on Roman Filippov code: http://stackoverflow.com/a/40203583/580850
    //open means allow gaps in path.
    UIBezierPath *path = [UIBezierPath bezierPath];
    CGPoint p1 = [points[0] CGPointValue];
    [path moveToPoint:p1];
    CGPoint oldControlPoint = p1;
    for (NSUInteger pointIndex = 1; pointIndex< points.count; pointIndex++) {
        CGPoint p2 = [points[pointIndex]  CGPointValue];  //note: mark missing data with CGFloatMax

        if (p1.y >= CGFloatMax || p2.y >= CGFloatMax) {
            if (open) {
                [path moveToPoint:p2];
            } else {
                [path addLineToPoint:p2];
            }
            oldControlPoint = p2;
        } else {
            CGPoint p3 = CGPointZero;
            if (pointIndex +1 < points.count) p3 = [points[pointIndex+1] CGPointValue] ;
            if (p3.y >= CGFloatMax) p3 = CGPointZero;
            CGPoint newControlPoint = controlPointForPoints2(p1, p2, p3);
            if (!CGPointEqualToPoint( newControlPoint, CGPointZero)) {
                [path addCurveToPoint: p2 controlPoint1:oldControlPoint controlPoint2: newControlPoint];
                oldControlPoint = imaginForPoints( newControlPoint,  p2);
                //**added to algorithm
                if (! CGPointEqualToPoint(p3,CGPointZero)) {
                   if (oldControlPoint.x > p3.x ) {
                       oldControlPoint.x = p3.x;
                   }
                //***
            } else {
                [path addCurveToPoint: p2 controlPoint1:oldControlPoint controlPoint2: p2];
                oldControlPoint = p2;
            }
        }
        p1 = p2;
    }
    return path;
}

static CGPoint imaginForPoints(CGPoint point, CGPoint center) {
    //returns "mirror image" of point: the point that is symmetrical through center.
    if (CGPointEqualToPoint(point, CGPointZero) || CGPointEqualToPoint(center, CGPointZero)) {
        return CGPointZero;
    }
    CGFloat newX = center.x + (center.x-point.x);
    CGFloat newY = center.y + (center.y-point.y);
    if (isinf(newY)) {
        newY = BEMNullGraphValue;
    }
    return CGPointMake(newX,newY);
}

static CGFloat clamp(CGFloat num, CGFloat bounds1, CGFloat bounds2) {
    //ensure num is between bounds.
    if (bounds1 < bounds2) {
        return MIN(MAX(bounds1,num),bounds2);
    } else {
        return MIN(MAX(bounds2,num),bounds1);
    }
}

static CGPoint controlPointForPoints2(CGPoint p1, CGPoint p2, CGPoint p3) {
    if (CGPointEqualToPoint(p3, CGPointZero)) return CGPointZero;
    CGPoint leftMidPoint = midPointForPoints(p1, p2);
    CGPoint rightMidPoint = midPointForPoints(p2, p3);
    CGPoint imaginPoint = imaginForPoints(rightMidPoint, p2);
    CGPoint controlPoint = midPointForPoints(leftMidPoint, imaginPoint);

    controlPoint.y = clamp(controlPoint.y, p1.y, p2.y);

    CGFloat flippedP3 = p2.y + (p2.y-p3.y);

    controlPoint.y = clamp(controlPoint.y, p2.y, flippedP3);
   //**added to algorithm
    controlPoint.x = clamp(controlPoint.x, p1.x, p2.x);
   //**
    return controlPoint;
}