简体   繁体   中英

How to draw a curved line using CAShapeLayer and BezierPath in Swift 4?

I was wondering how I could render a curved line like the one shown in the picture below given two points (Point A and Point B) using CAShapeLayer and BezierPath in Swift 4?

func drawCurvedLine(start: CGPoint, end: CGPoint) {
   //insert code here
}

在此处输入图片说明

You will need to apply some math. The way I see it this is composed of 2 arcs with different radiuses. It might be quite challenging to compute those from 2 points but fortunately we have tools for arc that already do this for us. A method addQuadCurve on UIBezierPath seems perfect for this.

We need to input 2 points, a factor on how much the arc is bound and some line thickness. We use bend factor to determine how much is control point moved downward from center of the two points in your case. Downwards can be relative to the 2 points so let's just rather use normal . What I got to is the following:

func generateSpecialCurve(from: CGPoint, to: CGPoint, bendFactor: CGFloat, thickness: CGFloat) -> UIBezierPath {

    let center = CGPoint(x: (from.x+to.x)*0.5, y: (from.y+to.y)*0.5)
    let normal = CGPoint(x: -(from.y-to.y), y: (from.x-to.x))
    let normalNormalized: CGPoint = {
        let normalSize = sqrt(normal.x*normal.x + normal.y*normal.y)
        guard normalSize > 0.0 else { return .zero }
        return CGPoint(x: normal.x/normalSize, y: normal.y/normalSize)
    }()

    let path = UIBezierPath()

    path.move(to: from)

    let midControlPoint: CGPoint = CGPoint(x: center.x + normal.x*bendFactor, y: center.y + normal.y*bendFactor)
    let closeControlPoint: CGPoint = CGPoint(x: midControlPoint.x + normalNormalized.x*thickness*0.5, y: midControlPoint.y + normalNormalized.y*thickness*0.5)
    let farControlPoint: CGPoint = CGPoint(x: midControlPoint.x - normalNormalized.x*thickness*0.5, y: midControlPoint.y - normalNormalized.y*thickness*0.5)


    path.addQuadCurve(to: to, controlPoint: closeControlPoint)
    path.addQuadCurve(to: from, controlPoint: farControlPoint)
    path.close()
    return path
}

The data I used to get to a shape similar to your request was (overriding drawRect was the quickest):

override func draw(_ rect: CGRect) {
    super.draw(rect)

    let from: CGPoint = CGPoint(x: 100.0, y: 300.0)
    let to: CGPoint = CGPoint(x: 200.0, y: 300.0)

    UIColor.blue.setFill()
    generateSpecialCurve(from: from, to: to, bendFactor: -0.25, thickness: 10.0).fill()
}

Now negative vend factor means bending downward, the rest should be pretty intuitive.

EDIT Much more control can be achieved by composing the shape out of 4 curves:

Using 4 curves you can create nicer shapes but code may complicate quite nicely. This is what I tried to get a shape closer to the one you want:

func generateSpecialCurve(from: CGPoint, to: CGPoint, bendFactor: CGFloat, thickness: CGFloat, showDebug: Bool = false) -> UIBezierPath {

    var specialCurveScale: CGFloat = 0.2 // A factor to control sides
    var midControlPointsScale: CGFloat = 0.3 // A factor to cotnrol mid

    let center = from.adding(to).scaled(by: 0.5)
    let direction = from.direction(toward: to)
    let directionNormalized = direction.normalized
    let normal = direction.normal
    let normalNormalized = normal.normalized

    let middlePoints: (near: CGPoint, far: CGPoint) = {
        let middlePoint = center.adding(normal.scaled(by: bendFactor))
        return (middlePoint.subtracting(normalNormalized.scaled(by: thickness*0.5)), middlePoint.adding(normalNormalized.scaled(by: thickness*0.5)))
    }()

    let borderControlPoints: (start: CGPoint, end: CGPoint) = {
        let borderTangentScale: CGFloat = 1.0
        let normalDirectionFactor: CGFloat = bendFactor < 0.0 ? -1.0 : 1.0

        let startTangent = normal.scaled(by: normalDirectionFactor).adding(direction.scaled(by: specialCurveScale)).scaled(by: normalDirectionFactor)
        let endTangent = normal.scaled(by: normalDirectionFactor).subtracting(direction.scaled(by: specialCurveScale)).scaled(by: normalDirectionFactor)

        return (from.adding(startTangent.scaled(by: bendFactor)), to.adding(endTangent.scaled(by: bendFactor)))
    }()


    let farMidControlPoints: (start: CGPoint, end: CGPoint) = {
        let normalDirectionFactor: CGFloat = bendFactor < 0.0 ? -1.0 : 1.0
        return (start: middlePoints.far.adding(direction.scaled(by: bendFactor*normalDirectionFactor*midControlPointsScale)),
                end: middlePoints.far.adding(direction.scaled(by: -bendFactor*normalDirectionFactor*midControlPointsScale)))
    }()
    let nearMidControlPoints: (start: CGPoint, end: CGPoint) = {
        let normalDirectionFactor: CGFloat = bendFactor < 0.0 ? -1.0 : 1.0
        return (start: middlePoints.near.adding(direction.scaled(by: -bendFactor*normalDirectionFactor*midControlPointsScale)),
                end: middlePoints.near.adding(direction.scaled(by: bendFactor*normalDirectionFactor*midControlPointsScale)))
    }()

    if showDebug {
        func line(_ a: CGPoint, _ b: CGPoint) -> UIBezierPath {
            let path = UIBezierPath()
            path.move(to: a)
            path.addLine(to: b)
            path.lineWidth = 1
            return path
        }

        let debugAlpha: CGFloat = 0.3

        UIColor.green.withAlphaComponent(debugAlpha).setFill()
        UIColor.green.withAlphaComponent(debugAlpha).setStroke()
        line(from, borderControlPoints.start).stroke()
        line(to, borderControlPoints.end).stroke()
        UIBezierPath(arcCenter: borderControlPoints.start, radius: 3.0, startAngle: 0.0, endAngle: .pi*2.0, clockwise: true).fill()
        UIBezierPath(arcCenter: borderControlPoints.end, radius: 3.0, startAngle: 0.0, endAngle: .pi*2.0, clockwise: true).fill()



        UIColor.red.withAlphaComponent(debugAlpha).setFill()
        UIColor.red.withAlphaComponent(debugAlpha).setStroke()
        line(middlePoints.near, nearMidControlPoints.start).stroke()
        UIBezierPath(arcCenter: nearMidControlPoints.start, radius: 3.0, startAngle: 0.0, endAngle: .pi*2.0, clockwise: true).fill()

        UIColor.cyan.withAlphaComponent(debugAlpha).setFill()
        UIColor.cyan.withAlphaComponent(debugAlpha).setStroke()
        line(middlePoints.far, farMidControlPoints.start).stroke()
        UIBezierPath(arcCenter: farMidControlPoints.start, radius: 3.0, startAngle: 0.0, endAngle: .pi*2.0, clockwise: true).fill()

        UIColor.yellow.withAlphaComponent(debugAlpha).setFill()
        UIColor.yellow.withAlphaComponent(debugAlpha).setStroke()
        line(middlePoints.near, nearMidControlPoints.end).stroke()
        UIBezierPath(arcCenter: nearMidControlPoints.end, radius: 3.0, startAngle: 0.0, endAngle: .pi*2.0, clockwise: true).fill()

        UIColor.purple.withAlphaComponent(debugAlpha).setFill()
        UIColor.purple.withAlphaComponent(debugAlpha).setStroke()
        line(middlePoints.far, farMidControlPoints.end).stroke()
        UIBezierPath(arcCenter: farMidControlPoints.end, radius: 3.0, startAngle: 0.0, endAngle: .pi*2.0, clockwise: true).fill()
    }


    let path = UIBezierPath()

    path.move(to: from)
    path.addCurve(to: middlePoints.near,
                  controlPoint1: borderControlPoints.start,
                  controlPoint2: nearMidControlPoints.start)
    path.addCurve(to: to,
                  controlPoint1: nearMidControlPoints.end,
                  controlPoint2: borderControlPoints.end)

    path.addCurve(to: middlePoints.far,
                  controlPoint1: borderControlPoints.end,
                  controlPoint2: farMidControlPoints.start)
    path.addCurve(to: from,
                  controlPoint1: farMidControlPoints.end,
                  controlPoint2: borderControlPoints.start)

    path.close()
    return path
}

I extended CGPoint a bit just for readability. Some of these methods may not make sense in general so I would not expose it more then fileprivate .

fileprivate extension CGPoint {

    var length: CGFloat { return sqrt(x*x + y*y) }
    var normal: CGPoint { return CGPoint(x: y, y: -x) }
    func scaled(by factor: CGFloat) -> CGPoint { return CGPoint(x: x*factor, y: y*factor) }
    func adding(_ point: CGPoint) -> CGPoint { return CGPoint(x: x+point.x, y: y+point.y) }
    func subtracting(_ point: CGPoint) -> CGPoint { return CGPoint(x: x-point.x, y: y-point.y) }
    func direction(toward point: CGPoint) -> CGPoint { return point.subtracting(self) }
    var normalized: CGPoint {
        let distance = length
        return distance > 0.0 ? scaled(by: 1.0/distance) : .zero
    }

}

There are 2 factors at the start of the method which may nicely control the shape, you can play with them (I added 2 sliders with values [0.0, 2.0] to style it). Also I left debug part in it which is really helpful when positioning control points.

It would be very nice to also have rounded corners but from the current code I am not sure I would be able to achieve that.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM