Liliana Terry
Liliana Terry

Reputation: 39

How to extend outer edge of UIBezierPath arc

I'm making a Simon Says-style wheel with 4 UIBezierArcs. I can't just make a single arc with different colors and white portions because I need to be able to identify which arc has been pressed.

However, when I position the arcs in a circle, the white space between the inner edges is smaller than the white space between the outer edges and makes the spaces look liked a wedge instead of a uniform rectangle.

How can I tweak the outer arc edge so that its start/end angle is longer than the inner arc edge?

private struct Constants {
    static let width: CGFloat = 115;
    static let height: CGFloat = 230;
}

override func draw(_ rect: CGRect) {
    let center = CGPoint(x: bounds.width / 2, y: bounds.height / 2)

    let radius: CGFloat = bounds.height

    let startAngle: CGFloat = 0 + .pi / 44
    let endAngle: CGFloat = .pi / 2 - .pi / 44

    shapePath = UIBezierPath(arcCenter: center,
                            radius: radius/2 - CGFloat(Constants.width/2),
                            startAngle: startAngle,
                            endAngle: endAngle,
                            clockwise: true)

    shapePath.lineWidth = Constants.width / 2
    color.setStroke()
    shapePath.stroke()
    shapePath.close()
}

This is what it currently looks like:

what it looks like currently

Upvotes: 3

Views: 2063

Answers (3)

Rob
Rob

Reputation: 437702

You can define a path that circumscribes the four arc shapes for your diagram. And you just need to offset the start/end angle of each curve by the arcsine of the gap divided by the radius. E.g.

let angleAdjustment = asin(gap / 2 / radius) 

Thus:

@IBDesignable
class SimonSaysView: UIView {

    @IBInspectable var innerPercentage: CGFloat = 0.5 { didSet { updatePaths() } }
    @IBInspectable var gap: CGFloat = 20              { didSet { updatePaths() } }

    let shapeLayers: [CAShapeLayer] = {
        let colors: [UIColor] = [.red, .blue, .black, .green]
        return colors.map { color -> CAShapeLayer in
            let shapeLayer = CAShapeLayer()
            shapeLayer.strokeColor = UIColor.clear.cgColor
            shapeLayer.fillColor = color.cgColor
            shapeLayer.lineWidth = 0
            return shapeLayer
        }
    }()

    override init(frame: CGRect = .zero) {
        super.init(frame: frame)
        configure()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        configure()
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        updatePaths()
    }

    func configure() {
        shapeLayers.forEach { layer.addSublayer($0) }
    }

    func updatePaths() {
        let arcCenter = CGPoint(x: bounds.midX, y: bounds.midY)
        let outerRadius = min(bounds.width, bounds.height) / 2
        let innerRadius = outerRadius * innerPercentage
        let outerAdjustment = asin(gap / 2 / outerRadius)
        let innerAdjustment = asin(gap / 2 / innerRadius)

        for (i, shapeLayer) in shapeLayers.enumerated() {
            let startAngle: CGFloat = -3 * .pi / 4 + CGFloat(i) * .pi / 2
            let endAngle = startAngle + .pi / 2
            let path = UIBezierPath(arcCenter: arcCenter, radius: outerRadius, startAngle: startAngle + outerAdjustment, endAngle: endAngle - outerAdjustment, clockwise: true)
            path.addArc(withCenter: arcCenter, radius: innerRadius, startAngle: endAngle - innerAdjustment, endAngle: startAngle + innerAdjustment, clockwise: false)
            path.close()
            shapeLayer.path = path.cgPath
        }
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first else { return }

        for (index, shapeLayer) in shapeLayers.enumerated() {
            if shapeLayer.path!.contains(touch.location(in: self)) {
                print(index)
                return
            }
        }
    }
}

Yields:

enter image description here


If you're wondering how I calculated what the adjustment angle, θ, should be for a given gap offset x and a given radius, r, basic trigonometry tells us that sin(θ) = x / r, and thus θ = asin(x / r) where x is the total gap divided by 2.

enter image description here

Upvotes: 1

rob mayoff
rob mayoff

Reputation: 385710

So you want this:

wedges with gaps

Let's write an extension on UIBezierPath that creates the path outlining a single wedge.

To warm up, first we'll write a function that creates the wedge path without leaving a gap between wedges:

import UIKit
import PlaygroundSupport

// This is useful to remind us that we measure angles in radians, not degrees.
typealias Radians = CGFloat

extension UIBezierPath {

    static func simonWedge(innerRadius: CGFloat, outerRadius: CGFloat, centerAngle: Radians) -> UIBezierPath {
        let innerAngle: Radians = CGFloat.pi / 4
        let outerAngle: Radians = CGFloat.pi / 4
        let path = UIBezierPath()
        path.addArc(withCenter: .zero, radius: innerRadius, startAngle: centerAngle - innerAngle, endAngle: centerAngle + innerAngle, clockwise: true)
        path.addArc(withCenter: .zero, radius: outerRadius, startAngle: centerAngle + outerAngle, endAngle: centerAngle - outerAngle, clockwise: false)
        path.close()
        return path
    }

}

With this extension, we can create wedges like this:

wedge examples

And we can use this extension in a UIView subclass to draw a wedge:

class SimonWedgeView: UIView {
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }

    required init?(coder decoder: NSCoder) {
        super.init(coder: decoder)
        commonInit()
    }

    var centerAngle: Radians = 0 { didSet { setNeedsDisplay() } }
    var color: UIColor = #colorLiteral(red: 0.8549019694, green: 0.250980407, blue: 0.4784313738, alpha: 1) { didSet { setNeedsDisplay() } }

    override func draw(_ rect: CGRect) {
        let path = wedgePath()
        color.setFill()
        path.fill()
    }

    private func commonInit() {
        contentMode = .redraw
        backgroundColor = .clear
        isOpaque = false
    }

    private func wedgePath() -> UIBezierPath {
        let bounds = self.bounds
        let outerRadius = min(bounds.size.width, bounds.size.height) / 2
        let innerRadius = outerRadius / 2
        let path = UIBezierPath.simonWedge(innerRadius: innerRadius, outerRadius: outerRadius, centerAngle: centerAngle)
        path.apply(CGAffineTransform(translationX: bounds.midX, y: bounds.midY))
        return path
    }
}

let rootView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
rootView.backgroundColor = .white

func addWedgeView(color: UIColor, angle: Radians) {
    let wedgeView = SimonWedgeView(frame: rootView.bounds)
    wedgeView.color = color
    wedgeView.centerAngle = angle
    rootView.addSubview(wedgeView)
}

addWedgeView(color: #colorLiteral(red: 0.8549019694, green: 0.250980407, blue: 0.4784313738, alpha: 1), angle: 0)
addWedgeView(color: #colorLiteral(red: 0.5843137503, green: 0.8235294223, blue: 0.4196078479, alpha: 1), angle: 0.5 * .pi)
addWedgeView(color: #colorLiteral(red: 0.2588235438, green: 0.7568627596, blue: 0.9686274529, alpha: 1), angle: .pi)
addWedgeView(color: #colorLiteral(red: 0.9686274529, green: 0.78039217, blue: 0.3450980484, alpha: 1), angle: 1.5 * .pi)

PlaygroundPage.current.liveView = rootView

Result:

wedge views

So now we want to add the gaps between wedges.

Consider this diagram:

arc diagram

In the diagram, there's a circle of radius r (centered at the origin), and an arc of that circle that subtends angle θ. The length of the arc is θr, when θ is in radians. (This formula, θr, is why we use radians to measure angles!)

In the gapless method above, θ (as the variables innerAngle and outerAngle) was .pi / 4. But now we want the angles to be less than .pi / 4 to form a gap. We want the gap length along the inner radius to equal the gap length along the outer radius. So we have a predetermined gap length, g, and we need to compute the proper θ for it.

gapless arc length = r π / 4
gapful arc length = θ r = r π / 4 - g / 2

(We use g / 2 because each wedge has half of the gap at one end and half of the gap at the other end.)

θ r = r π / 4 - g / 2
// Solve for θ by dividing both sides by r:
θ = π / 4 - g / (2 r)

Now we can update the formulas innerAngle and outerAngle in the extension, to create paths that include a gap:

static func simonWedge(innerRadius: CGFloat, outerRadius: CGFloat, centerAngle: Radians, gap: CGFloat) -> UIBezierPath {
    let innerAngle: Radians = CGFloat.pi / 4 - gap / (2 * innerRadius)
    let outerAngle: Radians = CGFloat.pi / 4 - gap / (2 * outerRadius)
    let path = UIBezierPath()
    path.addArc(withCenter: .zero, radius: innerRadius, startAngle: centerAngle - innerAngle, endAngle: centerAngle + innerAngle, clockwise: true)
    path.addArc(withCenter: .zero, radius: outerRadius, startAngle: centerAngle + outerAngle, endAngle: centerAngle - outerAngle, clockwise: false)
    path.close()
    return path
}

Then we update the wedgePath method of SimonWedgeView to compute and pass a gap length to the simonWidge method:

    private func wedgePath() -> UIBezierPath {
        let bounds = self.bounds
        let outerRadius = min(bounds.size.width, bounds.size.height) / 2
        let innerRadius = outerRadius / 2
        let gap = (outerRadius - innerRadius) / 4
        let path = UIBezierPath.simonWedge(innerRadius: innerRadius, outerRadius: outerRadius, centerAngle: centerAngle, gap: gap)
        path.apply(CGAffineTransform(translationX: bounds.midX, y: bounds.midY))
        return path
    }

And we get the desired result:

wedges with gaps

You can find the complete playground source code (for the version with gaps) in this gist.

By the way, after you get the draw method working, you're probably going to want to detect which wedge was tapped. To do that, you'll want to override the point(inside:with:) method in SimonWedgeView. I explain what to do in this answer.

Upvotes: 10

Duncan C
Duncan C

Reputation: 131426

You won't be able to use 4 thick arc sections. Those will have angled ends.

Instead, you'll have to build 4 separate filled polygons with 2 arcs and 2 line segments each.

Figuring it out will involve some algebra 2, and a little trig.

Imagine a bounding square for your drawing that has bars of a given thickness going from the top left to the bottom right corners and from the top right to the bottom left corners. Let's call them crossbars. Imagine you draw those crossbars with a square-edged marker, so the crossbars start at each corner of the square, and the corners of the crossbars stick outside the bounding square a little bit. Diagram it.

Now draw the inside and outside diameter circles inside the square.

Each circle will intersect the outside lines of each bar. You will need to solve for the intersections of the bar boundary lines and the inner and outer circles. If the thickness of the cross-bars is t, the coordinates of the corners of the bars will be offset from the corners of your square by t•√2/4. You'd need to diagram out the bars and map out the coordinates of the outside lines of the crossbars. Then note the endpoints of each crossbar and calculate the equation of the outside lines. (In slope-intercept form, or standard form. Standard form allows you to deal with vertical lines, but your case won't have vertical lines.

The formula for the circles is r² = (x-h)² + (y-k)². You should be able to use simultaneous equations for each circle and each bar boundary line to find the intersections of each bar outside line and each circle. There should be 2 solutions for each pair.

Once you have the coordinates of the intersections, you'll have to use trig to calculate the start and end angles of each arc. Then you'll just use UIBezierPath arc commands and line commands to assemble your shapes.

That's the basic approach. Working out the details is fussy and time-consuming, but straightforward. I'll leave that bit as an "exercise for the reader."

Upvotes: 2

Related Questions