Files
blob-menu/Sources/Views/StickyPathGenerator.swift

365 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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
}
}