最近接了个需求,用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