最近接了个需求,用iOS端的原生代码完成一个随机曲线的制作,杂乱的手势交互处理咱们先放在一边,本篇首要想记载一下曲线制作的算法。
首要咱们需求知道有哪些具体要求
- 开始点和开始线条固定
- 制作区域有规模限定
- 线条制作需求有随机性,每次都不同
- 随机的线条需求确保必定的漂亮性
- 传入
duration
(制作时间),制作速度speed
为15pt/s
前期准备
UIBezierPath
因为笔者没做过相似的UI需求,所以去学习了一下曲线制作。在iOS中,特别线条和形状的制作根本都是依托贝塞尔曲线。UIBezierPath
封装了CGPath
数据类型。一个UIBezierPath
目标定义一个完好的途径(一个/多个子途径)
// 椭圆
public convenience init(ovalIn rect: CGRect)
// 圆角矩形
public convenience init(roundedRect rect: CGRect,
cornerRadius: CGFloat)
// 圆角矩形。依据一个Rect针对四角中的某个或多个角设置圆角
public convenience init(roundedRect rect: CGRect,
byRoundingCorners corners: UIRectCorner,
cornerRadii: CGSize)
// 弧线。依据指定圆心,指定半径、开始弧度、制作方向。
public convenience init(arcCenter center: CGPoint,
radius: CGFloat,
startAngle: CGFloat,
endAngle: CGFloat,
clockwise: Bool)
// 依据CGPath创立并回来一个新的UIBezierPath目标
public convenience init(cgPath CGPath: CGPath)
// 三次贝塞尔曲线。经过moveToPoint:设置开始端点,endPoint为终止端点。以及两个控制点。
open func addCurve(to endPoint: CGPoint,
controlPoint1: CGPoint,
controlPoint2: CGPoint)
// 二次贝塞尔曲线。经过moveToPoint:设置开始端点。endPoint为终止端点,controlPoint为控制点。
open func addQuadCurve(to endPoint: CGPoint,
controlPoint: CGPoint)
在各种测验往后,发现弧线的制作更契合需求。原本也试过使用二次/三次贝塞尔曲线,发现漂亮性上不如弧线,且上一段和下一段曲线的衔接处不太好处理(调研时间有限,也许是能够的,以后再研究)。那么咱们就需求对圆弧的制作有一些根本的数学了解。
弧度与视点
首要,咱们要对弧度的表明有根本了解,如下图所示
// 视点 -> 弧度
func degreeToRadian(_ number: Double) -> Double {
return number * .pi / 180
}
// 弧度 -> 视点
func radianToDegree(_ number: Double) -> Double {
return number * 180 / .pi
}
CGPath的长度核算
iOS原生的UIKit
或CoreGraphics
库中都无法直接获取CGPath
的长度,所以我目前直接用了一个bezierpath-length库中的扩展来核算。感兴趣的小伙伴能够去学习一下具体实现,这边不多赘述了。
计划敲定
根本计划描述如下
-
经过传入的duration和speed得到终究需求制作的总长度
-
制作多段曲线,终究拼接在一起。
- 已知开始途径(依据已知信息核算得出)
- 随机取得下一条途径的半径和弧度(边界条件特别处理)
- 为了确保衔接处的丝滑,经过上一条弧线的圆心和结尾确认下一条弧线的圆心。
- 下一条的制作方向与上一条相反
“下一条途径”的核算如图所示
具体算法阐明
startAngle与endAngle
因为每次传入的只要开始点坐标(即上一条途径的结尾)、圆心坐标与半径,咱们需求核算出startAngle
与endAngle
,算法如下
startPoint
的视点核算其实就是已知三角形三个点坐标,核算夹角视点的问题。
func calStartAngle(startPoint:CGPoint,
centerPoint:CGPoint,
radius: CGFloat) -> Double {
// 此处endPoint为辅佐点
let endPoint = CGPoint(x: centerPoint.x + radius,
y: centerPoint.y)
//扫除特别状况,三个点一条线
if (startPoint.x == centerPoint.x && centerPoint.x == endPoint.x) ||
(startPoint.y == centerPoint.x && centerPoint.x == endPoint.x) {
return 0
}
let x1 = startPoint.x - centerPoint.x
let y1 = startPoint.y - centerPoint.y
let x2 = endPoint.x - centerPoint.x // radius
let y2 = endPoint.y - centerPoint.y // 0
let x = x1 * x2 + y1 * y2
let y = x1 * y2 - x2 * y1
var angle = acos(x / sqrt(x * x + y * y))
// 钝角
if startPoint.y < centerPoint.y {
angle = -angle
}
return angle
}
func calEndAngle(startAngle: Double,
clockWise: BOOL) -> Double {
let endOffset = angle / 180 * Double.pi
let endAngle: CGFloat = clockWise ? (startAngle + endOffset) : (startAngle - endOffset)
}
UIBezierPath
初始化方法
终究全体的UIBezierPath
途径 = 一个固定的开始UIBezierPath
+ 多个随机核算得出的UIBezierPath
所以依据已有数据,咱们需求如下的UIBezierPath
初始化方法
extension UIBezierPath {
public convenience init(start: CGPoint,
center: CGPoint,
radius: CGFloat,
clockWise: Bool,
angle: CGFloat) {
let startAngle = center.calAngleInCircle(pointInCircle: start,
radius: radius)
let endOffset = angle / 180 * Double.pi
let endAngle: CGFloat = clockWise ? (startAngle + endOffset) : (startAngle - endOffset)
self.init(arcCenter: center,
radius: radius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: clockWise)
}
}
开始途径的核算
依据需求1(开始点和开始线条固定)已知
- 开始途径的开始点:
CGPoint(x: 80, y: 517)
- 开始途径的圆心:
CGPoint(x: startPoint.x + startRadius, y: startPoint.y)
- 开始途径圆弧的半径:80pt
- 开始途径的全体制作视点:120度
- 开始途径的制作方向:
clockWise
(顺时针)
如下所示
let startPoint = CGPoint(x: 80,
y: 517)
let startRadius: CGFloat = 80 * 3 / 2 / Double.pi
let center = CGPointMake(startPoint.x + startRadius,
startPoint.y)
let angle = 120
那么为了创立该条途径,咱们需求核算的是:
- 开始点视点
startAngle
- 结尾视点
endEngle
let startAngle = calStartAngle(startPoint: startPoint,
centerPoint: center,
radius: startRadius)
let endOffset = angle / 180 * Double.pi
let endAngle: CGFloat = startAngle + endOffset
剩下途径的核算
对于剩下的途径,已知条件有所不同
- 随机取得的半径,在
kRadiusList
中随机取一个值,其实也能够直接取某个规模内的随机值,这里首要是为了必定的漂亮性,所以暂时固定在几个值中获取
let kWidth = UIScreen.main.bounds.width
let kRadiusList = [kWidth / 4, kWidth / 3, kWidth / 10, kWidth / 12]
- 随机取得的圆弧全体视点。
let kAngleList: [CGFloat] = [180, 210, 240, 270, 300]
- 制作方向。(为上一条途径制作方向的反方向,因为已知开始途径是顺时针的,所以剩下途径的制作方向都是能够获取的)
- 开始点:也就是上一条途径的结尾,能够依据
UIBezierPath
的currentPoint
属性取得
核算流程
- 圆心方位:当咱们得到上述条件之后,能够算得下一个圆的圆心方位
var nextCenter = prevCenter.calDestination(pastPoint: nextStart,
nextRadius: nextRadius)
extension CGPoint {
/// self为center point,核算视点
/// - Parameters:
/// - p2: 两圆相交点坐标
/// - r2: 下一个圆的圆心
/// - Returns: 结尾(即下一个圆心方位)坐标
func calDestination(pastPoint p2: CGPoint,
nextRadius r2: CGFloat) -> CGPoint {
let p1 = self
let prevRadius = sqrt(pow(p2.x - p1.x, 2) + pow(p2.y - p1.y, 2))
let yOffset1 = abs(p2.y - p1.y)
let xOffset1 = abs(p2.x - p1.x)
if xOffset1 == 0 {
let nextCenterY = (p2.y > p1.y) ? (p2.y + r2) : (p2.y - r2)
return CGPoint(x: p1.x,
y: nextCenterY)
}
if yOffset1 == 0 {
let nextCenterX = (p2.x > p1.x) ? (p2.x + r2) : (p2.x - r2)
return CGPoint(x: nextCenterX,
y: p1.y)
}
let xOffset2 = xOffset1 * (prevRadius + r2) / prevRadius
let yOffset2 = yOffset1 * (prevRadius + r2) / prevRadius
let nextCenterX = (p2.x < p1.x) ? ceil(p1.x - xOffset2) : ceil(p1.x + xOffset2)
let nextCenterY = (p2.y < p1.y) ? ceil(p1.y - yOffset2) : ceil(p1.y + yOffset2)
let nextCenter = CGPoint(x: nextCenterX,
y: nextCenterY)
return nextCenter
}
}
- 安全区域判断。若下一个圆的方位超出了安全区域,则将radius取二分之一再次核算和判断,直到确保下一个圆彻底在安全区域内停止
while (!pointSafeArea.rangeJudgeLegal(center: nextCenter,
radius: nextRadius)) {
legalFlag = false
nextRadius = nextRadius / 2
nextCenter = prevCenter.calDestination(pastPoint: nextStart,
nextRadius: nextRadius)
}
extension CGRect {
func rangeJudgeLegal(center: CGPoint,
radius: CGFloat) -> Bool {
if (center.x - radius < minX ) ||
(center.x + radius > maxX ) ||
(center.y - radius < minY ) ||
(center.y + radius > maxY ) {
return false
}
return true
}
}
- 如上述代码展示,需求记载legalFlag,若曾经经历过从头核算,阐明原半径偏大,则代表行将靠近安全区域的边界,则将angle设置为330度(让道路往反转)
let path = UIBezierPath(start: nextStart,
center: nextCenter,
radius: nextRadius,
clockWise: tempClockWise,
angle: legalFlag ? kAngleList.random()! : 330)
- 最终一条途径。若上面的path创立后,核算当时全部途径长度若已经超越指定途径总长,则核算出angle,从头创立
UIBezierPath
并跳出循环
if (currentLength + path.cgPath.length > wholeLengthWithoutStart) {
let leftLength = wholeLengthWithoutStart - currentLength
let angle = (leftLength / (Double.pi * 2 * nextRadius)) * 180 / .pi
let lastPath = UIBezierPath(start: nextStart,
center: nextCenter,
radius: nextRadius,
clockWise: tempClockWise,
angle: legalFlag ? kAngleList.random()! : 330)
tempList.append(lastPath)
break
}
异常状况
暴力处理。在上面核算边界状况时,会碰到一些异常状况,比方算得结尾为NaN,所以在这里回来nil。并在外部判断和从头核算。
if (__inline_isnand(path.currentPoint.x) != 0) {
return nil
}
// 外部
var leftPaths = startPath.subPaths(startClockWise: true,
startCenter: startCenter,
wholeLengthWithoutStart: lengthNeededToRun,
pointSafeArea: pointSafeArea)
while leftPaths == nil {
leftPaths = startPath.subPaths(startClockWise: true,
startCenter: startCenter,
wholeLengthWithoutStart: lengthNeededToRun,
pointSafeArea: pointSafeArea)
}
优化点
- 摒弃视点,只用弧度
- 在拼接完途径开始制作之后,points能够清除
- 核算时的autorelease
- 异常状况的处理。现在比较暴力,其实对于angle和radius的取值上能够再优化,比方应该先算得目前的合法radius,再在规模中取值等等。
参考
UIBezierPath长度核算:bezierpath-length