// // StickyPathGenerator.swift // BlobMenu // // Created by Igor K. on 29.04.2020. // Copyright © 2020 Ramotion. All rights reserved. // import Foundation import SwiftUI public final class StickyPathGenerator { static let `default` = StickyPathGenerator() private enum Direction { case up case left case right case down } private struct Input { let baseRect: CGRect let figureRect: CGRect let figureCornerRadius: CGFloat let avulsionDistance: CGFloat /// Calculated constants let figurePointA: CGPoint let figurePointB: CGPoint let basePointA: CGPoint let basePointB: CGPoint let initialBasePointA: CGPoint let initialBasePointB: CGPoint /// Distance between figure and base let distance: CGFloat init(baseRect: CGRect, figureRect: CGRect, figureCornerRadius: CGFloat, avulsionDistance: CGFloat) { self.baseRect = baseRect self.figureRect = figureRect self.figureCornerRadius = figureCornerRadius self.avulsionDistance = avulsionDistance figurePointA = CGPoint(x: figureRect.maxX, y: figureRect.minY) figurePointB = CGPoint(x: figureRect.maxX, y: figureRect.maxY) basePointA = CGPoint(x: baseRect.minX, y: max(baseRect.minY, figureRect.minY)) basePointB = CGPoint(x: baseRect.minX, y: min(baseRect.maxY, figureRect.maxY)) initialBasePointA = basePointA initialBasePointB = basePointB distance = baseRect.minX - figureRect.maxX } } ///Bool flag to control shape constriction private var stopConstriction: Bool = false private var apexControlUp: CGPoint = CGPoint.zero private var apexControlDown: CGPoint = CGPoint.zero private var storedBasePointA: CGPoint? private var storedBasePointB: CGPoint? public func generatePath(baseRect: CGRect, figureRect: CGRect, figureCornerRadius: CGFloat, avulsionDistance: CGFloat) -> CGPath { let input = Input(baseRect: baseRect, figureRect: figureRect, figureCornerRadius: figureCornerRadius, avulsionDistance: avulsionDistance) return generateShapePath(input: input).cgPath } /// Calculate Base Line Points for shape /// - used movement on Xaxis /// - return (point0, point3) private func calculateBaseLinePoints(input: Input) -> (CGPoint, CGPoint) { let distance = max(0, input.distance) let pY = { (from: CGFloat, to: CGFloat, lineSegment: CGFloat) -> CGFloat in //calculate movement coeficient from start to finish while distance <= avulsion let coef = abs(to - from) / lineSegment let p = from < to ? from + coef * distance : from - coef * distance return p } let movement = (input.figurePointA.y - input.initialBasePointA.y) let cornerRadius = input.figureCornerRadius //point0 let startY0 = max(input.baseRect.minY, input.initialBasePointA.y - cornerRadius + movement) let finishY0 = max(input.baseRect.minY, input.initialBasePointA.y + cornerRadius + movement) let point0 = CGPoint(x: input.initialBasePointA.x, y: pY(startY0, finishY0, input.avulsionDistance)) //point3 let startY3 = min(input.baseRect.maxY, input.initialBasePointB.y + cornerRadius + movement) let finishY3 = min(input.baseRect.maxY, input.initialBasePointB.y - cornerRadius + movement) let point3 = CGPoint(x: input.initialBasePointB.x, y: pY(startY3, finishY3, input.avulsionDistance)) //set limit for movement let limitDistanceBetwenPoints = input.figureRect.height - cornerRadius * 2 if stopConstriction || point3.y - point0.y < limitDistanceBetwenPoints, let pA = storedBasePointA, let pB = storedBasePointB { //in that case use stored values return (pA, pB) } return (point0, point3) } /// Calculate points of intercsection betwen two circles /// The solution is to find tangents by calculation of intersection of two circles /// https://www.mathsisfun.com/geometry/construct-circletangent.html /// Intersection of two circles /// Discussion http://stackoverflow.com/questions/3349125/circle-circle-intersection-points /// Description http://paulbourke.net/geometry/circlesphere/ private func findIntersection(centerCircle1 c1: CGPoint, radiusCircle1 c1r: CGFloat, centerCircle2 c2: CGPoint) -> (CGPoint, CGPoint) { //Calculate distance between centres of circle let d = (c1 - c2).length let c2r = d //in our case let m = c1r + c2r var n = c1r - c2r if (n < 0) { n = n * -1 } //No solns if (d > m) { return (CGPoint.zero, CGPoint.zero) } //Circle are contained within each other if (d < n) { return (CGPoint.zero, CGPoint.zero) } //Circles are the same if (d == 0 && c1r == c2r) { return (CGPoint.zero, CGPoint.zero) } let a = (c1r * c1r - c2r * c2r + d * d) / (2 * d) let h = sqrt(c1r * c1r - a * a) //Calculate point p, where the line through the circle intersection points crosses the line between the circle centers. var x = c1.x + (a / d) * (c2.x - c1.x) var y = c1.y + (a / d) * (c2.y - c1.y) let p = CGPoint(x: x, y: y) //1 Intersection , circles are touching if (d == c1r + c2r) { return (p, CGPoint.zero) } //2 Intersections //Intersection 1 x = p.x + (h / d) * (c2.y - c1.y) y = p.y - (h / d) * (c2.x - c1.x) let p1 = CGPoint(x: x, y: y) //Intersection 2 x = p.x - (h / d) * (c2.y - c1.y) y = p.y + (h / d) * (c2.x - c1.x) let p2 = CGPoint(x: x, y: y) return (p1, p2) } /// Calculate left top point of shape /// - used method of intersection betwen two circles /// - first circle - centerfigCircle /// - second circle - circle from controlPoint private func calculateShapePoint1(input: Input, cp:CGPoint, point0:CGPoint) -> CGPoint { let cornerRadius = input.figureCornerRadius var centerfigCircle = CGPoint(x: input.figurePointA.x - cornerRadius, y: input.figurePointA.y + cornerRadius) centerfigCircle.x = point0.x - centerfigCircle.x < cornerRadius ? point0.x - cornerRadius : centerfigCircle.x centerfigCircle.x = max(centerfigCircle.x, input.figurePointA.x + cornerRadius - input.figureRect.width) let x3 = (centerfigCircle.x + cp.x) / 2 let y3 = (centerfigCircle.y + cp.y) / 2 let c1 = centerfigCircle let c2 = CGPoint(x: x3, y: y3) let c1r = cornerRadius var (p1, _) = findIntersection(centerCircle1: c1, radiusCircle1: c1r, centerCircle2: c2) //when something wrong in findIntersection pass top center point if p1 == CGPoint.zero { p1 = CGPoint(x: centerfigCircle.x, y: centerfigCircle.y - cornerRadius) } return p1 } /// Calculate left bottom point of shape /// - used method of intersection betwen two circles /// - first circle - centerfigCircle /// - second circle - circle from controlPoint private func calculateShapePoint2(input: Input, cp:CGPoint, point3:CGPoint) -> CGPoint { let cornerRadius = input.figureCornerRadius var circleCenter = CGPoint(x: input.figurePointB.x - cornerRadius, y: input.figurePointB.y - cornerRadius) circleCenter.x = point3.x - circleCenter.x < cornerRadius ? point3.x - cornerRadius : circleCenter.x circleCenter.x = max(circleCenter.x, input.figurePointA.x + cornerRadius - input.figureRect.width) let x3 = (circleCenter.x + cp.x) / 2 let y3 = (circleCenter.y + cp.y) / 2 let c1 = circleCenter let c2 = CGPoint(x: x3, y: y3) let c1r = cornerRadius var (_, p2) = findIntersection(centerCircle1: c1, radiusCircle1: c1r, centerCircle2: c2) if p2 == CGPoint.zero { p2 = CGPoint(x: circleCenter.x, y: circleCenter.y - cornerRadius) } return p2 } /// Calculate 2 control points for Bezier Path /// - concavity depends of distance /// - used movement for concavity effect private func calculateControlPoints(input: Input, basePoint: CGPoint, direction: Direction) -> (CGPoint, CGPoint) { let k: CGFloat = (input.figureRect.width > input.figureRect.height) ? 1.05 : 1.3 var y: CGFloat = 0 if direction == .down { let controlPointСoncavity = input.distance + input.distance * 0.05 let deltaY = abs(basePoint.y - input.figurePointA.y) / k y = basePoint.y + controlPointСoncavity y = max(basePoint.y + deltaY, y) } else if direction == .up { let controlPointСoncavity = max(0, input.distance + input.distance * 0.05) let deltaY = abs(basePoint.y - input.figurePointB.y) / k y = basePoint.y - controlPointСoncavity y = min(basePoint.y - deltaY, y) } let cp1 = CGPoint(x: basePoint.x, y: y) let cp2 = CGPoint(x: min(basePoint.x, input.figurePointA.x + input.distance / 4), y: cp1.y) return (cp1, cp2) } //MARK: - Generate main path /* vertical scheme: point1 |------------------------------| point2 | | | -cpUp2 cpDown2- | | | | -cpUp1 cpDown1- | point0 |______________________________| point3 horizontal scheme: point1 _____________________ point0 | cpTop2 cpTop1 | | | | | | | | cpBottom2 cpBottom1 | point2 |_____________________| point3 */ /// Generate shape path /// - calculate baseLinePoints (point0, point3) /// - calculate control points /// - calculate topLinePoints (point1, point2) /// - constrinction control /// - draw the shape by points private func generateShapePath(input: Input) -> UIBezierPath { let distance = max(0, input.distance) //calculate base points let (point0, point3) = calculateBaseLinePoints(input: input) //calculete control points var (cpUp1, cpUp2) = calculateControlPoints(input: input, basePoint: point0, direction: .down) var (cpDown1, cpDown2) = calculateControlPoints(input: input, basePoint: point3, direction: .up) var point1 = calculateShapePoint1(input: input, cp: cpUp2, point0: point0) var point2 = calculateShapePoint2(input: input, cp: cpDown2, point3: point3) //constriction control let upPoints = BezierUtilities.getCubeCurvePoints(p0: point0, p1: cpUp1, p2: cpUp2, p3: point1) let downPoints = BezierUtilities.getCubeCurvePoints(p0: point2, p1: cpDown2, p2: cpDown1, p3: point3) //search for curve cross var crossed = false for up in upPoints { for dp in downPoints { let delta = dp.y - up.y if delta <= 1 { crossed = true break } } } self.stopConstriction = crossed //if true - we'll use old values if crossed { cpUp2.y = apexControlUp.y cpUp1.y = apexControlUp.y cpDown2.y = apexControlDown.y cpDown1.y = apexControlDown.y point1 = calculateShapePoint1(input: input, cp: cpUp2, point0: point0) point2 = calculateShapePoint2(input: input, cp: cpDown2, point3: point3) } else { storedBasePointA = point0 storedBasePointB = point3 apexControlUp = cpUp2 apexControlDown = cpDown2 } //Do not need to draw shape under baseLine or after avulsion if distance <= -input.figureRect.width || distance > input.avulsionDistance { stopConstriction = false point1 = CGPoint(x: point0.x, y: point0.y) point2 = CGPoint(x: point0.x, y: point0.y + input.figureRect.height) cpUp1 = point0 cpUp2 = point0 cpDown1 = point3 cpDown2 = point3 } let curvePath = UIBezierPath() curvePath.move(to: point0) curvePath.addCurve(to: point1, controlPoint1: cpUp1, controlPoint2: cpUp2) if crossed { curvePath.addLine(to: point2) } else { let center = input.figureRect.center let dc = CGPoint(x: center.x - 0.5, y: center.y) // to fix small gap between menu and arc let start = atan((point1.y - center.y) / (point1.x - center.x)) let end = atan((point2.y - center.y) / (point2.x - center.x)) curvePath.addArc(withCenter: dc, radius: input.figureCornerRadius, startAngle: start, endAngle: end, clockwise: true) curvePath.addLine(to: point2) } curvePath.addCurve(to: point3, controlPoint1: cpDown2, controlPoint2: cpDown1) return curvePath } }