最近接了个需求,用iOS端的原生代码完成一个随机曲线的制作,杂乱的手势交互处理咱们先放在一边,本篇首要想记载一下曲线制作的算法。

首要咱们需求知道有哪些具体要求

  1. 开始点和开始线条固定
  2. 制作区域有规模限定
  3. 线条制作需求有随机性,每次都不同
  4. 随机的线条需求确保必定的漂亮性
  5. 传入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)

在各种测验往后,发现弧线的制作更契合需求。原本也试过使用二次/三次贝塞尔曲线,发现漂亮性上不如弧线,且上一段和下一段曲线的衔接处不太好处理(调研时间有限,也许是能够的,以后再研究)。那么咱们就需求对圆弧的制作有一些根本的数学了解。

弧度与视点

首要,咱们要对弧度的表明有根本了解,如下图所示

用Swift完成贝塞尔曲线游戏

// 视点 -> 弧度
func degreeToRadian(_ number: Double) -> Double {
  return number * .pi / 180
}
// 弧度 -> 视点
func radianToDegree(_ number: Double) -> Double {
  return number * 180 / .pi
}

CGPath的长度核算

iOS原生的UIKitCoreGraphics库中都无法直接获取CGPath的长度,所以我目前直接用了一个bezierpath-length库中的扩展来核算。感兴趣的小伙伴能够去学习一下具体实现,这边不多赘述了。

计划敲定

根本计划描述如下

  1. 经过传入的duration和speed得到终究需求制作的总长度

  2. 制作多段曲线,终究拼接在一起。

    1. 已知开始途径(依据已知信息核算得出)
    2. 随机取得下一条途径的半径弧度(边界条件特别处理)
    3. 为了确保衔接处的丝滑,经过上一条弧线圆心结尾确认下一条弧线的圆心。
    4. 下一条的制作方向与上一条相反

“下一条途径”的核算如图所示

用Swift完成贝塞尔曲线游戏

具体算法阐明

startAngle与endAngle

因为每次传入的只要开始点坐标(即上一条途径的结尾)、圆心坐标与半径,咱们需求核算出startAngleendAngle,算法如下

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(开始点和开始线条固定)已知

  1. 开始途径的开始点:CGPoint(x: 80, y: 517)
  2. 开始途径的圆心:CGPoint(x: startPoint.x + startRadius, y: startPoint.y)
  3. 开始途径圆弧的半径:80pt
  4. 开始途径的全体制作视点:120度
  5. 开始途径的制作方向: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

那么为了创立该条途径,咱们需求核算的是:

  1. 开始点视点startAngle
  2. 结尾视点endEngle
let startAngle = calStartAngle(startPoint: startPoint,
                               centerPoint: center,
                               radius: startRadius)
let endOffset = angle / 180 * Double.pi
let endAngle: CGFloat = startAngle + endOffset

用Swift完成贝塞尔曲线游戏

剩下途径的核算

对于剩下的途径,已知条件有所不同

  1. 随机取得的半径,在kRadiusList中随机取一个值,其实也能够直接取某个规模内的随机值,这里首要是为了必定的漂亮性,所以暂时固定在几个值中获取
let kWidth = UIScreen.main.bounds.width
let kRadiusList = [kWidth / 4, kWidth / 3, kWidth / 10, kWidth / 12]
  1. 随机取得的圆弧全体视点。
let kAngleList: [CGFloat] = [180, 210, 240, 270, 300]
  1. 制作方向。(为上一条途径制作方向的反方向,因为已知开始途径是顺时针的,所以剩下途径的制作方向都是能够获取的)
  2. 开始点:也就是上一条途径的结尾,能够依据UIBezierPathcurrentPoint属性取得

核算流程

  1. 圆心方位:当咱们得到上述条件之后,能够算得下一个圆的圆心方位
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
  }
}
  1. 安全区域判断。若下一个圆的方位超出了安全区域,则将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
  }
}
  1. 如上述代码展示,需求记载legalFlag,若曾经经历过从头核算,阐明原半径偏大,则代表行将靠近安全区域的边界,则将angle设置为330度(让道路往反转)
let path = UIBezierPath(start: nextStart,
                              center: nextCenter,
                              radius: nextRadius,
                              clockWise: tempClockWise,
                              angle: legalFlag ? kAngleList.random()! : 330)
  1. 最终一条途径。若上面的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)
    }

优化点

  1. 摒弃视点,只用弧度
  2. 在拼接完途径开始制作之后,points能够清除
  3. 核算时的autorelease
  4. 异常状况的处理。现在比较暴力,其实对于angle和radius的取值上能够再优化,比方应该先算得目前的合法radius,再在规模中取值等等。

参考

UIBezierPath长度核算:bezierpath-length