在 315° 圆弧上绘制圆角

Drawing rounders corners on 315° arc

我正在画一个 270° 的圆弧,两端都是圆角。这工作正常,但现在我想将我的拱形更改为 315°(-45°),但我的角计算将不起作用。

我尝试过计算这种不同的方法,但似乎无法找到当起点和终点不是垂直或水平时为我的圆弧添加圆角的通用函数的公式。

这是我的游乐场代码:

import UIKit
import PlaygroundSupport

class ArcView: UIView {

  private var strokeWidth: CGFloat {
    return CGFloat(min(self.bounds.width, self.bounds.height) * 0.25)
  }
  private let cornerRadius: CGFloat = 10

  override open func draw(_ rect: CGRect) {
    super.draw(rect)
    backgroundColor = UIColor.white

    drawNormalCircle()
  }

  func drawNormalCircle() {
    let strokeWidth = CGFloat(min(self.bounds.width, self.bounds.height) * 0.25)
    let innerRadius = (min(self.bounds.width, self.bounds.height) - strokeWidth*2) / 2.0
    let outerRadius = (min(self.bounds.width, self.bounds.height)) / 2.0

    var endAngle: CGFloat = 270.0
    let bezierPath = UIBezierPath(arcCenter: self.center, radius: outerRadius, startAngle: 0, endAngle: endAngle * .pi / 180, clockwise: true)

    var point = bezierPath.currentPoint
    point.y += cornerRadius
    let arc = UIBezierPath(arcCenter: point, radius: cornerRadius, startAngle: 180 * .pi / 180, endAngle: 270 * .pi / 180, clockwise: true)
    arc.apply(CGAffineTransform(rotationAngle: (360 - endAngle) * .pi / 180))

    var firstCenter = bezierPath.currentPoint
    firstCenter.y += cornerRadius

    bezierPath.addArc(withCenter: firstCenter, radius: cornerRadius , startAngle: 270 * .pi / 180 , endAngle: 0, clockwise: true)
    bezierPath.addLine(to: CGPoint(x: bezierPath.currentPoint.x, y: strokeWidth - cornerRadius))

    var secondCenter = bezierPath.currentPoint
    secondCenter.x -= cornerRadius
    bezierPath.addArc(withCenter: secondCenter, radius: cornerRadius , startAngle: 0, endAngle: 90 * .pi / 180, clockwise: true)
    bezierPath.addArc(withCenter: self.center, radius: innerRadius, startAngle: 270 * .pi / 180, endAngle: 0, clockwise: false)

    var thirdCenter = bezierPath.currentPoint
    thirdCenter.x += cornerRadius
    bezierPath.addArc(withCenter: thirdCenter, radius: cornerRadius , startAngle: 180 * .pi / 180, endAngle: 270 * .pi / 180, clockwise: true)

    bezierPath.addLine(to: CGPoint(x: bezierPath.currentPoint.x + strokeWidth - (cornerRadius * 2), y: bezierPath.currentPoint.y))

    var fourthCenter = bezierPath.currentPoint
    fourthCenter.y += cornerRadius
    bezierPath.addArc(withCenter: fourthCenter, radius: cornerRadius , startAngle: 270 * .pi / 180, endAngle: 0, clockwise: true)

    bezierPath.close()

    let backgroundLayer = CAShapeLayer()
    backgroundLayer.path = bezierPath.cgPath
    backgroundLayer.strokeColor = UIColor.red.cgColor
    backgroundLayer.lineWidth = 2
    backgroundLayer.fillColor = UIColor.lightGray.cgColor

    self.layer.addSublayer(backgroundLayer)
  }
}

let arcView = ArcView(frame: CGRect(x: 0, y: 0, width: 400, height: 400))
PlaygroundPage.current.liveView = arcView

我的问题是当角不是给定的 X - CornerRadius 或 Y + 角半径时,如何计算角的圆弧中心,它是完全水平或垂直的情况。圆弧315°怎么会有圆角

前言:通常当我对一个问题的回答是"do something completely different,"时,我的目标是按原样修复原始问题,然后另外提出更好的方法。但是,这对于这个代码来说是不可行的,因为如果以相同的样式扩展此代码,其复杂性会大大增加,以至于不值得这样做。

这里的问题基本上是代码组织的问题。许多表达式被 copy-pasting 重复。将它们提取到一个变量中不仅可以为编辑提供一个中心位置,而且还可以为表达式命名,从而极大地提高可读性。

这段代码会很长。但这没关系,因为它会很简单。拥有一堆简单的东西几乎总是胜过少量复杂的东西。您也许能够编写一些疯狂的三角代码来完美地布置您的贝塞尔曲线,但很有可能,您不会一次就把它做对。调试会很困难。这将是完全陌生的,对任何不是你的人来说都更难……包括未来的你。未来你会奋斗。

在我们开始之前,这里有一个粗略的图表可以帮助您了解其余部分 post:

可视化开发环境

首先,我们需要建立一种可视化结果的好方法。

Playground 可以快速重新加载预览,这是一个优点。但是使用闭合路径进行调试很困难,因为很难区分贝塞尔曲线路径的各个部分,而且对于凸形,路径通常会以一种方式自行闭合,从而掩盖了您正在处理的路径部分.所以我要处理的代码的第一部分是 UIBezierPath

的抽象层

为了解决这个问题,我将通过用不同颜色抚摸每个部分来开发形状。不幸的是,您不能将 UIBezierPath 的子部分与其余部分分开进行描边,因此要实现这一点,我们的形状将需要由多个 UIBezierPath 组成,并在进行过程中逐一描边。但这在 performance-sensitive 上下文中可能会很慢,因此理想情况下我们只想在开发期间这样做。我希望能够在两种不同的方法中选择一种来做同样的事情。协议对此非常完美,所以让我们从这里开始吧。

我将从 BezierPathBuilder 开始。它所做的就是允许我将 BezierPathRenderable 部分附加到它(我稍后会谈到),并构建一个最终路径,我可以将其交给我的 CALayer 或其他任何东西。

protocol BezierPathBuilder: AnyObject {
    func append(_: BezierPathRenderable)
    func build() -> UIBezierPath
}

这个协议的主要实现很简单,就是包装了一个UIBezierPath。当 renderable 被告知渲染自己时,它会简单地在我们给它的路径上运行,而不需要分配任何中间路径。

class BezierPathBuilderImpl: BezierPathBuilder {
    let path = UIBezierPath()

    func append(_ renderable: BezierPathRenderable) {
        renderable.render(into: self, self.path)
    }
    func build() -> UIBezierPath {
        return path
    }
}

调试实现有点有趣。当附加一个 renderable 时,我们不让它自己直接绘制到我们的主路径中。相反,我们创建了一个新的临时路径供它使用,它会在那里绘制自己。然后我们有机会描边那条路径(每次都用不同的颜色)。完成后,我们可以将该临时路径附加到主路径,然后继续。

class DebugBezierPathBuilder: BezierPathBuilder {

    var rainbowIterator = ([
        .red, .orange, .yellow, .green, .cyan, .blue, .magenta, .purple ] as Array<UIColor>).makeIterator()

    let path = UIBezierPath()

    func append(_ renderable: BezierPathRenderable) {
        let newPathSegment = UIBezierPath()
        renderable.render(into: self, newPathSegment)

        // This will crash if you use too many colours, but it suffices for now.
        rainbowIterator.next()!.setStroke()
        newPathSegment.lineWidth = 20
        newPathSegment.stroke()

        path.append(newPathSegment)
    }

    func build() -> UIBezierPath {
        return path
    }
}

将我们的几何对象化

在您的代码中,几何计算和绘图之间没有分离。因此,您不能轻松地定义一个组件引用另一个组件,因为您没有办法 "fishing out" 您在 UIBezierPath 中绘制的最后一条弧线,或其他任何东西。所以让我们补救一下。

首先,我将定义一个协议,BezierPathRenderable,我们的程序将使用它来定义实体可渲染到 BezierPath 的意义。

protocol BezierPathRenderable {
    func render(into builder: BezierPathBuilder, _ path: UIBezierPath)
}

这个设计不是我最喜欢的,但它是我能想到的最好的。这里的两个参数允许符合类型直接将自己绘制到 path 中,或者在 builder 上调用 append。后者对于由更简单的成分组成的聚合形状很有用(听起来很熟悉?)

愿望导向发展

我最喜欢的编写代码的过程包括尽早搭建一大堆东西,编写我感兴趣的代码的任何部分,然后添加存根实现 left-and-right。在此过程中的每一步,我基本上都是在回答问题 "what API do I wish I had right now?",然后我将其存根,并假装它存在。

截短主要形状

我将从主要结构开始。我们想要一个模拟 sector of an annulus 圆角的对象。这个对象必须做两件事:

  1. 存储参数化形状的所有值
  2. 通过符合 BezierPathRenderable
  3. 定义渲染形状的方法

让我们从这里开始:

struct RoundedAnnulusSector: BezierPathRenderable {
    let center: CGPoint
    var innerRadius: CGFloat
    var outerRadius: CGFloat
    var startAngle: Angle
    var endAngle: Angle
    var cornerRadius: CGFloat

    func render(into builder: BezierPathBuilder, _ path: BezierPath) {
        /// ???
    }

正在准备一些渲染代码

让我们编写一些脚手架代码来使用我们的新渲染系统来绘制它。现在我将使用我们的调试笔触来渲染,所以我将注释掉 CAShapeLayer 内容:

import UIKit
import PlaygroundSupport

class ArcView: UIView {
    private var strokeWidth: CGFloat {
        return CGFloat(min(self.bounds.width, self.bounds.height) * 0.25)
    }

    override open func draw(_ rect: CGRect) {
        super.draw(rect)
        self.backgroundColor = UIColor.white

        let innerRadius = (min(self.bounds.width, self.bounds.height) - strokeWidth*2) / 2.0
        let outerRadius = (min(self.bounds.width, self.bounds.height)) / 2.0

        let shape = RoundedAnnulusSector(
            center: self.center,
            innerRadius: innerRadius - 50,
            outerRadius: outerRadius - 50,
            startAngle: (45 * .pi) / 180,
            endAngle: (315 * .pi) / 180,
            cornerRadius: 25
        )
        let builder = DebugBezierPathBuilder()
        builder.append(shape)
        let path = builder.build()

        let backgroundLayer = CAShapeLayer()
//      backgroundLayer.path = path.cgPath
//      backgroundLayer.strokeColor = UIColor.red.cgColor
//      backgroundLayer.lineWidth = 2
//      backgroundLayer.fillColor = UIColor.lightGray.cgColor

        self.layer.addSublayer(backgroundLayer)
    }
}

let arcView = ArcView(frame: CGRect(x: 0, y: 0, width: 800, height: 800))
PlaygroundPage.current.liveView = arcView

这显然没有任何作用,b因为我们还没有实现 RoundedAnnulusSector.render(into:_:).

拐弯抹角

我们可以注意到,整个图形的绘制都取决于它的 4 个角的细节。如果我们的形状有四个角,我们为什么不直接说呢?

struct RoundedAnnulusSector: BezierPathRenderable {
    // ...

    private var corner1: RoundedAnnulusSectorCorner { ??? }
    private var corner2: RoundedAnnulusSectorCorner { ??? }
    private var corner3: RoundedAnnulusSectorCorner { ??? }
    private var corner4: RoundedAnnulusSectorCorner { ??? }
}

在写这篇文章时,我希望存在一个名为 RoundedAnnulusSectorCorner 的结构,它可以做两件事:

  1. 存储参数化形状的所有值
  2. 通过符合 BezierPathRenderable
  3. 定义渲染形状的方法

请注意,这两个角色与 RoundedAnnulusSector 所扮演的角色相同。这些东西是有意设计的简单,并且是可组合的。

现在我们可以存根 RoundedAnnulusSectorCorner

struct RoundedAnnulusSectorCorner {}

...并将我们的计算属性填充到 return 默认实例。接下来我们要定义形状的内弧和外弧。

struct RoundedAnnulusSector: BezierPathRenderable {
    // ...

    private var corner1: RoundedAnnulusSectorCorner { return RoundedAnnulusSectorCorner() }
    private var corner2: RoundedAnnulusSectorCorner { return RoundedAnnulusSectorCorner() }
    private var corner3: RoundedAnnulusSectorCorner { return RoundedAnnulusSectorCorner() }
    private var corner4: RoundedAnnulusSectorCorner { return RoundedAnnulusSectorCorner() }
    private var outerArc: Arc { ??? }
    private var innerArc: Arc { ??? }
}

实施Arc

同样,Arc 只是另一种形状,它将履行与其他形状相同的两个角色。根据我们对 UIBezierPath 弧 API 的熟悉程度,我们会知道弧需要中心、半径、start/end 角度,以及是否顺时针绘制或 counter-clockwise 的指示符.所以我们可以填写:

struct Arc: BezierPathRenderable {
    let center: CGPoint
    let radius: CGFloat
    let startAngle: CGFloat
    let endAngle: CGFloat
    let clockwise: Bool

    func render(into builder: BezierPathBuilder, _ path: UIBezierPath) {
        path.addArc(withCenter: center, radius:  radius,
                startAngle: startAngle, endAngle: endAngle, clockwise: clockwise)
    }
}

innerArc/outerArc

的一阶近似

现在我们需要确定用于初始化弧的参数。我们将从不使用圆角开始,因此我们将直接使用我们的 startAngle/endAngle,以及我们的 innerRadius/outerRadius

struct RoundedAnnulusSector: BezierPathRenderable {
    // ...

    private var outerArc: Arc {
        return Arc(
            center: self.center,
            radius: self.outerRadius,
            startAngle: self.startAngle,
            endAngle: self.endAngle,
            clockwise: true
        )
    }

    private var innerArc: Arc {
        return Arc(
            center: self.center,
            radius: self.innerRadius,
            startAngle: self.endAngle,
            endAngle: self.startAngle,
            clockwise: false
        )
    }
}

初始渲染

完成这两个部分后,我们可以开始绘制以查看目前的效果,方法是对 RoundedAnnulusSector.render(into:_:)

进行初始实现
struct RoundedAnnulusSector: BezierPathRenderable {
    // ...

    func render(into builder: BezierPathBuilder, _ path: BezierPath) {
        let components: [BezierPathRenderable] = [
            self.outerArc,
            self.innerArc,
        ]
        builder.append(contentsOf: components)
    }

}

extension BezierPathBuilder {
    func append<S: Sequence>(contentsOf renderables: S) where S.Element == BezierPathRenderable {
        for renderable in renderables {
            self.append(renderable)
        }
    }
}

随着我们的进步,我们可以向此列表添加更多 BezierPathRenderable 组件。我看到了这一点,所以我制作了 BezierPathBuilder 来处理序列,这样我们就可以为它提供一个数组并让它自动附加其中的所有元素。

存根 startAngleEdgeendAngleEdge

这个形状需要两条直线。第一个将连接角 4 和角 1(这将是一条从中心沿 startAngle 向外的径向线),第二个将连接角 2 和角 3(这将是一条从中心向外的径向线沿着 endAngle。让我们把它们放在:

struct RoundedAnnulusSector: BezierPathRenderable {
    // ...

    func render(into builder: BezierPathBuilder, _ path: BezierPath) {
        let components: [BezierPathRenderable] = [
            self.outerArc,
            self.endAngleEdge,
            self.innerArc,
            self.startAngleEdge,
        ]
        builder.append(contentsOf: components)
    }

    // ...


    private var endAngleEdge: Line {
        return Line()
    }

    private var startAngleEdge: Line {
        return Line()
    }
}

实施Line

我们可以截断 Line,但我们知道一条线只连接两点。就这么简单,我们干脆完成吧:

struct Line: BezierPathRenderable {
    let start: CGPoint
    let end: CGPoint

    func render(into builder: BezierPathBuilder, _ path: BezierPath) {
        path.move(to: self.start)
        path.addLine(to: self.end)
    }
}

实施startAngleEdge/endAngleEdge

现在我们需要弄清楚我们的两条线的 start/end 点是多少。如果我们的 RoundedAnnulusSectorCorner 具有 startPoint: CGPointendPoint: CGPoint 属性,那将非常方便。

struct RoundedAnnulusSector: BezierPathRenderable {
    // ...

    private var endAngleEdge: Line {
        return Line(
            start: self.corner2.endPoint,
              end: self.corner3.startPoint)
    }

    private var startAngleEdge: Line {
        return Line(
            start: self.corner4.endPoint,
              end: self.corner1.startPoint)
    }
}

startAngleEdge/endAngleEdge

的一阶近似

来完成我们的愿望吧

struct RoundedAnnulusSector: BezierPathRenderable {
    // ...
    var startPoint: CGPoint { return .zero }
    var   endPoint: CGPoint { return .zero }
}

因为这些都是作为 CGPoint.zero 实现的,所以我们的边缘 none 会绘制。

startAngleEdge/endAngleEdge

的二次近似

所以让我们实现一些更好的 startPoint/endPoint 的近似值。假设我们的点有一个 rawCornerPoint: CGPoint。如果没有舍入(即舍入半径 = 0),这将是一个角点的位置。在 no-rounding 世界中,我们的 startPoint/endPoint 都将是 rawCornerPoint。让我们存根并使用它:

struct RoundedAnnulusSector: BezierPathRenderable {
    // ...

    var rawCornerPoint: CGPoint { return .zero }
    var startPoint: CGPoint { return self.rawCornerPoint }
    var   endPoint: CGPoint { return self.rawCornerPoint }
}

实施rawCornerPoint

现在,我们需要推导出它的真实价值。 rawCornerPoint 取决于两件事:

  1. 父图形的中心,
  2. 角的位置,相对于父图形的中心。这本身依赖于:
    1. 到父图形中心的距离
    2. 相对于父图形中心的角度

这些东西中的每一个都是我们父形状的参数,因此这些属性实际上将被存储(并由父形状初始化)。然后我们可以使用它们来计算偏移量,并将该偏移量添加到 parentCenter.

struct RoundedAnnulusSectorCorner {
    let parentCenter: CGPoint
    let distanceToParentCenter: CGFloat
    let angleToParentCenter: CGFloat

    var rawCornerPoint: CGPoint {
        let inset = CGPoint(
            radius: self.distanceToParentCenter,
            angle: self.angleToParentCenter
        )

        return self.parentCenter + inset
    }

    // ...
}

显然,CGPoint 的初始化程序从极坐标初始化它会很棒。

此外,写.applying(CGAffineTransform(translationX: deltaX, y: deltaY)很烦人,如果有一个+运算符就好了。

实施一些 CGPoint 实用程序

让我们实现更多的愿望:

// Follows UIBezierPath convention on angles.
// 0 is "right" at 3 o'clock, and angle increase clockwise.
extension CGPoint {
    init(radius: CGFloat, angle: CGFloat) {
        self.init(x: radius * cos(angle), y: radius * sin(angle))
    }


    static func + (l: CGPoint, r: CGPoint) -> CGPoint {
        return CGPoint(x: l.x + r.x, y: l.y + r.y)
    }

    static func - (l: CGPoint, r: CGPoint) -> CGPoint {
        return CGPoint(x: l.x - r.x, y: l.y - r.y)
    }
}

填充角落字段

现在我们的角实际上已经存储了属性,我们可以回到我们的 RoundedAnnulusSector 并填充它们。

struct RoundedAnnulusSector: BezierPathRenderable {
    // ...
    private var corner1: RoundedAnnulusSectorCorner {
        return RoundedAnnulusSectorCorner(
            parentCenter: self.center,
            distanceToParentCenter: self.outerRadius,
            angleToParentCenter: self.startAngle
        )
    }

    private var corner2: RoundedAnnulusSectorCorner {
        return RoundedAnnulusSectorCorner(
            parentCenter: self.center,
            distanceToParentCenter: self.outerRadius,
            angleToParentCenter: self.endAngle
        )
    }

    private var corner3: RoundedAnnulusSectorCorner {
        return RoundedAnnulusSectorCorner(
            parentCenter: self.center,
            distanceToParentCenter: self.innerRadius,
            angleToParentCenter: self.endAngle
        )
    }

    private var corner4: RoundedAnnulusSectorCorner {
        return RoundedAnnulusSectorCorner(
            parentCenter: self.center,
            distanceToParentCenter: self.innerRadius,
            angleToParentCenter: self.startAngle

        )
    }
// ...
}

二次渲染

我们的主要形状的渲染列表已经包含了我们的线条,但现在我们已经实现了它们的近似值,我们可以实际测试它们。如果到目前为止我已经正确解释了事情,那么此时应该有一个 closed-looking 形状,带有尖角。成功! (我希望)

四舍五入

听起来我们快完成了,但没办法哈哈,这就是好东西的开始。

首先,我们应该添加圆度附带的所有内容。我们知道我们的圆角将变成弧形,幸运的是,我们已经实现了se!

弧线需要起始角和终止角,所以我们也需要它们。我们可以用 0 对它们进行存根,这样我们就不必担心圆角弧的方向。现在,它们只是完整的圆圈。

struct RoundedAnnulusSectorCorner {
    // ...
    let radius: CGFloat

    var arc: Arc {
        return Arc(center: rawCornerPoint, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
    }

    /// The angle at which this corner's arc starts.
    var startAngle: CGFloat { return 0 }

    /// The angle at which this corner's arc ends.
    var endAngle: CGFloat { return 2 * .pi }
}

第三次渲染

现在我们的角有了圆弧,我们可以将这些圆弧添加到我们的渲染列表中进行绘制:

struct RoundedAnnulusSector: BezierPathRenderable {
    let center: CGPoint
    let innerRadius: CGFloat
    let outerRadius: CGFloat
    let startAngle: CGFloat
    let endAngle: CGFloat
    let cornerRadius: CGFloat

    func render(into builder: BezierPathBuilder, _ path: UIBezierPath) {
        let components: [BezierPathRenderable] = [
            self.corner1.arc,
            self.outerArc,
            self.corner2.arc,
            self.endAngleEdge,
            self.corner3.arc,
            self.innerArc,
            self.corner4.arc,
            self.startAngleEdge,
        ]
        builder.append(contentsOf: components)
    }
}

看,圆圈!

但是哦,它们以 rawCornerPoint 为中心。我想这应该不足为奇,因为这就是我们定义 Arc.

的字面意思

设置中心

但是要做到这一点,我们需要插入圆弧的中心。我们称它为 center。中心需要插入,这样它更接近 RoundedAnnulusSector 的 "inside",这样在添加圆角半径后,笔划与形状的其余部分对齐。

此插图由两部分组成: 1.圆心需要相对于parentCenter旋转一个角度(称之为rotationalInsetAngle),使其位于内弧或外弧上,使其弧旋转延伸到达半径

照片供参考:

  1. 蓝色圆圈是我们角的当前圆圈,以 rawCornerPoint.
  2. 为中心
  3. 标有1的小箭头是插图的旋转分量。
  4. 粉色圆圈是我们角的圆圈,在旋转插入之后。
  5. 标有2的小箭头是插图的径向平移分量
  6. 绿色圆圈是我们想要的角圆,以center为中心。
  7. 标记为 offset 的虚线箭头是 rawCornerPointcenter 之间的偏移量,作为旋转和径向平移分量的组合获得。
struct RoundedAnnulusSectorCorner {
    // ...
    /// The center of this rounded corner's arc
    ///
    /// ...after insetting from the `rawCornerPoint`, so that this rounded corner's arc
    /// aligns perfectly with the curves adjacent to it.
    var center: CGPoint {
        return self.rawCornerPoint
            .rotated(around: self.parentCenter, by: self.rotationalInsetAngle)
            .translated(towards: self.edgeAngle, by: self.radialInsetDistance)
    }
}

我们这里有一长串愿望清单:rotationalInsetAngleradialInsetDistanceCGPoint.rotated(around:by:)CGPoint.translated(towards:, by:).

更多 CGPoint 实用程序

幸运的是,现在我们有了极坐标初始化器,这些很容易实现。

extension CGPoint {
    func translated(towards angle: CGFloat, by r: CGFloat) -> CGPoint {
        return self + CGPoint(radius: r, angle: angle)
    }

    func rotated(around pivot: CGPoint, by angle: CGFloat) -> CGPoint {
        return (self - pivot).applying(CGAffineTransform(rotationAngle: angle)) + pivot
    }
}

卡在 rotationalInsetAngleradialInsetDistance

这就是狗屎击中粉丝的地方。我们知道我们必须通过将角平移 radius 来插入角。但是在哪个方向呢?

内部的两个角(#3 和#4)需要从父级远离 径向平移,而外部的两个角(#1 和#3)需要平移径向 向内 朝向父级。

同样,我们的旋转插图也需要变化。对于起始边上的两个角(#1 和#4),我们需要从 startEdge 顺时针插入 ,而结束边上的两个角(#2和 #3) 需要从 endEdge.

插入 counter-clockwise

但到目前为止,我们的数据模型仅根据角度和距离告诉我们角的位置。它没有指定相对于定义的 distanceFromCenterangleToParentCenter.

他们需要留在哪一边

这需要一些相当大的重构。

实施 RadialPositionRotationalPosition

让我们实现一个名为 RadialPosition 的类型。它不仅会捕获径向位置(即距离中心点的距离),还会捕获该距离的哪一侧到 "stay on"。一个包含 radialDistance: CGFloatisInsideOfRadialDistance: Bool 的结构就可以了,但我知道我经常因处理条件不当而产生很多错误。相反,我将使用两种情况的枚举,其中 inside/outside 的区别更明确,也更不容易错过。因为枚举关联值访问起来很麻烦,所以我将添加一个助手计算 属性、distanceFromCenter 来隐藏烦人的 switch 语句。

struct RoundedAnnulusSectorCorner {
   // ...
    enum RadialPosition {
        case outside(ofRadius: CGFloat)
        case  inside(ofRadius: CGFloat)

        var distanceFromCenter: CGFloat {
            switch self {
            case .outside(ofRadius: let d), .inside(ofRadius: let d): return d
            }
        }
    }
    // ...
}

接下来,我将为RotationalPosition做类似的事情:

struct RoundedAnnulusSectorCorner {
   // ...
    enum RotationalPosition {
        case cw(of: CGFloat)
        case ccw(of: CGFloat)

        var edgeAngle: CGFloat {
            switch self {
            case .cw(of: let angle), .ccw(of: let angle): return angle
            }
        }
    }
    // ...
}

现在我必须删除现有的 distanceToParentCenter: CGFloatangleToParentCenter: CGFloat 属性并用这些新模型替换它们。我们需要将他们的呼叫站点迁移到 radialPosition.distanceFromCenterRotationalPosition. edgeAngle。这是 RoundedAnnulusSectorCorner 的最后一组存储属性:

struct RoundedAnnulusSectorCorner {
    let parentCenter: CGPoint
    let radius: CGFloat
    let radialPosition: RadialPosition
    let rotationalPosition: RotationalPosition

    // ...

    /// The location of the corner, if this rounded wasn't rounded.
    private var rawCornerPoint: CGPoint {
        let inset = CGPoint(
            radius: self.radialPosition.distanceFromCenter,
            angle: self.rotationalPosition.edgeAngle
        )

        return self.parentCenter + inset
    }

    // ...
}

并且我们必须更新我们的角定义以提供这些新数据。这些是角的最终定义集。

struct RoundedAnnulusSector: BezierPathRenderable {
    // ...
    private var corner1: RoundedAnnulusSectorCorner {
        return RoundedAnnulusSectorCorner(
            parentCenter: self.center,
            radius: self.cornerRadius,
            radialPosition: .inside(ofRadius: self.outerRadius),
            rotationalPosition: .cw(of: self.startAngle)
        )
    }

    private var corner2: RoundedAnnulusSectorCorner {
        return RoundedAnnulusSectorCorner(
            parentCenter: self.center,
            radius: self.cornerRadius,
            radialPosition: .inside(ofRadius: self.outerRadius),
            rotationalPosition: .ccw(of: self.endAngle)
        )
    }

    private var corner3: RoundedAnnulusSectorCorner {
        return RoundedAnnulusSectorCorner(
            parentCenter: self.center,
            radius: self.cornerRadius,
            radialPosition: .outside(ofRadius: self.innerRadius),
            rotationalPosition: .ccw(of: self.endAngle)
        )
    }

    private var corner4: RoundedAnnulusSectorCorner {
        return RoundedAnnulusSectorCorner(
            parentCenter: self.center,
            radius: self.cornerRadius,
            radialPosition: .outside(ofRadius: self.innerRadius),
            rotationalPosition: .cw(of: self.startAngle)
        )
    }
    // ...
}

第四次渲染

运行 再次这段代码,我们看到现在圆圈仍然以 rawCornerPoint 为中心,但这很好。这意味着我们的重构没有破坏我们的 already-working 功能。如果我们一直都有单元测试,这就是它们有用的地方。

待续

中,因为我刚刚遇到一个 Whosebug 错误,我翻了很多人以前遇到过的错误:


再次尝试 rotationalInsetAngleradialInsetDistance

现在角知道它们相对于它们的距离和角度是哪一侧,我们可以实现我们的 rotationalInsetAngleradialInsetDistance.

radialInsetDistance 很简单。我们只是离中间更近或更远 self.radius,这取决于我们是在内侧还是外侧。

struct RoundedAnnulusSectorCorner { 
    // ...

    /// The distance towards/away from the disk's center
    /// where this corner's center is going to be
    internal var radialInsetDistance: CGFloat {
        switch self.radialPosition {
        case  .inside(_): return -self.radius // negative: towards center
        case .outside(_): return +self.radius // positive: away from center
        }
    }
}

rotationalInsetAngle有点棘手,你需要拿出记事本,尽力回忆高中三角。

struct RoundedAnnulusSectorCorner { 
    // ...
    /// The angular inset (in radians) from the disk's edge
    /// where this corner's center is going to be
    internal var rotationalInsetAngle: CGFloat {
        let angle = ???
        switch self.rotationalPosition {
        case .ccw(_): return -angle // negative: ccw from the edge
        case  .cw(_): return +angle // postiive:  cw from the edge
        }
    }
}

我们知道我们需要旋转某个角度,称为 angle,其大小始终相同,但其符号取决于我们的角是在开始边缘之前还是在结束边缘之后。

我们知道在我们的翻译之后,我们的角圆的笔划将与父形状的 edges/arcs 重叠。该距离是 self.radius,它形成直角三角形的 "opposite" 边。斜边是我们旋转的径向线,长度为 self.radialPosition.distanceFromCenter。鉴于我们有一个 opposite (o) 和一个斜边 (h),该作业的正确三角函数是 sinangle = sin(o / h)。在上下文中:

struct RoundedAnnulusSectorCorner { 
    // ...
    /// The angular inset (in radians) from the disk's edge
    /// where this corner's center is going to be
    internal var rotationalInsetAngle: CGFloat {
        let angle = sin(self.radius / self.radialPosition.distanceFromCenter)
        switch self.rotationalPosition {
        case .ccw(_): return -angle // negative: ccw from the edge
        case  .cw(_): return +angle // postiive:  cw from the edge
        }
    }
}

放弃rawCornerPoint

嬉皮狂欢,我们的 center 计算的 属性 现在应该可以正常世界了,因为我们新实现的 rotationalInsetAngleradialInsetDistance.

我们可以更新角的弧线来使用它:

struct RoundedAnnulusSectorCorner { 
    // ...
    var arc: Arc {
        return Arc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
    }
    // ...
}

第五次渲染

如果一切顺利,您应该会看到圆角的圆现在位于正确的位置(以 centers 为中心,而不是 rawCornerPoints)。

去环化

到目前为止,我们将圆角渲染为完整的圆。这对于让它继续运行很有用,但现在我们可以修复它。让我们正确地实现 startAngle/endAngle 计算属性来为每个圆角导出适当的开始和结束角度。

这些很简单。每个圆角都以边角开始,垂直于它,反对它,或以其他方式垂直。然后弧继续四分之一圈,所以我们可以通过将四分之一圈 (2π) 添加到 startAngle.

来获得 endAngle
struct RoundedAnnulusSectorCorner { 
    // ...
    /// The angle at which this corner's arc starts.
    var startAngle: CGFloat {
        switch (radialPosition, rotationalPosition) {
        case let ( .inside(_),  .cw(of: edgeAngle)): return edgeAngle + (3 * .pi/2)
        case let ( .inside(_), .ccw(of: edgeAngle)): return edgeAngle + (0 * .pi/2)
        case let (.outside(_), .ccw(of: edgeAngle)): return edgeAngle + (1 * .pi/2)
        case let (.outside(_),  .cw(of: edgeAngle)): return edgeAngle + (2 * .pi/2)
        }
    }


    /// The angle at which this corner's arc ends.
    var endAngle: CGFloat {
        return self.startAngle + .pi/2 // A quarter turn clockwise from the start
    }
}

第六次渲染

我们快到了!现在我们的圈子不复存在了。我们有圆角,以正确插入的 center 点为中心,在右弧中形成弧形。

唯一剩下的问题是我们的 startAngleEdgeendAngleEdgeinnerArcouterArc 没有在圆角终止的地方终止。

修复边缘

我们现在可以替换 startPoint/endPoint 的定义,直到现在它被计算为 rawCornerPoint

计算这些就像将我们的点向 start/end 角平移 radius 的距离一样容易。哦,看,我们已经为此制作了一个工具!

struct RoundedAnnulusSectorCorner {
    // ...
    /// The point at which this corner's arc starts.
    var startPoint: CGPoint {
        return self.center.translated(towards: startAngle, by: radius)
    }

    /// The point at which this corner's arc ends.
    var endPoint: CGPoint {
        return self.center.translated(towards: endAngle, by: radius)
    }
    // ...
}

第七次渲染

现在我们的边缘在正确的位置!

修复圆弧

修复圆弧很容易。我们知道每个角的 rotationalInsetAngle,我们只需要将它添加到我们的 start/end 角中,以便它们 start/end later/earlier 根据需要:

struct RoundedAnnulusSectorCorner {
    // ...
    private var outerArc: Arc {
        return Arc(
            center: self.center,
            radius: self.outerRadius,
            startAngle: self.startAngle + self.corner1.rotationalInsetAngle,
            endAngle: self.endAngle + self.corner2.rotationalInsetAngle,
            clockwise: true
        )
    }

    private var innerArc: Arc {
        return Arc(
            center: self.center,
            radius: self.innerRadius,
            startAngle: self.endAngle + self.corner3.rotationalInsetAngle,
            endAngle: self.startAngle + self.corner4.rotationalInsetAngle,
            clockwise: false
        )
    }
    // ...
}

第八次渲染

完成!这是最后的 final gist,但我强烈建议您继续学习并了解该过程。

这就是我的解决方案。 就是简单的三角函数

/// Create a path made with 6 small subpaths
///
/// - Parameters:
///   - startAngle: the start angle of the path in cartesian plane angles system
///   - endAngle: the end angle of the path in cartesian plane angles system
///   - outerRadius: the radius of the outer circle in % relative to the size of the view that holds it
///   - innerRadius: the radius of the inner circle in % relative to the size of the view that holds it
///   - cornerRadius: the corner radius of the edges
///
/// - Returns: the path itself
func createPath(from startAngle: Double, to endAngle: Double,
                outerRadius:CGFloat, innerRadius:CGFloat,
                cornerRadius: CGFloat) -> UIBezierPath {

    let path = UIBezierPath()
    let maxDim = min(view.frame.width, view.frame.height)
    let oRadius: CGFloat = maxDim/2 * outerRadius
    let iRadius: CGFloat = maxDim/2 * innerRadius
    let center = CGPoint.init(x: view.frame.width/2, y: view.frame.height/2)

    let startAngle = deg2rad(360.0 - startAngle)
    let endAngle = deg2rad(360.0 - endAngle)

    // Outer Finish Center point
    let ofcX = center.x + (oRadius - cornerRadius) * CGFloat(cos(endAngle - deg2rad(360)))
    let ofcY = center.y + (oRadius - cornerRadius) * CGFloat(sin(endAngle - deg2rad(360)))

    // Inner Finish Center point
    let ifcX = center.x + (iRadius + cornerRadius) * CGFloat(cos(endAngle - deg2rad(360)))
    let ifcY = center.y + (iRadius + cornerRadius) * CGFloat(sin(endAngle - deg2rad(360)))

    // Inner Starting Center point
    let iscX = center.x + (iRadius + cornerRadius) * CGFloat(cos(startAngle - deg2rad(360)))
    let iscY = center.y + (iRadius + cornerRadius) * CGFloat(sin(startAngle - deg2rad(360)))

    // Outer Starting Center point
    let oscX = center.x + (oRadius - cornerRadius) * CGFloat(cos(startAngle - deg2rad(360)))
    let oscY = center.y + (oRadius - cornerRadius) * CGFloat(sin(startAngle - deg2rad(360)))


    // Outer arch
    path.addArc(withCenter: center, radius: oRadius,
                startAngle: startAngle, endAngle: endAngle,
                clockwise: true)

    // Rounded outer finish
    path.addArc(withCenter: CGPoint(x: ofcX, y: ofcY), radius: cornerRadius,
                startAngle: endAngle, endAngle:endAngle +  deg2rad(90),
                clockwise: true)

    // Rounded inner finish
    path.addArc(withCenter: CGPoint(x: ifcX, y: ifcY), radius: cornerRadius,
                startAngle: endAngle +  deg2rad(90), endAngle: endAngle +  deg2rad(180),
                clockwise: true)

    // Inner arch
    path.addArc(withCenter: center, radius: iRadius,
                startAngle: endAngle, endAngle: startAngle,
                clockwise: false)

    // Rounded inner start
    path.addArc(withCenter:  CGPoint(x: iscX, y: iscY), radius: cornerRadius,
                startAngle:  startAngle + deg2rad(180), endAngle:  startAngle + deg2rad(270),
                clockwise: true)

    // Rounded outer start
    path.addArc(withCenter:  CGPoint(x: oscX, y: oscY), radius: cornerRadius,
                startAngle:  startAngle + deg2rad(270), endAngle:  startAngle,
                clockwise: true)

    return path
}

func deg2rad(_ number: Double) -> CGFloat {
    return CGFloat(number * .pi / 180)
}

用法:

 @IBOutlet weak var mainView: UIView!

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)

    let borderLayer = CAShapeLayer()
    borderLayer.path = createPath(from: 30, to: 120, outerRadius: 0.9, innerRadius: 0.3, cornerRadius: 5).cgPath
    borderLayer.strokeColor = UIColor.orange.cgColor
    borderLayer.fillColor = UIColor.orange.cgColor
    borderLayer.lineWidth = 0.0
    mainView.layer.addSublayer(borderLayer)
}