Skip to content

Instantly share code, notes, and snippets.

@SergLam
Created January 11, 2021 22:04
Show Gist options
  • Save SergLam/3d662ef26f8be69ff90e9db6604f3efc to your computer and use it in GitHub Desktop.
Save SergLam/3d662ef26f8be69ff90e9db6604f3efc to your computer and use it in GitHub Desktop.
Bubble Triangle View in Swift for iOS
import UIKit
final class BubbleTriangleView: UIView {
private var viewBorderWidth: CGFloat = 3
private var radius: CGFloat = 15
private var viewFillColor: UIColor = .supAzure
private var viewBorderColor: UIColor = .white
private var bubbleLayer: CAShapeLayer = CAShapeLayer()
private var triangleLayer: CAShapeLayer = CAShapeLayer()
private var triangleCornerRadius: CGFloat = 1.0
private var position: BubbleTriangleViewTrianglePosition = .topLeft(offset: 15.0, border: BubbleTriangleViewTriangleBorderStroke(startPoint: 0.0, endPoint: 0.0))
private var type: BubbleTriangleViewTriangleType = .rectangular(largeLeg: 30, alphaAngle: 40)
// MARK: - Life cycle
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
override func layoutSubviews() {
super.layoutSubviews()
applyBublePath()
}
// MARK: - Public functions
func update(position: BubbleTriangleViewTrianglePosition,
type: BubbleTriangleViewTriangleType) {
self.position = position
self.type = type
self.setNeedsDisplay()
self.setNeedsLayout()
}
func updateFillColor(_ color: UIColor) {
viewFillColor = color
}
func updateBorderWidth(_ width: CGFloat) {
viewBorderWidth = width
}
// MARK: - Private functions
private func applyBublePath() {
triangleLayer.removeFromSuperlayer()
bubbleLayer.removeFromSuperlayer()
bubbleLayer.path = bubblePathFor(trianglePosition: position, of: type).cgPath
bubbleLayer.fillColor = viewFillColor.cgColor
bubbleLayer.strokeColor = viewBorderColor.cgColor
bubbleLayer.strokeStart = 0.0
bubbleLayer.strokeEnd = 1.0
bubbleLayer.lineWidth = viewBorderWidth
bubbleLayer.position = CGPoint.zero
self.layer.insertSublayer(bubbleLayer, at: 0)
triangleLayer.path = trianglePathFor(trianglePosition: position, of: type).cgPath
triangleLayer.fillColor = viewFillColor.cgColor
triangleLayer.strokeColor = viewBorderColor.cgColor
triangleLayer.strokeStart = position.strokeStart
triangleLayer.strokeEnd = position.strokeEnd
triangleLayer.lineWidth = viewBorderWidth
triangleLayer.lineCap = .round
triangleLayer.frame = CGRect(origin: CGPoint(x: CGPoint.zero.x,
y: CGPoint.zero.y + viewBorderWidth),
size: CGSize(width: self.bounds.width, height: viewBorderWidth + type.largeLeg))
self.layer.insertSublayer(triangleLayer, above: bubbleLayer)
}
// MARK: - Draw triangle
private func trianglePathFor(trianglePosition: BubbleTriangleViewTrianglePosition,
of type: BubbleTriangleViewTriangleType) -> UIBezierPath {
let path = UIBezierPath()
switch position {
case .topLeft(let offset, _ ):
switch type {
case .equiangular:
break
case .rectangular(let largeLeg, _):
let rect = CGRect(origin: .zero, size: bounds.size)
let rectOrigin: CGPoint = CGPoint(x: rect.minX + viewBorderWidth,
y: rect.minY + viewBorderWidth + largeLeg)
let trianglePath = UIBezierPath()
let triangleLeftPoint: CGPoint = CGPoint(x: rectOrigin.x + offset,
y: rectOrigin.y - viewBorderWidth / 2)
trianglePath.move(to: triangleLeftPoint)
let triangleTopPoint: CGPoint = CGPoint(x: triangleLeftPoint.x,
y: triangleLeftPoint.y - largeLeg - triangleCornerRadius)
trianglePath.addLine(to: triangleTopPoint)
let topCornerArcCenter: CGPoint = CGPoint(x: triangleTopPoint.x + triangleCornerRadius, y: triangleTopPoint.y)
trianglePath.addArc(withCenter: topCornerArcCenter, radius: triangleCornerRadius, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 1.8, clockwise: true)
let triangleRightPoint: CGPoint = CGPoint(x: triangleLeftPoint.x + type.smallLeg + triangleCornerRadius,
y: triangleLeftPoint.y + viewBorderWidth / 2)
trianglePath.addLine(to: triangleRightPoint)
trianglePath.close()
return trianglePath
}
case .topMid:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .topRight(let offset, _ ):
switch type {
case .equiangular:
break
case .rectangular(let largeLeg, _):
let rect = CGRect(origin: .zero, size: bounds.size)
let rectOrigin: CGPoint = CGPoint(x: rect.maxX - type.smallLeg - triangleCornerRadius - offset,
y: rect.minY + viewBorderWidth)
let trianglePath = UIBezierPath()
let triangleLeftPoint: CGPoint = CGPoint(x: rectOrigin.x,
y: rectOrigin.y + largeLeg - viewBorderWidth / 2)
trianglePath.move(to: triangleLeftPoint)
let triangleTopPoint: CGPoint = CGPoint(x: triangleLeftPoint.x + type.smallLeg + triangleCornerRadius,
y: rectOrigin.y)
trianglePath.addLine(to: triangleTopPoint)
let topCornerArcCenter: CGPoint = CGPoint(x: triangleTopPoint.x + triangleCornerRadius, y: triangleTopPoint.y)
trianglePath.addArc(withCenter: topCornerArcCenter, radius: triangleCornerRadius, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 1.8, clockwise: true)
let triangleRightPoint: CGPoint = CGPoint(x: topCornerArcCenter.x,
y: triangleLeftPoint.y)
trianglePath.addLine(to: triangleRightPoint)
trianglePath.close()
return trianglePath
}
case .leftTop:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .leftMid:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .leftBottom:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .rigthTop:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .rightMid:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .rigthBottom:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .bottomLeft(let offset, _ ):
switch type {
case .equiangular:
break
case .rectangular(let largeLeg, _):
let rect = CGRect(origin: .zero, size: bounds.size)
let rectOrigin: CGPoint = CGPoint(x: rect.minX,
y: rect.maxY)
let trianglePath = UIBezierPath()
let triangleLeftPoint: CGPoint = CGPoint(x: rectOrigin.x + offset,
y: rectOrigin.y - largeLeg - (viewBorderWidth * 2) - triangleCornerRadius * 2)
trianglePath.move(to: triangleLeftPoint)
let triangleTopPoint: CGPoint = CGPoint(x: triangleLeftPoint.x,
y: rectOrigin.y - viewBorderWidth)
trianglePath.addLine(to: triangleTopPoint)
let topCornerArcCenter: CGPoint = CGPoint(x: triangleTopPoint.x + triangleCornerRadius, y: triangleTopPoint.y)
trianglePath.addArc(withCenter: topCornerArcCenter, radius: triangleCornerRadius, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 0.05, clockwise: false)
let triangleRightPoint: CGPoint = CGPoint(x: triangleLeftPoint.x + type.smallLeg + triangleCornerRadius,
y: triangleLeftPoint.y)
trianglePath.addLine(to: triangleRightPoint)
trianglePath.close()
return trianglePath
}
case .bottomMid:
switch type {
case .equiangular(let side):
let rect = CGRect(origin: .zero, size: bounds.size)
let rectOrigin: CGPoint = CGPoint(x: rect.midX,
y: rect.maxY)
let trianglePath = UIBezierPath()
let triangleLeftPoint: CGPoint = CGPoint(x: rectOrigin.x - type.hipotenuseSize / 2,
y: rectOrigin.y - side - (viewBorderWidth * 2) - triangleCornerRadius * 2)
trianglePath.move(to: triangleLeftPoint)
let triangleTopPoint: CGPoint = CGPoint(x: triangleLeftPoint.x + (type.hipotenuseSize / 2),
y: rectOrigin.y - viewBorderWidth)
trianglePath.addLine(to: triangleTopPoint)
let triangleRightPoint: CGPoint = CGPoint(x: rectOrigin.x + type.hipotenuseSize / 2,
y: triangleLeftPoint.y)
trianglePath.addLine(to: triangleRightPoint)
trianglePath.close()
return trianglePath
case .rectangular:
break
}
case .bottomRight(let offset, _):
switch type {
case .equiangular:
break
case .rectangular(let largeLeg, _ ):
let rect = CGRect(origin: .zero, size: bounds.size)
let rectOrigin: CGPoint = CGPoint(x: rect.maxY,
y: rect.maxY)
let trianglePath = UIBezierPath()
let triangleRightPoint: CGPoint = CGPoint(x: rectOrigin.x - offset,
y: rectOrigin.y - largeLeg - (viewBorderWidth * 2) - triangleCornerRadius * 2)
trianglePath.move(to: triangleRightPoint)
let triangleTopPoint: CGPoint = CGPoint(x: triangleRightPoint.x,
y: rectOrigin.y - viewBorderWidth)
trianglePath.addLine(to: triangleTopPoint)
let topCornerArcCenter: CGPoint = CGPoint(x: triangleTopPoint.x - triangleCornerRadius, y: triangleTopPoint.y)
trianglePath.addArc(withCenter: topCornerArcCenter, radius: triangleCornerRadius, startAngle: CGFloat.zero, endAngle: CGFloat.pi - (CGFloat.pi * 0.05), clockwise: true)
let triangleLeftPoint: CGPoint = CGPoint(x: triangleRightPoint.x - type.smallLeg - triangleCornerRadius,
y: triangleRightPoint.y)
trianglePath.addLine(to: triangleLeftPoint)
trianglePath.close()
return trianglePath
}
}
return path
}
// MARK: - Rounded rect path
private func bubblePathFor(trianglePosition: BubbleTriangleViewTrianglePosition,
of type: BubbleTriangleViewTriangleType) -> UIBezierPath {
let path = UIBezierPath()
switch position {
case .topLeft:
switch type {
case .equiangular:
break
case .rectangular(let largeLeg, _):
let rect = CGRect(origin: .zero, size: bounds.size)
let radiusSize: CGSize = CGSize(width: radius, height: radius)
let rectOrigin: CGPoint = CGPoint(x: rect.minX + viewBorderWidth,
y: rect.minY + viewBorderWidth + largeLeg)
let rectWidth: CGFloat = rect.width - (viewBorderWidth * 2)
let rectHeigth: CGFloat = rect.height - (viewBorderWidth * 2) - largeLeg
let innerRectSize: CGSize = CGSize(width: rectWidth, height: rectHeigth)
let innerRect: CGRect = CGRect(origin: rectOrigin,
size: innerRectSize)
let rectPath = UIBezierPath(roundedRect: innerRect, byRoundingCorners: UIRectCorner.allCorners, cornerRadii: radiusSize)
rectPath.close()
return rectPath
}
case .topMid:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .topRight:
switch type {
case .equiangular:
break
case .rectangular(let largeLeg, _):
let rect = CGRect(origin: .zero, size: bounds.size)
let radiusSize: CGSize = CGSize(width: radius, height: radius)
let rectOrigin: CGPoint = CGPoint(x: rect.minX + viewBorderWidth,
y: rect.minY + viewBorderWidth + largeLeg)
let rectWidth: CGFloat = rect.width - (viewBorderWidth * 2)
let rectHeigth: CGFloat = rect.height - (viewBorderWidth * 2) - largeLeg
let innerRectSize: CGSize = CGSize(width: rectWidth, height: rectHeigth)
let innerRect: CGRect = CGRect(origin: rectOrigin,
size: innerRectSize)
let rectPath = UIBezierPath(roundedRect: innerRect, byRoundingCorners: UIRectCorner.allCorners, cornerRadii: radiusSize)
rectPath.close()
return rectPath
}
case .leftTop:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .leftMid:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .leftBottom:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .rigthTop:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .rightMid:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .rigthBottom:
switch type {
case .equiangular:
break
case .rectangular:
break
}
case .bottomLeft:
switch type {
case .equiangular:
break
case .rectangular(let largeLeg, _):
let rect = CGRect(origin: .zero, size: bounds.size)
let radiusSize: CGSize = CGSize(width: radius, height: radius)
let rectOrigin: CGPoint = CGPoint(x: rect.minX + viewBorderWidth,
y: rect.minY + viewBorderWidth)
let rectWidth: CGFloat = rect.width - (viewBorderWidth * 2)
let rectHeigth: CGFloat = rect.height - (viewBorderWidth * 2) - largeLeg
let innerRectSize: CGSize = CGSize(width: rectWidth, height: rectHeigth)
let innerRect: CGRect = CGRect(origin: rectOrigin,
size: innerRectSize)
let rectPath = UIBezierPath(roundedRect: innerRect, byRoundingCorners: UIRectCorner.allCorners, cornerRadii: radiusSize)
rectPath.close()
return rectPath
}
case .bottomMid:
switch type {
case .equiangular(let side):
let rect = CGRect(origin: .zero, size: bounds.size)
let radiusSize: CGSize = CGSize(width: radius, height: radius)
let rectOrigin: CGPoint = CGPoint(x: rect.minX + viewBorderWidth,
y: rect.minY + viewBorderWidth)
let rectWidth: CGFloat = rect.width - (viewBorderWidth * 2)
let rectHeigth: CGFloat = rect.height - (viewBorderWidth * 2) - side
let innerRectSize: CGSize = CGSize(width: rectWidth, height: rectHeigth)
let innerRect: CGRect = CGRect(origin: rectOrigin,
size: innerRectSize)
let rectPath = UIBezierPath(roundedRect: innerRect, byRoundingCorners: UIRectCorner.allCorners, cornerRadii: radiusSize)
rectPath.close()
return rectPath
case .rectangular:
break
}
case .bottomRight:
switch type {
case .equiangular:
break
case .rectangular(let largeLeg, _):
let rect = CGRect(origin: .zero, size: bounds.size)
let radiusSize: CGSize = CGSize(width: radius, height: radius)
let rectOrigin: CGPoint = CGPoint(x: rect.minX + viewBorderWidth,
y: rect.minY + viewBorderWidth)
let rectWidth: CGFloat = rect.width - (viewBorderWidth * 2)
let rectHeigth: CGFloat = rect.height - (viewBorderWidth * 2) - largeLeg
let innerRectSize: CGSize = CGSize(width: rectWidth, height: rectHeigth)
let innerRect: CGRect = CGRect(origin: rectOrigin,
size: innerRectSize)
let rectPath = UIBezierPath(roundedRect: innerRect, byRoundingCorners: UIRectCorner.allCorners, cornerRadii: radiusSize)
rectPath.close()
return rectPath
}
}
return path
}
private func setup() {
isAccessibilityElement = true
}
}
import UIKit
typealias BubbleTriangleViewTriangleBorderStroke = (startPoint: CGFloat, endPoint: CGFloat)
enum BubbleTriangleViewTrianglePosition {
// NOTE: - Offset from the left side (start of coordinate system)
case topLeft(offset: CGFloat, border: BubbleTriangleViewTriangleBorderStroke)
case topMid(offset: CGFloat, border: BubbleTriangleViewTriangleBorderStroke)
case topRight(offset: CGFloat, border: BubbleTriangleViewTriangleBorderStroke)
case leftTop(offset: CGFloat, border: BubbleTriangleViewTriangleBorderStroke)
case leftMid(offset: CGFloat, border: BubbleTriangleViewTriangleBorderStroke)
case leftBottom(offset: CGFloat, border: BubbleTriangleViewTriangleBorderStroke)
// NOTE: - Offset from the left side (end of coordinate system)
case rigthTop(offset: CGFloat, border: BubbleTriangleViewTriangleBorderStroke)
case rightMid(offset: CGFloat, border: BubbleTriangleViewTriangleBorderStroke)
case rigthBottom(offset: CGFloat, border: BubbleTriangleViewTriangleBorderStroke)
case bottomLeft(offset: CGFloat, border: BubbleTriangleViewTriangleBorderStroke)
case bottomMid(offset: CGFloat, border: BubbleTriangleViewTriangleBorderStroke)
case bottomRight(offset: CGFloat, border: BubbleTriangleViewTriangleBorderStroke)
var strokeStart: CGFloat {
switch self {
case .topLeft(_, let border):
return border.startPoint
case .topMid(_, let border):
return border.startPoint
case .topRight(_, let border):
return border.startPoint
case .leftTop(_, let border):
return border.startPoint
case .leftMid(_, let border):
return border.startPoint
case .leftBottom(_, let border):
return border.startPoint
case .rigthTop(_, let border):
return border.startPoint
case .rightMid(_, let border):
return border.startPoint
case .rigthBottom(_, let border):
return border.startPoint
case .bottomLeft(_, let border):
return border.startPoint
case .bottomMid(_, let border):
return border.startPoint
case .bottomRight(_, let border):
return border.startPoint
}
}
var strokeEnd: CGFloat {
switch self {
case .topLeft(_, let border):
return border.endPoint
case .topMid(_, let border):
return border.endPoint
case .topRight(_, let border):
return border.endPoint
case .leftTop(_, let border):
return border.endPoint
case .leftMid(_, let border):
return border.endPoint
case .leftBottom(_, let border):
return border.endPoint
case .rigthTop(_, let border):
return border.endPoint
case .rightMid(_, let border):
return border.endPoint
case .rigthBottom(_, let border):
return border.endPoint
case .bottomLeft(_, let border):
return border.endPoint
case .bottomMid(_, let border):
return border.endPoint
case .bottomRight(_, let border):
return border.endPoint
}
}
}
// PRE-NOTE: - Before drawing a rect - inset by border width (if it needed)
// NOTE: - Top left scheme (BubbleTriangleViewTriangleType.swift)
// - rectangular
// P5 - P0 - largeLeg: CGFloat
// P0 - P1 - hypotenuse: CGFloat
// equiangular
// (P0 - P1) = (P1 - P2) - side: CGFloat
// P0
// | \
// | \
// P5 P1-------------------------P2
// | |
// | |
// | |
// | |
// | |
// | |
// | |
// | |
// | |
// P4---------------------------- P3
//
// NOTE: - Top mid scheme (BubbleTriangleViewTriangleType.swift)
// - rectangular
// P5 - P0 - largeLeg: CGFloat
// P0 - P1 - hypotenuse: CGFloat
// equiangular
// (P0 - P1) = (P1 - P2) - side: CGFloat
// P2
// / \
// / \
// P0---------P1 P3----------P4
// | |
// | |
// | |
// | |
// | |
// | |
// | |
// | |
// | |
// P6---------------------------- P5
//
// P.S.: - Always start from top-left corner, no matter what are you drawing (coordinate system start)
import UIKit
enum BubbleTriangleViewTriangleType {
case equiangular(side: CGFloat) // 90-45-45
case rectangular(largeLeg: CGFloat, alphaAngle: Double) // alphaAngle - 0.0...45.0
// sin(alphaAngle) = largeLeg / hipotenuse =>
//
// hipotenuse = largeLeg / sin(Double.deg2rad(alphaAngle))
//
// https://www.hackingwithswift.com/example-code/language/how-to-convert-degrees-to-radians
//
// https://en.wikipedia.org/wiki/Hypotenuse
// alpha
// | \
// | \
// | \
// | \
// | \
// A | \ C sin(alpha) = a / c => c = a / sin(alpha)
// | \
// | \
// | \
// | \
// | \
// -----------beta
// B
var hipotenuseSize: CGFloat {
switch self {
case .equiangular(let side):
return side / CGFloat(sin(Double.deg2rad(45.0)))
case .rectangular(let largeLeg, let alphaAngle):
return largeLeg / CGFloat(sin(Double.deg2rad(alphaAngle)))
}
}
var smallLeg: CGFloat {
switch self {
case .equiangular(let side):
return side
case .rectangular(let largeLeg, let alphaAngle):
return largeLeg * CGFloat(tan(Double.deg2rad(alphaAngle)))
}
}
var largeLeg: CGFloat {
switch self {
case .equiangular(let side):
return side
case .rectangular(let largeLeg, _ ):
return largeLeg
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment