swiftcgpath

How to make arrow on bubble reach all the way to enclosing rectangle


So I created a demo project to answer a question here on SO about how to draw a bubble shape with a rounded-corner rectangle on the side as a pointer (sort of like a chat bubble.)

The code creates shapes like this:

enter image description here

It uses the CGMutablePath method addArc(tangent1End:tangent2End:radius:transform:) to draw the rounded corner of the pointer rectangle.

I added a border around the shape layer's bounds so you can see the limits of the shape.

Ideally, the rounded corner of the triangle on the left should go all the way to the left edge of the bounds. I can't figure out the math to calculate how far the rounded corner cuts off the tip of the triangle's rounded corner.

If I knew how to calculate the amount that gets cut off the rounded corner, I could shift the whole left edge of the shape over by that amount and make the rounded corner go right up to the left edge of the bounding rectangle.

Here is the code that generates the path:

var path: CGPath  {
    let cgPath = CGMutablePath()
    
    cgPath.move(to: CGPoint(x: triangleWidth, y: shapeLayer.bounds.height/2 + triangleHeight/2))
    cgPath.addArc(center: CGPoint(x: triangleWidth + radius, y: shapeLayer.bounds.height - radius),
                  radius: radius,
                  startAngle: CGFloat.pi,
                  endAngle: CGFloat.pi / 2,
                  clockwise: true,
                  transform: .identity)
    
    cgPath.addArc(center: CGPoint(x: shapeLayer.bounds.width - radius, y: shapeLayer.bounds.height - radius),
                  radius: radius,
                  startAngle: CGFloat.pi / 2,
                  endAngle: 0,
                  clockwise: true,
                  transform: .identity)
    cgPath.addArc(center: CGPoint(x: shapeLayer.bounds.width - radius, y: radius),
                  radius: radius,
                  startAngle: 0,
                  endAngle: 3 * CGFloat.pi / 2,
                  clockwise: true,
                  transform: .identity)
    
    cgPath.addArc(center: CGPoint(x: triangleWidth + radius, y: radius),
                  radius: radius,
                  startAngle: 3 * CGFloat.pi / 2,
                  endAngle: CGFloat.pi,
                  clockwise: true,
                  transform: .identity)
    cgPath.addLine(to: CGPoint(x: triangleWidth, y: shapeLayer.bounds.height / 2 - triangleHeight / 2))
    
    cgPath.addArc(tangent1End: CGPoint(x: 0, y: shapeLayer.bounds.height / 2), tangent2End: CGPoint(x: triangleWidth, y: shapeLayer.bounds.height / 2 + triangleHeight / 2), radius: triangleRadius)
    cgPath.addLine(to: CGPoint(x: triangleWidth, y: shapeLayer.bounds.height / 2 + triangleHeight / 2))

    return cgPath
}

(The very last arc is the one that creates the rounded corner of the rectangle.)

I'd like a general-purpose solution that works for any triangle hight, width, and corner radius, but I can't figure out the trig that would let me calculate the amount the triangle gets clipped.

Here's the whole class:

//
//  CustomBubbleView.swift
//  CustomBubbleView
//
//  Created by Duncan Champney on 11/21/23.
//

import UIKit
import CoreGraphics

class CustomBubbleView: UIView {
    
    let radius: CGFloat = 10.0
    let triangleRadius = 5.0
    let triangleWidth: CGFloat = 30
    let triangleHeight: CGFloat = 30


    var shapeLayer = CAShapeLayer()
    
    override var bounds: CGRect {
        didSet {
            print("In didSet")
            shapeLayer.frame = bounds
//            shapeLayer.frame = bounds.insetBy(dx: 10, dy: 10)
            shapeLayer.path = path
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    
    private func commonInit() {
        // Custom initialization
        backgroundColor = UIColor(red: 0.95, green: 0.95, blue: 0.95, alpha: 1)
        layer.borderWidth = 1
        layer.borderColor = UIColor.blue.cgColor
        layer.addSublayer(shapeLayer)
        shapeLayer.fillColor = UIColor.white.cgColor
        shapeLayer.strokeColor = UIColor.lightGray.cgColor
        shapeLayer.lineWidth = 2
    }
    
    var path: CGPath  {
        let cgPath = CGMutablePath()
        
        cgPath.move(to: CGPoint(x: triangleWidth, y: shapeLayer.bounds.height/2 + triangleHeight/2))
        cgPath.addArc(center: CGPoint(x: triangleWidth + radius, y: shapeLayer.bounds.height - radius),
                      radius: radius,
                      startAngle: CGFloat.pi,
                      endAngle: CGFloat.pi / 2,
                      clockwise: true,
                      transform: .identity)
        
        cgPath.addArc(center: CGPoint(x: shapeLayer.bounds.width - radius, y: shapeLayer.bounds.height - radius),
                      radius: radius,
                      startAngle: CGFloat.pi / 2,
                      endAngle: 0,
                      clockwise: true,
                      transform: .identity)
        cgPath.addArc(center: CGPoint(x: shapeLayer.bounds.width - radius, y: radius),
                      radius: radius,
                      startAngle: 0,
                      endAngle: 3 * CGFloat.pi / 2,
                      clockwise: true,
                      transform: .identity)
        
        cgPath.addArc(center: CGPoint(x: triangleWidth + radius, y: radius),
                      radius: radius,
                      startAngle: 3 * CGFloat.pi / 2,
                      endAngle: CGFloat.pi,
                      clockwise: true,
                      transform: .identity)
        cgPath.addLine(to: CGPoint(x: triangleWidth, y: shapeLayer.bounds.height / 2 - triangleHeight / 2))
        
        cgPath.addArc(tangent1End: CGPoint(x: 0, y: shapeLayer.bounds.height / 2), tangent2End: CGPoint(x: triangleWidth, y: shapeLayer.bounds.height / 2 + triangleHeight / 2), radius: triangleRadius)
        cgPath.addLine(to: CGPoint(x: triangleWidth, y: shapeLayer.bounds.height / 2 + triangleHeight / 2))

        return cgPath
    }
     /*
    override func draw(_ rect: CGRect) {
        let radius: CGFloat = 10
        let triangleWidth: CGFloat = 20
        let triangleHeight: CGFloat = 20

        // Create the path for the triangle and the rounded rectangle
        let path = UIBezierPath()

        // Start with the triangle part
        path.move(to: CGPoint(x: triangleWidth, y: rect.height / 2 - triangleHeight / 2))
        path.addLine(to: CGPoint(x: 0, y: rect.height / 2))
        path.addLine(to: CGPoint(x: triangleWidth, y: rect.height / 2 + triangleHeight / 2))

        // Continue with the rounded rectangle part
        path.addLine(to: CGPoint(x: triangleWidth, y: rect.height - radius))
        path.addArc(withCenter: CGPoint(x: triangleWidth + radius, y: rect.height - radius),
                    radius: radius,
                    startAngle: CGFloat.pi,
                    endAngle: CGFloat.pi / 2,
                    clockwise: false)
        path.addLine(to: CGPoint(x: rect.width - radius, y: rect.height))
        path.addArc(withCenter: CGPoint(x: rect.width - radius, y: rect.height - radius),
                    radius: radius,
                    startAngle: CGFloat.pi / 2,
                    endAngle: 0,
                    clockwise: false)
        path.addLine(to: CGPoint(x: rect.width, y: radius))
        path.addArc(withCenter: CGPoint(x: rect.width - radius, y: radius),
                    radius: radius,
                    startAngle: 0,
                    endAngle: -CGFloat.pi / 2,
                    clockwise: false)
        path.addLine(to: CGPoint(x: triangleWidth + radius, y: 0))
        path.addArc(withCenter: CGPoint(x: triangleWidth + radius, y: radius),
                    radius: radius,
                    startAngle: -CGFloat.pi / 2,
                    endAngle: -CGFloat.pi,
                    clockwise: false)
        path.close()

        // Set the stroke color to gray
        UIColor.gray.setStroke()

        // Set the fill color to white
        UIColor.white.setFill()

        // Draw the filled path
        path.fill()

        // Draw the path with stroke
        path.stroke()
    }
*/
}

And the project on Github:

https://github.com/DuncanMC/CustomBubbleView.git


Solution

  • I was able to work out some algebra that allows the calculation needed to shift the arc for the tip of the triangle such that the curved tip is on the left edge of the shape while leaving the triangle the desired width.

    First the final code. An explanation of the math will follow.

    This working code goes back to overriding the draw method instead of using shape layer but the calculations to shift the arc for the triangle tip would be the same.

    class CustomBubbleView: UIView {
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
    
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
    
        private func commonInit() {
            // Custom initialization
            backgroundColor = .clear
        }
    
        func calculateShift(width: CGFloat, height: CGFloat, cornerRadius: CGFloat) -> CGFloat {
            let h = height / 2
            let w = width
            let r = cornerRadius
            guard r < h else { return 0 } // Ensure radius is less than half the height
    
            // a, b, and c are the coefficients of x^2, x, and the constant
            // for a standard polynomial in the form ax^2 + bx + c = 0
            let a = (h / r) * (h / r) - 1
            let b = (h / r) * (h / r) * 2 * r - 2 * w
            let c = w * w * -1
    
            //print(a, b, c)
            // Now work out the two possible values of x using the quadratic formula
            let x1 = (-b + sqrt(b * b - (4 * a * c))) / (2 * a)
            let x2 = (-b - sqrt(b * b - (4 * a * c))) / (2 * a)
    
            //print(x1, x2)
            // Select the positive result, if any
            if x1 >= 0 { return x1 }
            if x2 >= 0 { return x2 }
            return 0 // ???
        }
    
        override func draw(_ rect: CGRect) {
            let radius: CGFloat = 10 // Used for the 4 main corners of the bubble
            let triangleWidth: CGFloat = 30
            let triangleHeight: CGFloat = 30
            let triangleRadius: CGFloat = 5 // Used for the 3 corners of the triangle (must be less than half the triangle height
    
            let cgPath = CGMutablePath()
            // Start of the top-left rounded corner
            cgPath.move(to: CGPoint(x: triangleWidth, y: radius))
            // Top-left corner
            cgPath.addArc(center: CGPoint(x: triangleWidth + radius, y: radius), radius: radius, startAngle: .pi, endAngle: .pi * 3 / 2, clockwise: false)
            // Top-right corner
            cgPath.addArc(center: CGPoint(x: frame.width - radius, y: radius), radius: radius, startAngle: .pi * 3 / 2, endAngle: 0, clockwise: false)
            // Bottom-right corner
            cgPath.addArc(center: CGPoint(x: frame.width - radius, y: frame.height - radius), radius: radius, startAngle: 0, endAngle: .pi / 2, clockwise: false)
            // Bottom-left corner
            cgPath.addArc(center: CGPoint(x: triangleWidth + radius, y: frame.height - radius), radius: radius, startAngle: .pi / 2, endAngle: .pi, clockwise: false)
    
            // Calculate the needed shift for the "point" of the triangle so the actual rounded tip of the triangle is at the left edge of the view
            let shift = calculateShift(width: triangleWidth, height: triangleHeight, cornerRadius: triangleRadius)
            // Bottom-right corner of triangle
            cgPath.addArc(tangent1End: CGPoint(x: triangleWidth, y: (frame.height + triangleHeight) / 2), tangent2End: CGPoint(x: -shift, y: frame.height / 2), radius: triangleRadius)
            // Point of triangle
            cgPath.addArc(tangent1End: CGPoint(x: -shift, y: frame.height / 2), tangent2End: CGPoint(x: triangleWidth, y: (frame.height - triangleHeight) / 2), radius: triangleRadius)
            // Top-right corner of triangle
            cgPath.addArc(tangent1End: CGPoint(x: triangleWidth, y: (frame.height - triangleHeight) / 2), tangent2End: CGPoint(x: triangleWidth, y: radius), radius: triangleRadius)
            cgPath.closeSubpath()
    
            let path = UIBezierPath(cgPath: cgPath)
    
            // Set the stroke color to gray
            UIColor.gray.setStroke()
    
            // Set the fill color to white
            UIColor.white.setFill()
    
            // Draw the filled path
            path.fill()
    
            // Draw the path with stroke
            path.stroke()
        }
    }
    

    Use that class and the following code in a playground to see the result. Putting the bubble in a parent view makes it easier to verify the result while viewing it in the playground.

    let view = UIView(frame: .init(x: 0, y: 0, width: 300, height: 100))
    view.backgroundColor = .lightGray
    let bubble = CustomBubbleView(frame: view.bounds)
    view.addSubview(bubble)
    

    The result with a triangle radius of 5:

    enter image description here

    And here's the result with the triangle radius increased to 10. Note the triangle width is still the same.

    enter image description here


    The math.

    Let's start with a diagram.

    enter image description here

    The darker gray area represents the triangle rectangle.

    The variables:

    The diagram points (red dots):

    The final drawn bubble arrow will be points ahfgc.

    Let's focus on two triangles in the diagram: abd and ebg. If you rotate ebg around point b such that line eb is on line ab then line bg will be on line bd. This means the angles abd and ebg are the same. This means the two triangles are congruent.

    Triangle abd: side ad has length h. Side bd has length w + x.

    Triangle ebg: side eg has length r. Side be has length x + r. Thanks to Pythagoras we know side bg is sqrt(x^2 + 2xr).

    Since the triangles are congruent, the two long sides ab and be are proportional to the two short sides ad and eg. This gives us a formula of:

    sqrt(x^2 + 2xr) * (h / r) = w + x

    and we need to solve for x. We know r and w. After lots of fun algebra we end up with:

    ((h / r)^2 - 1) * x^2 + ((h / r)^2 * 2r - 2w) * x - w^2 = 0

    That leaves us with a quadratic formula where:

    a = (h / r) * (h / r) - 1
    b = (h / r) * (h / r) * 2 * r - 2 * w
    c = -w * w

    We then use the quadratic formula to obtain two possible values. The positive result is the desired value.