Search code examples
iosiphoneswiftuiviewuibezierpath

Drawing a custom view using UIBezierPath results in a non-symmetrical shape


I'm trying to draw an UIView with some 'curvy edges'.

Here's what it's supposed to look like:

enter image description here

here's what I got:

enter image description here

Notice how the top right (TR) corner is not symmetrical to the bottom right (BR) corner ? The BR corner is very similar to what I want to achieve but I can't get the TR corner to align correctly (played around with bunch of different start and end angles).

here's the code:

struct Constants {
    static let cornerRadius: CGFloat = 15.0 // used for left-top and left-bottom curvature
    static let rightTipWidth: CGFloat = 40.0 // the max. width for the right tip thingy
    static let rightCornerRadius: CGFloat = 10.0 // the radius for the right tip
    static let rightEdgeRadius: CGFloat = 10.0 // the radius for the top right and bottom right curvature
}
    override func draw(_ rect: CGRect) {
    super.draw(rect)

    // Initialize the path.
    let path = UIBezierPath()

    // starting point
    let startingPoint = CGPoint(x: Constants.cornerRadius, y: 0.0)
    path.move(to: startingPoint)

    // create a center point for the arc for the top left corner
    let leftTopCircleCenterPoint = CGPoint(x: Constants.cornerRadius, y: Constants.cornerRadius)
    path.addArc(withCenter: leftTopCircleCenterPoint, radius: Constants.cornerRadius, startAngle: 270.degreesToRadians, endAngle: 180.degreesToRadians, clockwise: false)

    // move the path to the bottom left corner
    path.addLine(to: CGPoint(x: 0.0, y: frame.size.height - Constants.cornerRadius))

    // add the arc to bottom left
    let leftBottomCircleCenterPoint = CGPoint(x: Constants.cornerRadius, y: frame.size.height - Constants.cornerRadius)
    path.addArc(withCenter: leftBottomCircleCenterPoint, radius: Constants.cornerRadius, startAngle: 180.degreesToRadians, endAngle: 90.degreesToRadians, clockwise: false)

    // move along the bottom to the right edge - rightTipWidth
    let maxXRightEdge = frame.size.width - Constants.rightTipWidth
    path.addLine(to: CGPoint(x: maxXRightEdge, y: frame.size.height))

    // add a curve at the bottom before tipping up at 45 degrees
    let bottomRightEdgeControlPoint = CGPoint(x: maxXRightEdge, y: frame.size.height - Constants.rightEdgeRadius)
    path.addArc(withCenter: bottomRightEdgeControlPoint, radius: Constants.rightEdgeRadius, startAngle: 90.degreesToRadians, endAngle: 45.degreesToRadians, clockwise: false)

    // figure out the center for the right side curvature
    let rightMidPointY = frame.size.height / 2.0
    let halfRadius = (Constants.rightCornerRadius / 2.0)

    // move up till the mid point corner radius
    path.addLine(to: CGPoint(x: frame.size.width - Constants.rightCornerRadius, y: (rightMidPointY + halfRadius)))

    // the destination for the curve (end point of the curve)
    let rightEndPoint = CGPoint(x: frame.size.width - Constants.rightCornerRadius, y: (rightMidPointY - halfRadius))

    // figure out the right side tip's control point (See: https://developer.apple.com/documentation/uikit/uibezierpath/1624351-addquadcurve)
    let rightControlPoint = CGPoint(x: frame.size.width - halfRadius, y: rightMidPointY)

    // add the curve for the right side tip
    path.addQuadCurve(to: rightEndPoint, controlPoint: rightControlPoint)

    // move up at 45 degrees
    path.addLine(to: CGPoint(x: maxXRightEdge + Constants.rightEdgeRadius, y: Constants.rightEdgeRadius))

    let topRightEdgeControlPoint = CGPoint(x: maxXRightEdge, y: Constants.rightEdgeRadius)
    path.addArc(withCenter: topRightEdgeControlPoint, radius: Constants.rightEdgeRadius, startAngle: 315.degreesToRadians, endAngle: 270.degreesToRadians, clockwise: false) // straight

    path.close()

    // Specify the fill color and apply it to the path.
    UIColor.orange.setFill()
    path.fill()

    // Specify a border (stroke) color.
    UIColor.orange.setStroke()
    path.stroke()
}

extension BinaryInteger {
    var degreesToRadians: CGFloat { return CGFloat(Int(self)) * .pi / 180 }
}

Just a quick summary of my thought process:

  • Create a bezierPath and move it to the startingPoint
  • Add the LT (left-top) curve and move the line downards
  • Move the line along the left edge and add the LB (left-bottom) curve and the move line along the bottom to the right edge
  • Move the line till frame.size.width - Constants.rightTipWidth
  • Add an arc with a center point at x = currentPoint and y = height- rightEdgeRadius
  • Move the line up until y = (height / 2.0) + (Constants.rightCornerRadius / 2.0)
  • Add the QuadCurve with an end point of y = (height / 2.0) - (Constants.rightCornerRadius / 2.0)
  • Move the line up till x = maxXRightEdge + Constants.rightEdgeRadius
  • Add the top right (TR) curve ---> resulting in a non-symmetrical curvature

Solution

  • Here is another rendition:

    @IBDesignable
    open class PointerView: UIView {
    
        /// The left-top and left-bottom curvature
        @IBInspectable var cornerRadius: CGFloat      = 15      { didSet { updatePath() } }
    
        /// The radius for the right tip
        @IBInspectable var rightCornerRadius: CGFloat = 10      { didSet { updatePath() } }
    
        /// The radius for the top right and bottom right curvature
        @IBInspectable var rightEdgeRadius: CGFloat   = 10      { didSet { updatePath() } }
    
        /// The fill color
        @IBInspectable var fillColor: UIColor         = .blue   { didSet { shapeLayer.fillColor = fillColor.cgColor } }
    
        /// The stroke color
        @IBInspectable var strokeColor: UIColor       = .clear  { didSet { shapeLayer.strokeColor = strokeColor.cgColor } }
    
        /// The angle of the tip
        @IBInspectable var angle: CGFloat             = 90      { didSet { updatePath() } }
    
        /// The line width
        @IBInspectable var lineWidth: CGFloat         = 0       { didSet { updatePath() } }
    
        /// The shape layer for the pointer
        private lazy var shapeLayer: CAShapeLayer = {
            let _shapeLayer = CAShapeLayer()
            _shapeLayer.fillColor = fillColor.cgColor
            _shapeLayer.strokeColor = strokeColor.cgColor
            _shapeLayer.lineWidth = lineWidth
            return _shapeLayer
        }()
    
        public override init(frame: CGRect) {
            super.init(frame: frame)
    
            configure()
        }
    
        public required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
    
            configure()
        }
    
        private func configure() {
            layer.addSublayer(shapeLayer)
        }
    
        open override func layoutSubviews() {
            super.layoutSubviews()
    
            updatePath()
        }
    
        private func updatePath() {
            let path = UIBezierPath()
    
            let offset = lineWidth / 2
            let boundingRect = bounds.insetBy(dx: offset, dy: offset)
    
            let arrowTop = CGPoint(x: boundingRect.maxX - boundingRect.height / 2 / tan(angle * .pi / 180 / 2), y: boundingRect.minY)
            let arrowRight = CGPoint(x: boundingRect.maxX, y: boundingRect.midY)
            let arrowBottom = CGPoint(x: boundingRect.maxX - boundingRect.height / 2 / tan(angle * .pi / 180 / 2), y: boundingRect.maxY)
            let start = CGPoint(x: boundingRect.minX + cornerRadius, y: boundingRect.minY)
    
            // top left
            path.move(to: start)
            path.addQuadCurve(to: CGPoint(x: boundingRect.minX, y: boundingRect.minY + cornerRadius), controlPoint: CGPoint(x: boundingRect.minX, y: boundingRect.minY))
    
            // left
            path.addLine(to: CGPoint(x: boundingRect.minX, y: boundingRect.maxY - cornerRadius))
    
            // lower left
            path.addQuadCurve(to: CGPoint(x: boundingRect.minX + cornerRadius, y: boundingRect.maxY), controlPoint: CGPoint(x: boundingRect.minX, y: boundingRect.maxY))
    
            // bottom
            path.addLine(to: calculate(from: path.currentPoint, to: arrowBottom, less: rightEdgeRadius))
    
            // bottom right (before tip)
            path.addQuadCurve(to: calculate(from: arrowRight, to: arrowBottom, less: rightEdgeRadius), controlPoint: arrowBottom)
    
            // bottom edge of tip
            path.addLine(to: calculate(from: path.currentPoint, to: arrowRight, less: rightCornerRadius))
    
            // tip
            path.addQuadCurve(to: calculate(from: arrowTop, to: arrowRight, less: rightCornerRadius), controlPoint: arrowRight)
    
            // top edge of tip
            path.addLine(to: calculate(from: path.currentPoint, to: arrowTop, less: rightEdgeRadius))
    
            // top right (after tip)
            path.addQuadCurve(to: calculate(from: start, to: arrowTop, less: rightEdgeRadius), controlPoint: arrowTop)
    
            path.close()
    
            shapeLayer.lineWidth = lineWidth
            shapeLayer.path = path.cgPath
        }
    
        /// Calculate some point between `startPoint` and `endPoint`, but `distance` from `endPoint
        ///
        /// - Parameters:
        ///   - startPoint: The starting point.
        ///   - endPoint: The ending point.
        ///   - distance: Distance from the ending point
        /// - Returns: Returns the point that is `distance` from the `endPoint` as you travel from `startPoint` to `endPoint`.
    
        private func calculate(from startPoint: CGPoint, to endPoint: CGPoint, less distance: CGFloat) -> CGPoint {
            let angle = atan2(endPoint.y - startPoint.y, endPoint.x - startPoint.x)
            let totalDistance = hypot(endPoint.y - startPoint.y, endPoint.x - startPoint.x) - distance
    
            return CGPoint(x: startPoint.x + totalDistance * cos(angle),
                           y: startPoint.y + totalDistance * sin(angle))
        }
    }
    

    And because that is @IBDesignable, I can put it in a separate framework target and then optionally use it (and customize it) right in Interface Builder:

    enter image description here

    The only change I made in parameters was to not use the width of the tip, but rather the angle of the tip. That way, if the size changes as constraints (or whatever) change, it preserves the desired shape.

    I also changed this to use a CAShapeLayer rather that a custom draw(_:) method to enjoy any efficiencies that Apple has built in to shape layers.