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:
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:
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:
And here's the result with the triangle radius increased to 10. Note the triangle width is still the same.
The math.
Let's start with a diagram.
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.