Last active
February 4, 2022 01:03
-
-
Save Peter-Schorn/09d6a002b07d59efeb403abab97fa2ee to your computer and use it in GitHub Desktop.
Revisions
-
Peter-Schorn revised this gist
Feb 4, 2022 . 1 changed file with 549 additions and 149 deletions.There are no files selected for viewing
This file contains hidden or 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 charactersOriginal file line number Diff line number Diff line change @@ -1,228 +1,628 @@ 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) } } -
Peter-Schorn revised this gist
Jan 27, 2022 . 1 changed file with 1 addition and 1 deletion.There are no files selected for viewing
This file contains hidden or 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 charactersOriginal file line number Diff line number Diff line change @@ -218,7 +218,7 @@ extension CGRect { struct XShape_Previews: PreviewProvider { static var previews: some View { XShape() .frame(width: 200, height: 200) .padding(2) .border(Color.green.opacity(0.5), width: 2) -
Peter-Schorn created this gist
Jan 27, 2022 .There are no files selected for viewing
This file contains hidden or 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 charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,228 @@ import SwiftUI public struct XShape: InsettableShape { public enum Thickness { case absolute(CGFloat) case relative(CGFloat) } public let thickness: Thickness public init(thickness: Thickness = .relative(0.1)) { self.thickness = thickness } private var inset: CGFloat = 0 public func inset(by amount: CGFloat) -> Self { var shape = self shape.inset += amount return shape } public func path(in rect: CGRect) -> Path { // Inset the shape let rect = rect.insetBy(dx: self.inset, dy: self.inset) // Make the rect square, centered on the inset rect let squareRect: CGRect if rect.width == rect.height { squareRect = rect } else { let smallestDimension = min(rect.width, rect.height) squareRect = CGRect( x: (rect.width - smallestDimension) / 2, y: (rect.height - smallestDimension) / 2, width: smallestDimension, height: smallestDimension ) } return self.pathInRectCore(squareRect) } /// The rect is already inset and square private func pathInRectCore(_ rect: CGRect) -> Path { var path = Path() let lineWidth: CGFloat switch thickness { case .absolute(let thickness): lineWidth = thickness case .relative(let thickness): lineWidth = rect.width * thickness } if lineWidth == 0 { return path } let circleRadius = lineWidth / 2 let triangleLeg = sqrt(pow(lineWidth / 2, 2) / 2) /// The offset from the center for each of the points /// near the center of the shape. let centerOffset = sqrt(pow(lineWidth, 2) / 2) do { // MARK: Top Left let topLeftCircleCenter = CGPoint( x: rect.minX + lineWidth / 2, y: rect.minY + lineWidth / 2 ) let topLeftCorner1 = CGPoint( x: topLeftCircleCenter.x - triangleLeg, y: topLeftCircleCenter.y + triangleLeg ) // let topLeftCorner2 = CGPoint( // x: topLeftCircleCenter.x + triangleLeg, // y: topLeftCircleCenter.y - triangleLeg // ) path.move(to: topLeftCorner1) path.addArc( center: topLeftCircleCenter, radius: circleRadius, startAngle: .degrees(135), endAngle: .degrees(315), clockwise: false ) } do { // MARK: Top Center var center = rect.center center.y -= centerOffset path.addLine(to: center) } do { // MARK: Top Right let topRightCircleCenter = CGPoint( x: rect.maxX - lineWidth / 2, y: rect.minY + lineWidth / 2 ) let topRightCorner1 = CGPoint( x: topRightCircleCenter.x - triangleLeg, y: topRightCircleCenter.y - triangleLeg ) // let topRightCorner2 = CGPoint( // x: topRightCircleCenter.x + triangleLeg, // y: topRightCircleCenter.y + triangleLeg // ) path.addLine(to: topRightCorner1) path.addArc( center: topRightCircleCenter, radius: circleRadius, startAngle: .degrees(225), endAngle: .degrees(45), clockwise: false ) } do { // MARK: Right Center var center = rect.center center.x += centerOffset path.addLine(to: center) } do { // MARK: Bottom Right let bottomRightCircleCenter = CGPoint( x: rect.maxX - lineWidth / 2, y: rect.maxY - lineWidth / 2 ) let bottomRightCorner1 = CGPoint( x: bottomRightCircleCenter.x + triangleLeg, y: bottomRightCircleCenter.y - triangleLeg ) // let bottomRightCorner2 = CGPoint( // x: bottomRightCircleCenter.x - triangleLeg, // y: bottomRightCircleCenter.y + triangleLeg // ) path.addLine(to: bottomRightCorner1) path.addArc( center: bottomRightCircleCenter, radius: circleRadius, startAngle: .degrees(315), endAngle: .degrees(135), clockwise: false ) } do { // MARK: Bottom Center var center = rect.center center.y += centerOffset path.addLine(to: center) } do { // MARK: Bottom Left let bottomLeftCircleCenter = CGPoint( x: rect.minX + lineWidth / 2, y: rect.maxY - lineWidth / 2 ) let bottomLeftCorner1 = CGPoint( x: bottomLeftCircleCenter.x + triangleLeg, y: bottomLeftCircleCenter.y + triangleLeg ) // let bottomLeftCorner2 = CGPoint( // x: bottomLeftCircleCenter.x - triangleLeg, // y: bottomLeftCircleCenter.y - triangleLeg // ) path.addLine(to: bottomLeftCorner1) path.addArc( center: bottomLeftCircleCenter, radius: circleRadius, startAngle: .degrees(45), endAngle: .degrees(225), clockwise: false ) } do { // MARK: Left Center var center = rect.center center.x -= centerOffset path.addLine(to: center) } return path } } extension CGRect { var center: CGPoint { CGPoint(x: self.midX, y: self.midY) } } struct XShape_Previews: PreviewProvider { static var previews: some View { XShape(thickness: .absolute(20)) .frame(width: 200, height: 200) .padding(2) .border(Color.green.opacity(0.5), width: 2) .padding() } }