Last active
February 4, 2022 01:03
-
-
Save Peter-Schorn/09d6a002b07d59efeb403abab97fa2ee to your computer and use it in GitHub Desktop.
X Shape in SwiftUI
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 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