Skip to content

Instantly share code, notes, and snippets.

@Peter-Schorn
Last active February 4, 2022 01:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Peter-Schorn/09d6a002b07d59efeb403abab97fa2ee to your computer and use it in GitHub Desktop.
Save Peter-Schorn/09d6a002b07d59efeb403abab97fa2ee to your computer and use it in GitHub Desktop.
X Shape in SwiftUI
import SwiftUI
/**
An X shape.
To ensure the shape is square, add the following modifier:
```
.aspectRatio(1, contentMode: .fit)
```
*/
public struct XShape: InsettableShape {
/// The thickness of the legs.
public enum Thickness {
/// An absolute thickness
case absolute(CGFloat)
/**
A relative Thickness. Must be between 0 and 1.
This will be multiplied by the smallest dimension of the bounding
frame to compute the absolute thickness
*/
case relative(CGFloat)
}
/// The thickness of the legs.
public let thickness: Thickness
/**
The angle between the horizontal and the right leg.
If `nil`, then the angle will be equal to that of the angle between the
horizonal and diagonal line starting from the bottom left
corner and extending towards the top right corner.
*/
public let legsAngle: Angle?
private var inset: CGFloat = 0
/**
Creates an Xshape.
- Parameters:
- thickness: The thickness of the legs.
- legsAngle: The angle between the horizontal and the right leg. If `nil`, then the angle will be equal to equal to that of the angle
between the horizonal and diagonal line starting from the bottom
left corner and extending towards the top right corner.
*/
public init(
thickness: Thickness = .relative(0.1),
legsAngle: Angle? = nil
) {
if case .relative(let relativeThickness) = thickness {
precondition(
(0...1).contains(relativeThickness),
"""
relative thickness must be between 0 and 1 \
(got \(relativeThickness))"
"""
)
}
self.thickness = thickness
if let legsAngle = legsAngle {
let degrees = legsAngle.degrees
precondition(
(0...90).contains(degrees),
"legsAngle must be between 0 and 90 degrees (got \(degrees))"
)
}
self.legsAngle = legsAngle
}
public func inset(by amount: CGFloat) -> Self {
var shape = self
shape.inset += amount
return shape
}
public func path(in rect: CGRect) -> Path {
let rect = rect.insetBy(dx: self.inset, dy: self.inset)
return self.pathInRectCore(rect)
}
/// The thickness of the legs.
private func legsWidth(rect: CGRect) -> CGFloat {
let minDimension = min(rect.width, rect.height)
switch self.thickness {
case .absolute(let thickness):
return min(thickness, minDimension)
case .relative(let thickness):
return min(minDimension * thickness, minDimension)
}
}
/// The angle from the horizontal to the diagonal line starting at the
/// bottom left corner and extending to the top right corner.
private func diagonalAngle(rect: CGRect) -> Angle {
let radians = atan(rect.height / rect.width)
return .radians(radians)
}
/// The angle from the horizontal to the right leg.
private func legsAngle(rect: CGRect) -> Angle {
if let legsAngle = self.legsAngle {
return legsAngle
}
return self.diagonalAngle(rect: rect)
}
/// The offset of the four vertices near the center *from* the center of the
/// shape.
private func centerVerticesOffset(
legsAngle: Double,
rect: CGRect,
legsWidth: CGFloat
) -> CGVector {
let angle = Double.pi / 2 - 2 * legsAngle
let dy = (legsWidth / cos(angle)) * sin(legsAngle)
let dx = (legsWidth / cos(angle)) * cos(legsAngle)
return CGVector(dx: abs(dx), dy: abs(dy))
}
/// The offset of the corner vertices from the corner circle center.
private func cornerVerticesOffset(
legsAngle: Double,
rect: CGRect,
circleRadius: CGFloat
) -> CGVector {
let dx = circleRadius * sin(legsAngle)
let dy = circleRadius * cos(legsAngle)
return CGVector(dx: abs(dx), dy: abs(dy))
}
/// The offset of the center of the circle near the corners of the bounding
/// frame *from* the center of the frame.
private func cornerCircleOffsetFromCenter(
legsAngle: Double,
rect: CGRect,
circleRadius: CGFloat
) -> CGVector {
var dx: CGFloat
var dy: CGFloat
let diagonalAngle = self.diagonalAngle(rect: rect).radians
if legsAngle <= diagonalAngle {
dx = rect.width / 2 - circleRadius
dy = dx * tan(legsAngle)
if abs(dy) + circleRadius > rect.height / 2 {
dy = rect.height / 2 - circleRadius
dx = dy / tan(legsAngle)
}
}
else /* if legsAngle > diagonalAngle */ {
dy = rect.height / 2 - circleRadius
dx = dy / tan(legsAngle)
if abs(dx) + circleRadius > rect.width / 2 {
dx = rect.width / 2 - circleRadius
dy = dx * tan(legsAngle)
}
}
return CGVector(dx: abs(dx), dy: abs(dy))
}
/**
Finds the angle between two points on the perimeter of a circle.
[Source](https://math.stackexchange.com/a/185844/825630)
*/
private func angleBetweenPoints(
point1: CGPoint,
point2: CGPoint,
radius: CGFloat
) -> Angle {
/// The distance between the two points squared
let distance2 =
pow(point2.x - point1.x, 2) +
pow(point2.y - point1.y, 2)
/// 2r^2
let r22 = (2 * pow(radius, 2))
let radians = acos((r22 - distance2) / r22)
return .radians(radians)
}
/**
Finds the points that intersect two circles.
If the circles only intersect at one point, then the second point will be
`nil`.
If both circles have the same center point, then returns `nil`.
Sources:
[gist](https://gist.github.com/jupdike/bfe5eb23d1c395d8a0a1a4ddd94882ac)
[stackexchange](https://math.stackexchange.com/a/1367732/825630)
- Parameters:
- center1: The center of the first circle.
- radius1: The radius of the first circle.
- center2: The center of the second circle.
- radius2: The radius of the second circle.
*/
private func intersectingPointsOfCircles(
center1: CGPoint,
radius1: CGFloat,
center2: CGPoint,
radius2: CGFloat
) -> (CGPoint, CGPoint?)? {
if center1 == center2 {
// If the centers are the same and the radii are the same, then the
// circles intersect at an infinite number of points, so return
// `nil`.
//
// If the centers are the same, but the radii are different, then
// there can't be any intersecting points, so also return `nil`.
return nil
}
let centerDx = center1.x - center2.x
let centerDy = center1.y - center2.y
/// The distance between the centers of the circles
let d = sqrt(pow(centerDx, 2) + pow(centerDy, 2))
if abs(radius1 - radius2) > d || d > radius1 + radius2 {
return nil
}
let d2 = d * d
let d4 = d2 * d2
let a = (radius1 * radius1 - radius2 * radius2) / (2 * d2)
let r2r2 = (radius1 * radius1 - radius2 * radius2)
let c = sqrt(
2 * (radius1 * radius1 + radius2 * radius2) /
d2 - (r2r2 * r2r2) / d4 - 1
)
let fx = (center1.x + center2.x) / 2 + a * (center2.x - center1.x)
let gx = c * (center2.y - center1.y) / 2
let ix1 = fx + gx
let ix2 = fx - gx
let fy = (center1.y + center2.y) / 2 + a * (center2.y - center1.y)
let gy = c * (center1.x - center2.x) / 2
let iy1 = fy + gy
let iy2 = fy - gy
// if gy == 0 and gx == 0, then the circles are tangent and there
// is only one solution
let intersectingPoint1 = CGPoint(x: ix1, y: iy1)
let intersectingPoint2 = CGPoint(x: ix2, y: iy2)
if intersectingPoint1 == intersectingPoint2 {
return (intersectingPoint1, nil)
}
return (intersectingPoint1, intersectingPoint2)
}
private func intersectingPointsOfCircles(
center1: CGPoint,
radius1: CGFloat,
center2: CGPoint
) -> (CGPoint, CGPoint?)? {
self.intersectingPointsOfCircles(
center1: center1,
radius1: radius1,
center2: center2,
radius2: radius1
)
}
// MARK: Path In Rect Core
/// The rect is already inset.
private func pathInRectCore(_ rect: CGRect) -> Path {
var path = Path()
/// Radians
let legsWidth = self.legsWidth(rect: rect)
if legsWidth == 0 {
return path
}
/// Radians
let legsAngle = self.legsAngle(rect: rect).radians
let minDimension = min(rect.width, rect.height)
/// 0 and 90 degrees
let rightAngles = [0, Double.pi / 2]
let legsAngleIs0Or90 = rightAngles.contains(legsAngle)
if legsWidth >= minDimension && !legsAngleIs0Or90 {
return Circle()
// so that the path always starts at the top left corner
.rotation(.degrees(180))
.path(in: rect)
}
let circleRadius = legsWidth / 2
let cornerCircleOffsetFromCenter = self.cornerCircleOffsetFromCenter(
legsAngle: legsAngle,
rect: rect,
circleRadius: circleRadius
)
let centerVerticesOffset = self.centerVerticesOffset(
legsAngle: legsAngle,
rect: rect,
legsWidth: legsWidth
)
let cornerVerticesOffset = self.cornerVerticesOffset(
legsAngle: legsAngle,
rect: rect,
circleRadius: circleRadius
)
// MARK: Top Left
let topLeftCircleCenter = CGPoint(
x: rect.midX - cornerCircleOffsetFromCenter.dx,
y: rect.midY - cornerCircleOffsetFromCenter.dy
)
var topLeftCorner1 = CGPoint(
x: topLeftCircleCenter.x - cornerVerticesOffset.dx,
y: topLeftCircleCenter.y + cornerVerticesOffset.dy
)
var topLeftCorner2 = CGPoint(
x: topLeftCircleCenter.x + cornerVerticesOffset.dx,
y: topLeftCircleCenter.y - cornerVerticesOffset.dy
)
var topLeftStartAngle = Double.pi / 2 + legsAngle
var topLeftEndAngle = topLeftStartAngle + Double.pi
// MARK: Top Center
var topCenter = rect.center
topCenter.y -= centerVerticesOffset.dy
// MARK: Top Right
let topRightCircleCenter = CGPoint(
x: rect.midX + cornerCircleOffsetFromCenter.dx,
y: rect.midY - cornerCircleOffsetFromCenter.dy
)
var topRightCorner1 = CGPoint(
x: topRightCircleCenter.x - cornerVerticesOffset.dx,
y: topRightCircleCenter.y - cornerVerticesOffset.dy
)
// var topRightCorner2 = CGPoint(
// x: topRightCircleCenter.x + cornerVerticesOffset.dx,
// y: topRightCircleCenter.y + cornerVerticesOffset.dy
// )
var topRightStartAngle = 3 * Double.pi / 2 - legsAngle
var topRightEndAngle = topRightStartAngle + Double.pi
// MARK: Right Center
var rightCenter = rect.center
rightCenter.x += centerVerticesOffset.dx
// MARK: Bottom Right
let bottomRightCircleCenter = CGPoint(
x: rect.midX + cornerCircleOffsetFromCenter.dx,
y: rect.midY + cornerCircleOffsetFromCenter.dy
)
var bottomRightCorner1 = CGPoint(
x: bottomRightCircleCenter.x + cornerVerticesOffset.dx,
y: bottomRightCircleCenter.y - cornerVerticesOffset.dy
)
// var bottomRightCorner2 = CGPoint(
// x: bottomRightCircleCenter.x - cornerVerticesOffset.dx,
// y: bottomRightCircleCenter.y + cornerVerticesOffset.dy
// )
var bottomRightStartAngle = 3 * Double.pi / 2 + legsAngle
var bottomRightEndAngle = bottomRightStartAngle + Double.pi
// MARK: Bottom Center
var bottomCenter = rect.center
bottomCenter.y += centerVerticesOffset.dy
// MARK: Bottom Left
let bottomLeftCircleCenter = CGPoint(
x: rect.midX - cornerCircleOffsetFromCenter.dx,
y: rect.midY + cornerCircleOffsetFromCenter.dy
)
var bottomLeftCorner1 = CGPoint(
x: bottomLeftCircleCenter.x + cornerVerticesOffset.dx,
y: bottomLeftCircleCenter.y + cornerVerticesOffset.dy
)
var bottomLeftCorner2 = CGPoint(
x: bottomLeftCircleCenter.x - cornerVerticesOffset.dx,
y: bottomLeftCircleCenter.y - cornerVerticesOffset.dy
)
var bottomLeftStartAngle = Double.pi / 2 - legsAngle
var bottomLeftEndAngle = bottomLeftStartAngle + Double.pi
// MARK: Left Center
var leftCenter = rect.center
leftCenter.x -= centerVerticesOffset.dx
let drawLeftCenter = bottomLeftCorner2.y > topLeftCorner1.y &&
!legsAngleIs0Or90
let drawTopCenter = topLeftCorner2.x < topRightCorner1.x &&
!legsAngleIs0Or90
// let drawRightCenter = topRightCorner2.y < bottomRightCorner1.y &&
// !legsAngleIs0Or90
let drawRightCenter = drawLeftCenter
// let drawBottomCenter = bottomRightCorner2.x > bottomLeftCorner1.x &&
// !legsAngleIs0Or90
let drawBottomCenter = drawTopCenter
// MARK: - Draw -
// MARK: Prevent Overlapping Arcs
if !legsAngleIs0Or90 {
if !drawLeftCenter {
// prevent arcs from overlapping on the left
let intersectingPointsLeft = self.intersectingPointsOfCircles(
center1: bottomLeftCircleCenter,
radius1: circleRadius,
center2: topLeftCircleCenter
)
if let intersectingPoints = intersectingPointsLeft {
let leftIntersectingPoint: CGPoint
if let p2 = intersectingPoints.1,
p2.x < intersectingPoints.0.x {
leftIntersectingPoint = p2
}
else {
leftIntersectingPoint = intersectingPoints.0
}
let angle = self.angleBetweenPoints(
point1: topLeftCorner1,
point2: leftIntersectingPoint,
radius: circleRadius
)
topLeftStartAngle += angle.radians
topLeftCorner1 = leftIntersectingPoint
bottomLeftCorner2 = leftIntersectingPoint
bottomLeftEndAngle -= angle.radians
// prevent arcs from overlapping on the right
let rightIntersectingPoint = CGPoint(
x: 2 * rect.midX - leftIntersectingPoint.x,
y: leftIntersectingPoint.y
)
topRightEndAngle -= angle.radians
bottomRightCorner1 = rightIntersectingPoint
// topRightCorner2 = rightIntersectingPoint
bottomRightStartAngle += angle.radians
}
}
if !drawTopCenter {
// prevent arcs from overlapping on the top
let intersectingPointsTop = self.intersectingPointsOfCircles(
center1: topLeftCircleCenter,
radius1: circleRadius,
center2: topRightCircleCenter
)
if let intersectingPoints = intersectingPointsTop {
let topIntersectingPoint: CGPoint
if let p2 = intersectingPoints.1,
p2.y < intersectingPoints.0.y {
topIntersectingPoint = p2
}
else {
topIntersectingPoint = intersectingPoints.0
}
let angle = self.angleBetweenPoints(
point1: topIntersectingPoint,
point2: topLeftCorner2,
radius: circleRadius
)
topLeftEndAngle -= angle.radians
topRightCorner1 = topIntersectingPoint
topLeftCorner2 = topIntersectingPoint
topRightStartAngle += angle.radians
// prevent arcs from overlapping on the bottom
let bottomIntersectingPoint = CGPoint(
x: topIntersectingPoint.x,
y: 2 * rect.midY - topIntersectingPoint.y
)
bottomRightEndAngle -= angle.radians
bottomLeftCorner1 = bottomIntersectingPoint
// bottomRightCorner2 = bottomIntersectingPoint
bottomLeftStartAngle += angle.radians
}
}
}
// MARK: Top Left
path.move(to: topLeftCorner1)
// path.addLine(to: topLeftCircleCenter)
// path.addLine(to: topLeftCorner2)
path.addArc(
center: topLeftCircleCenter,
radius: circleRadius,
startAngle: .radians(topLeftStartAngle),
endAngle: .radians(topLeftEndAngle),
clockwise: false
)
// MARK: Top Center
if drawTopCenter {
path.addLine(to: topCenter)
}
// MARK: Top Right
if legsAngle != Double.pi / 2 {
path.addLine(to: topRightCorner1)
// path.addLine(to: topRightCircleCenter)
// path.addLine(to: topRightCorner2)
path.addArc(
center: topRightCircleCenter,
radius: circleRadius,
startAngle: .radians(topRightStartAngle),
endAngle: .radians(topRightEndAngle),
clockwise: false
)
}
// If the angle is zero, then we only need to draw the top half
// of each leg
if legsAngle == 0 {
// close the path by adding a line back to the starting point
path.addLine(to: topLeftCorner1)
path.closeSubpath()
return path
}
// MARK: Right Center
if drawRightCenter {
path.addLine(to: rightCenter)
}
// MARK: Bottom Right
path.addLine(to: bottomRightCorner1)
// path.addLine(to: bottomRightCircleCenter)
// path.addLine(to: bottomRightCorner2)
path.addArc(
center: bottomRightCircleCenter,
radius: circleRadius,
startAngle: .radians(bottomRightStartAngle),
endAngle: .radians(bottomRightEndAngle),
clockwise: false
)
// MARK: Bottom Center
if drawBottomCenter {
path.addLine(to: bottomCenter)
}
// MARK: Bottom Left
if legsAngle != Double.pi / 2 {
path.addLine(to: bottomLeftCorner1)
// path.addLine(to: bottomLeftCircleCenter)
// path.addLine(to: bottomLeftCorner2)
path.addArc(
center: bottomLeftCircleCenter,
radius: circleRadius,
startAngle: .radians(bottomLeftStartAngle),
endAngle: .radians(bottomLeftEndAngle),
clockwise: false
)
}
// MARK: Left Center
if drawLeftCenter {
path.addLine(to: leftCenter)
}
// close the path by adding a line back to the starting point
path.addLine(to: topLeftCorner1)
path.closeSubpath()
return path
}
}
extension CGRect {
var center: CGPoint {
CGPoint(x: self.midX, y: self.midY)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment