Skip to content

Commit

Permalink
✨ Add support for rounded tooltip pointers
Browse files Browse the repository at this point in the history
  • Loading branch information
mmaatttt committed Apr 11, 2023
1 parent 9fca85f commit 7db91c5
Show file tree
Hide file tree
Showing 4 changed files with 133 additions and 23 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// CGSize+PointerSize.swift
// AppcuesKit
//
// Created by Matt on 2023-03-16.
// Copyright © 2023 Appcues. All rights reserved.
//

import UIKit

extension CGSize {
// How does this work?
// We're interested in the radius value such that the arc from outer circle (C1) and arc from the inner circle (C2)
// intersect, drawing a continuous line for the pointer rather than C2 exceeding C1 and there being an odd straigt line
// while the path backtracks.
// There's a few constraints we know:
// 1. The angle of the line between C1 and C2 (perpendicular to the side of the pointer triangle)
// 2. The x value of the center of C2 must be in the middle of the pointer (so pointerSize.width / 2)
// 3. The y value of the center of C2 (h) can be calculated from the tip of the pointer
// 4. The distance between C1 and C2 is 2 * radius
// 5. The center of C1 can be calculated by projecting from C1 distance 2 * radius by the angle
// 6. The y value of the center of C1 must be exactly -radius from the base (so pointerSize.height - radius)
var maxPointerCornerRadius: CGFloat {
let pt1 = CGPoint(x: 0, y: self.height)
let pt2 = CGPoint(x: self.width / 2, y: 0)

let angle = atan2(pt1.y, pt2.x) // (1)
// let h = r / cos(angle) // (3)
// let centerC2 = CGPoint(
// x: pt2.x, // (2)
// y: pt2.y + h) // (3)

// The position of centerC1, given centerC2 and r
// let centerC1 = CGPoint(
// x: centerC2.x - cos(angle - .pi / 2) * r * 2, // (4, 5)
// y: centerC2.y + sin(angle - .pi / 2) * r * 2) // (4, 5)

// We know pt1.y - r = centerC1.y // (6)
// => r = pt1.y - centerC1.y

// Subtitute and solve for r:
let radius = (pt1.y - pt2.y) / (1 + 2 * sin(angle - .pi / 2) + (1 / cos(angle)))

return radius
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,84 +15,118 @@ internal struct Pointer {

let edge: Edge
let size: CGSize
let cornerRadius: CGFloat
let offset: CGFloat
}

extension UIBezierPath {
convenience init(tooltipAround mainRect: CGRect, cornerRadius: CGFloat, pointer: Pointer) {
self.init()
extension CGPath {
static func tooltip(around mainRect: CGRect, cornerRadius: CGFloat, pointer: Pointer) -> CGPath {
let path = CGMutablePath()

let triangle = Triangle(pointer: pointer, mainRect: mainRect, cornerRadius: cornerRadius)
let triangle = CGMutablePath.Triangle(pointer: pointer, mainRect: mainRect, cornerRadius: cornerRadius)

// Draw the path clockwise from top left

if !triangle.overridesTopLeftCorner {
let topLeft = CGPoint(x: mainRect.minX + cornerRadius, y: mainRect.minY + cornerRadius)
addArc(withCenter: topLeft, radius: cornerRadius, startAngle: .pi, endAngle: 3 * .pi / 2, clockwise: true)
path.addArc(center: topLeft, radius: cornerRadius, startAngle: .pi, endAngle: 3 * .pi / 2, clockwise: false)
} else {
move(to: mainRect.origin)
path.move(to: mainRect.origin)
}

if case .top = pointer.edge {
addTriangle(triangle)
path.addTriangle(triangle)
}

if !triangle.overridesTopRightCorner {
let topRight = CGPoint(x: mainRect.maxX - cornerRadius, y: mainRect.minY + cornerRadius)
addArc(withCenter: topRight, radius: cornerRadius, startAngle: -.pi / 2, endAngle: 0, clockwise: true)
path.addArc(center: topRight, radius: cornerRadius, startAngle: -.pi / 2, endAngle: 0, clockwise: false)
}

if case .right = pointer.edge {
addTriangle(triangle)
path.addTriangle(triangle)
}

if !triangle.overridesBottomRightCorner {
let bottomRight = CGPoint(x: mainRect.maxX - cornerRadius, y: mainRect.maxY - cornerRadius)
addArc(withCenter: bottomRight, radius: cornerRadius, startAngle: 0, endAngle: .pi / 2, clockwise: true)
path.addArc(center: bottomRight, radius: cornerRadius, startAngle: 0, endAngle: .pi / 2, clockwise: false)
}

if case .bottom = pointer.edge {
addTriangle(triangle)
path.addTriangle(triangle)
}

if !triangle.overridesBottomLeftCorner {
let bottomLeft = CGPoint(x: mainRect.minX + cornerRadius, y: mainRect.maxY - cornerRadius)
addArc(withCenter: bottomLeft, radius: cornerRadius, startAngle: .pi / 2, endAngle: .pi, clockwise: true)
path.addArc(center: bottomLeft, radius: cornerRadius, startAngle: .pi / 2, endAngle: .pi, clockwise: false)
}

if case .left = pointer.edge {
addTriangle(triangle)
path.addTriangle(triangle)
}

close()
path.closeSubpath()
return path
}
}

private extension CGMutablePath {
func addTriangle(_ triangle: Triangle) {
if triangle.cornerRadius == 0 {
addLine(to: triangle.point1)
addLine(to: triangle.point2)
addLine(to: triangle.point3)
} else {
if triangle.offCenterPointer1 {
// Can't round the straight edge
addLine(to: triangle.point1)
} else {
addArc(tangent1End: triangle.point1, tangent2End: triangle.point2, radius: triangle.cornerRadius)
}

addArc(tangent1End: triangle.point2, tangent2End: triangle.point3, radius: triangle.cornerRadius)

private func addTriangle(_ triangle: Triangle) {
addLine(to: triangle.point1)
addLine(to: triangle.point2)
addLine(to: triangle.point3)
if triangle.offCenterPointer2 {
// Can't round the straight edge
addLine(to: triangle.point3)
} else {
addArc(tangent1End: triangle.point3, tangent2End: triangle.point4, radius: triangle.cornerRadius)
}
}
}
}

private extension UIBezierPath {
struct Triangle {
// Points are ordered for a tooltip drawn clockwise
let point1: CGPoint
let point2: CGPoint
let point3: CGPoint

let cornerRadius: CGFloat

// Control points for rounded pointer arc calculations
let point0: CGPoint
let point4: CGPoint

private(set) var overridesTopLeftCorner: Bool
private(set) var overridesTopRightCorner: Bool
private(set) var overridesBottomRightCorner: Bool
private(set) var overridesBottomLeftCorner: Bool

private(set) var offCenterPointer1: Bool
private(set) var offCenterPointer2: Bool

// swiftlint:disable:next cyclomatic_complexity function_body_length
init(pointer: Pointer, mainRect: CGRect, cornerRadius: CGFloat) {
self.cornerRadius = pointer.cornerRadius

overridesTopLeftCorner = false
overridesTopRightCorner = false
overridesBottomRightCorner = false
overridesBottomLeftCorner = false

offCenterPointer1 = false
offCenterPointer2 = false

switch pointer.edge {
case .top:
var triangleBounds = CGRect(
Expand All @@ -111,6 +145,7 @@ private extension UIBezierPath {
triangleBounds.origin.x = cornerRadius
}
point2X = triangleBounds.minX
offCenterPointer1 = true
} else if triangleBounds.origin.x > mainRect.maxX - pointer.size.width - cornerRadius {
// Check for collisions with right corner
if triangleBounds.origin.x > mainRect.maxX - pointer.size.width {
Expand All @@ -120,13 +155,17 @@ private extension UIBezierPath {
triangleBounds.origin.x = mainRect.maxX - pointer.size.width - cornerRadius
}
point2X = triangleBounds.maxX
offCenterPointer2 = true
} else {
// Centered pointer
point2X = triangleBounds.midX
}
point1 = CGPoint(x: triangleBounds.minX, y: triangleBounds.maxY)
point2 = CGPoint(x: point2X, y: triangleBounds.minY)
point3 = CGPoint(x: triangleBounds.maxX, y: triangleBounds.maxY)

point0 = CGPoint(x: mainRect.minX + cornerRadius, y: point1.y)
point4 = CGPoint(x: mainRect.maxX - cornerRadius, y: point3.y)
case .bottom:
var triangleBounds = CGRect(
x: mainRect.midX - pointer.size.width / 2 + pointer.offset,
Expand All @@ -143,6 +182,7 @@ private extension UIBezierPath {
triangleBounds.origin.x = cornerRadius
}
point2X = triangleBounds.minX
offCenterPointer2 = true
} else if triangleBounds.origin.x > mainRect.maxX - pointer.size.width - cornerRadius {
if triangleBounds.origin.x > mainRect.maxX - pointer.size.width {
overridesBottomRightCorner = true
Expand All @@ -151,12 +191,16 @@ private extension UIBezierPath {
triangleBounds.origin.x = mainRect.maxX - pointer.size.width - cornerRadius
}
point2X = triangleBounds.maxX
offCenterPointer1 = true
} else {
point2X = triangleBounds.midX
}
point1 = CGPoint(x: triangleBounds.maxX, y: triangleBounds.minY)
point2 = CGPoint(x: point2X, y: triangleBounds.maxY)
point3 = CGPoint(x: triangleBounds.minX, y: triangleBounds.minY)

point0 = CGPoint(x: mainRect.maxX - cornerRadius, y: point1.y)
point4 = CGPoint(x: mainRect.minX + cornerRadius, y: point3.y)
case .left:
var triangleBounds = CGRect(
x: mainRect.minX - pointer.size.height,
Expand All @@ -173,6 +217,7 @@ private extension UIBezierPath {
triangleBounds.origin.y = cornerRadius
}
point2Y = triangleBounds.minY
offCenterPointer2 = true
} else if triangleBounds.origin.y > mainRect.maxY - pointer.size.width - cornerRadius {
if triangleBounds.origin.y > mainRect.maxY - pointer.size.width {
overridesBottomLeftCorner = true
Expand All @@ -181,12 +226,16 @@ private extension UIBezierPath {
triangleBounds.origin.y = mainRect.maxY - pointer.size.width - cornerRadius
}
point2Y = triangleBounds.maxY
offCenterPointer1 = true
} else {
point2Y = triangleBounds.midY
}
point1 = CGPoint(x: triangleBounds.maxX, y: triangleBounds.maxY)
point2 = CGPoint(x: triangleBounds.minX, y: point2Y)
point3 = CGPoint(x: triangleBounds.maxX, y: triangleBounds.minY)

point0 = CGPoint(x: point1.x, y: mainRect.maxY - cornerRadius)
point4 = CGPoint(x: point3.x, y: mainRect.minY + cornerRadius)
case .right:
var triangleBounds = CGRect(
x: mainRect.maxX,
Expand All @@ -203,6 +252,7 @@ private extension UIBezierPath {
triangleBounds.origin.y = cornerRadius
}
point2Y = triangleBounds.minY
offCenterPointer1 = true
} else if triangleBounds.origin.y > mainRect.maxY - pointer.size.width - cornerRadius {
if triangleBounds.origin.y > mainRect.maxY - pointer.size.width {
overridesBottomRightCorner = true
Expand All @@ -211,12 +261,16 @@ private extension UIBezierPath {
triangleBounds.origin.y = mainRect.maxY - pointer.size.width - cornerRadius
}
point2Y = triangleBounds.maxY
offCenterPointer2 = true
} else {
point2Y = triangleBounds.midY
}
point1 = CGPoint(x: triangleBounds.minX, y: triangleBounds.minY)
point2 = CGPoint(x: triangleBounds.maxX, y: point2Y)
point3 = CGPoint(x: triangleBounds.minX, y: triangleBounds.maxY)

point0 = CGPoint(x: point1.x, y: mainRect.minY + cornerRadius)
point4 = CGPoint(x: point3.x, y: mainRect.maxY - cornerRadius)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ internal class AppcuesTooltipTrait: StepDecoratingTrait, WrapperCreatingTrait, P
let hidePointer: Bool?
let pointerBase: Double?
let pointerLength: Double?
let pointerCornerRadius: Double?
let style: ExperienceComponent.Style?
}

Expand All @@ -25,11 +26,13 @@ internal class AppcuesTooltipTrait: StepDecoratingTrait, WrapperCreatingTrait, P
let tooltipStyle: ExperienceComponent.Style?
let hidePointer: Bool
let pointerSize: CGSize
let pointerCornerRadius: CGFloat

required init?(configuration: ExperiencePluginConfiguration, level: ExperienceTraitLevel) {
let config = configuration.decode(Config.self)
self.hidePointer = config?.hidePointer ?? false
self.pointerSize = CGSize(width: config?.pointerBase ?? 16, height: config?.pointerLength ?? 8)
self.pointerCornerRadius = config?.pointerCornerRadius ?? 0
self.tooltipStyle = config?.style
}

Expand All @@ -47,6 +50,8 @@ internal class AppcuesTooltipTrait: StepDecoratingTrait, WrapperCreatingTrait, P
let experienceWrapperViewController = ExperienceWrapperViewController<TooltipWrapperView>(wrapping: containerController)
experienceWrapperViewController.configureStyle(tooltipStyle)
experienceWrapperViewController.bodyView.pointerSize = hidePointer ? nil : pointerSize
experienceWrapperViewController.bodyView.pointerCornerRadius = pointerCornerRadius

if let preferredWidth = tooltipStyle?.width {
experienceWrapperViewController.bodyView.preferredWidth = preferredWidth
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ internal class TooltipWrapperView: ExperienceWrapperView {
var preferredPosition: ContentPosition?
/// A nil pointerSize means no pointer
var pointerSize: CGSize?
var pointerCornerRadius: CGFloat = 0
var distanceFromTarget: CGFloat = 0

var targetRectangle: CGRect? {
Expand Down Expand Up @@ -310,10 +311,14 @@ internal class TooltipWrapperView: ExperienceWrapperView {
height: pointerSize.height
)

let pointer = Pointer(edge: pointerEdge, size: constrainedPointerSize, offset: offsetFromCenter)
let tooltipPath = UIBezierPath(tooltipAround: mainRect, cornerRadius: cornerRadius, pointer: pointer)
let constrainedPointerCornerRadius = min(pointerCornerRadius, constrainedPointerSize.maxPointerCornerRadius)

return tooltipPath.cgPath
let pointer = Pointer(
edge: pointerEdge,
size: constrainedPointerSize,
cornerRadius: constrainedPointerCornerRadius,
offset: offsetFromCenter)
return CGPath.tooltip(around: mainRect, cornerRadius: cornerRadius, pointer: pointer)
}

}
Expand Down

0 comments on commit 7db91c5

Please sign in to comment.