Created
January 11, 2021 22:04
-
-
Save SergLam/3d662ef26f8be69ff90e9db6604f3efc to your computer and use it in GitHub Desktop.
Bubble Triangle View in Swift for iOS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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