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

100 Views Asked by At

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

2

There are 2 best solutions below

2
HangarRash On BEST ANSWER

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:

  • w triangle width
  • h half the triangle height (so 2h is the full height)
  • r triangle curve radius
  • x The offset we need to calculate

The diagram points (red dots):

  • a The start of the triangle
  • b The calculated tip of the triangle
  • c The end of the triangle
  • d Part of the midline of the triangle area
  • e The center of the desired arc for the triangle tip
  • f The desired apex of the rounded triangle tip
  • g The start of the rounded triangle tip arc (tangent point)
  • h The end of the rounded triangle tip arc (tangent point)

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.

5
Sweeper On

The math for this can be found in this Math.SE post. From that post, you can find the distance between the centre of the arc and the intersection point of the tangents (called QA in that post). Subtracting the triangle radius from that is the amount you need to shift.

QA is equal to the radius divided by sin(alpha/2), where alpha is the angle that the tangents make.

func calculateShift(width: CGFloat, height: CGFloat, cornerRadius: CGFloat) -> CGFloat {
    // here the CGSizes represent 2D vectors
    let v1 = CGSize(width: -width, height: height / 2)
    let v2 = CGSize(width: -width, height: -height / 2)
    let alpha = angle(between: v1, and: v2)
    let qa = cornerRadius / sin(alpha / 2)
    return qa - cornerRadius
}

func cross(_ v1: CGSize, _ v2: CGSize) -> CGFloat {
    v1.width * v2.height - v1.height * v2.width
}

func dot(_ v1: CGSize, _ v2: CGSize) -> CGFloat {
    v1.width * v2.width + v1.height * v2.height
}

func angle(between v1: CGSize, and v2: CGSize) -> CGFloat {
    atan2(cross(v1, v2), dot(v1, v2))
}

Then the path can be shifted to the left easily:

let cgPath = CGMutablePath()
let shiftAmount = calculateShift(width: triangleWidth, height: triangleHeight, cornerRadius: triangleRadius)
cgPath.move(to: CGPoint(x: triangleWidth - shiftAmount, y: shapeLayer.bounds.height/2 + triangleHeight/2))
cgPath.addArc(center: CGPoint(x: triangleWidth + radius - shiftAmount, 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 - shiftAmount, y: radius),
              radius: radius,
              startAngle: 3 * CGFloat.pi / 2,
              endAngle: CGFloat.pi,
              clockwise: true,
              transform: .identity)
cgPath.addLine(to: CGPoint(x: triangleWidth - shiftAmount, y: shapeLayer.bounds.height / 2 - triangleHeight / 2))

cgPath.addArc(
    tangent1End: CGPoint(x: -shiftAmount, y: shapeLayer.bounds.height / 2),
    tangent2End: CGPoint(x: triangleWidth - shiftAmount, y: shapeLayer.bounds.height / 2 + triangleHeight / 2),
    radius: triangleRadius
)
cgPath.addLine(to: CGPoint(x: triangleWidth - shiftAmount, y: shapeLayer.bounds.height / 2 + triangleHeight / 2))

return cgPath

Alternatively, you can just shift the x coordinate of tangent1End - move it to somewhere such that the actual drawn triangle has width triangleWidth.

For that we need to solve for newWidth in the equation

newWidth - (cornerRadius / sin(alpha / 2) - cornerRadius) - triangleWidth = 0

where newWidth - triangleWidth is the amount we want to shift tangent1End to the left, and alpha is the angle between the new tangents.

I doubt this can be solved analytically. You can use a solver like Numerical to solve this numerically:

func calculateNewWidth(width: CGFloat, height: CGFloat, cornerRadius: CGFloat) -> CGFloat {
    let f: (CGFloat) -> CGFloat = { nw in
        let v1 = CGSize(width: -nw, height: height / 2)
        let v2 = CGSize(width: -nw, height: -height / 2)
        let alpha = angle(between: v1, and: v2)
        return nw - (cornerRadius / sin(alpha / 2) - cornerRadius) - width
    }
    let result = root(guess: width, xmin: width, tolerance: .init(absolute: 0.001)) { Double(f(Double($0))) }
    guard case let .success(_, _, root) = result else { fatalError() }
    return (root.a + root.b) / 2
}
// when drawing the last arc...
let extraWidth = calculateNewWidth(
    width: triangleWidth, 
    height: triangleHeight, 
    cornerRadius: triangleRadius
) - triangleWidth
cgPath.addArc(
    tangent1End: CGPoint(x: -extraWidth, y: shapeLayer.bounds.height / 2),
    tangent2End: CGPoint(x: triangleWidth, y: shapeLayer.bounds.height / 2 + triangleHeight / 2),
    radius: triangleRadius
)

Note that it is not physically possible to draw a rounded corner, if the corner radius is less than half go the triangle's height. You might want to use min(triangleHeight / 2, triangleRadius) as the radius instead.