iosswiftuikitcore-animationuibezierpath

Rounded corner of a cut-out arc using UIBezierPath without off-setting the arc centre


(This question is a sequel to this one—I'm trying to figure out how to adapt the code to new circumstances.)

I have a code that produces this shape:

A shape (in red) with rounded corners and cut-out arcs with separate rounded corners

The code is extracted into a function, producing CGPath:

private func makePath(
  width: CGFloat,
  height: CGFloat,
  cornerRadius: CGFloat,
  cutOutRadius: CGFloat,
  cutOutCornerRadius: CGFloat,
  cutOutPosition: CGFloat
) -> CGPath {
  let path = CGMutablePath()

  // Start at bottom-left before left cut-out.
  path.move(to: CGPoint(x: .zero, y: height - cornerRadius))

  // Up left edge to left cut-out (rounded corner).
  path.addArc(
    tangent1End: CGPoint(x: .zero, y: cutOutPosition + cutOutRadius),
    tangent2End: CGPoint(x: cutOutRadius, y: cutOutPosition + cutOutRadius),
    radius: cutOutCornerRadius
  )

  // Left cut-out arc (top to bottom, clockwise).
  path.addArc(
    center: CGPoint(x: cutOutCornerRadius, y: cutOutPosition),
    radius: cutOutRadius,
    startAngle: .pi / 2.0,
    endAngle: 3.0 * .pi / 2.0,
    clockwise: true
  )

  // Down left edge after cut-out to bottom-left (rounded corner).
  path.addArc(
    tangent1End: CGPoint(x: .zero, y: cutOutPosition - cutOutRadius),
    tangent2End: .zero,
    radius: cutOutCornerRadius
  )

  // Bottom-left corner to bottom-right corner.
  path.addArc(
    tangent1End: .zero,
    tangent2End: CGPoint(x: width, y: .zero),
    radius: cornerRadius
  )

  // Bottom-right corner up to right cut-out start (rounded corner).
  path.addArc(
    tangent1End: CGPoint(x: width, y: .zero),
    tangent2End: CGPoint(x: width, y: cutOutPosition - cutOutRadius),
    radius: cornerRadius
  )

  // Up right edge to right cutout (rounded corner).
  path.addArc(
    tangent1End: CGPoint(x: width, y: cutOutPosition - cutOutRadius),
    tangent2End: CGPoint(
      x: width - cutOutRadius, y: cutOutPosition - cutOutRadius
    ),
    radius: cutOutCornerRadius
  )

  // Right cut-out arc (bottom to top, counterclockwise).
  path.addArc(
    center: CGPoint(x: width - cutOutCornerRadius, y: cutOutPosition),
    radius: cutOutRadius,
    startAngle: 3.0 * .pi / 2.0,
    endAngle: .pi / 2.0,
    clockwise: true
  )

  // Up right edge after cut-out to top-right (rounded corner).
  path.addArc(
    tangent1End: CGPoint(x: width, y: cutOutPosition + cutOutRadius),
    tangent2End: CGPoint(x: width, y: height),
    radius: cutOutCornerRadius
  )

  // Top-right corner to top-left.
  path.addArc(
    tangent1End: CGPoint(x: width, y: height),
    tangent2End: CGPoint(x: .zero, y: height),
    radius: cornerRadius
  )

  // Top-left corner down to starting point.
  path.addArc(
    tangent1End: CGPoint(x: .zero, y: height),
    tangent2End: .zero,
    radius: cornerRadius
  )

  path.closeSubpath()

  return path
}

I need to tweak the code so that the centre of the cut-out arc is not moved horizontally towards the shape centre by the size of the radius, but stays on the shape edge. In other words, instead of this:

Path that adds rounded corners to a cut-out arc by shifting it

I need this:

Path that adds rounded corners to a cut-out arc without shifting it

I suppose it would involve even more complicated geometry, and I again fail to figure out how it could be implemented.

Any guidance is much appreciated!

P.S. Here's some code you could paste into Playgrounds along with the function above and have the complete working piece of software:

import PlaygroundSupport
import UIKit

let width = 300.0
let height = 200.0

let view = UIView(
  frame: CGRect(origin: .zero, size: CGSize(width: width, height: height))
)
view.backgroundColor = .clear

let shapeLayer = CAShapeLayer()
shapeLayer.frame = view.bounds
shapeLayer.fillColor = UIColor.systemRed.cgColor

shapeLayer.fillRule = .evenOdd
shapeLayer.path = makePath(
  width: width,
  height: height,
  cornerRadius: 20.0,
  cutOutRadius: 30.0,
  cutOutCornerRadius: 10.0,
  cutOutPosition: height * 0.6
)

view.layer.insertSublayer(shapeLayer, at: 0)

PlaygroundPage.current.liveView = view

Solution

  • Notice that the angle that the cutout spans across would be less than 180 degrees. To determine exactly how much less, we can draw a diagram like this:

    enter image description here

    The angle we want is the angle at the center of the cutout. This is

    asin(cutOutCornerRadius / (cutOutRadius + cutOutCornerRadius))
    

    We need to adjust both the start and end angles of the cutout by this much.

    Using this, we can also figure out the end angle of the rounded corner. This is just the angle made at the center of the corner, i.e. 90 degrees minus the above expression.

    Finally, we also need the horizontal distance between the center of the corner and the center of the cutout, so that we know where all the renters are. This is just simple Pythagorus's theorem.

    sqrt(pow(cutOutRadius + cutOutCornerRadius, 2) - pow(cutOutCornerRadius, 2))
    

    Here is some code that draws just one side of the shape,

    private func makePath(
      cutOutRadius: CGFloat,
      cutOutCornerRadius: CGFloat,
      cutOutPosition: CGFloat
    ) -> CGPath {
        let path = CGMutablePath()
        path.move(to: .zero)
        let angleReduced = asin(cutOutCornerRadius / (cutOutRadius + cutOutCornerRadius))
        let cornerAngle = .pi / 2 - angleReduced
        let distanceBetweenCenters = sqrt(pow(cutOutRadius + cutOutCornerRadius, 2) - pow(cutOutCornerRadius, 2))
        path.addLine(to: .init(x: 0, y: cutOutPosition - distanceBetweenCenters))
        path.addArc(
            center: .init(x: cutOutCornerRadius, y: cutOutPosition - distanceBetweenCenters),
            radius: cutOutCornerRadius,
            startAngle: .pi,
            endAngle: .pi - cornerAngle,
            clockwise: true
        )
        path.addArc(
            center: .init(x: 0, y: cutOutPosition),
            radius: cutOutRadius,
            startAngle: 3 * .pi / 2 + angleReduced,
            endAngle: .pi / 2 - angleReduced,
            clockwise: false
        )
        path.addArc(
            center: .init(x: cutOutCornerRadius, y: cutOutPosition + distanceBetweenCenters),
            radius: cutOutCornerRadius,
            startAngle: .pi + cornerAngle,
            endAngle: .pi,
            clockwise: true
        )
        path.addLine(to: .init(x: 0, y: path.currentPoint.y + 100))
        return path
    }
    
    makePath(cutOutRadius: 100, cutOutCornerRadius: 20, cutOutPosition: 250)
    

    enter image description here

    By adjusting the start/end angles and clockwise:, you can adapt this into your existing code:

    private func makePath(
        width: CGFloat,
        height: CGFloat,
        cornerRadius: CGFloat,
        cutOutRadius: CGFloat,
        cutOutCornerRadius: CGFloat,
        cutOutPosition: CGFloat
    ) -> CGPath {
        let path = CGMutablePath()
        
        let angleReduced = asin(cutOutCornerRadius / (cutOutRadius + cutOutCornerRadius))
        let cornerAngle = .pi / 2 - angleReduced
        let distanceBetweenCenters = sqrt(pow(cutOutRadius + cutOutCornerRadius, 2) - pow(cutOutCornerRadius, 2))
        
        path.move(to: CGPoint(x: .zero, y: height - cornerRadius))
        path.addLine(to: .init(x: 0, y: cutOutPosition + distanceBetweenCenters))
        
        path.addArc(
            center: .init(x: cutOutCornerRadius, y: cutOutPosition + distanceBetweenCenters),
            radius: cutOutCornerRadius,
            startAngle: .pi,
            endAngle: .pi + cornerAngle,
            clockwise: false
        )
        
        path.addArc(
            center: .init(x: 0, y: cutOutPosition),
            radius: cutOutRadius,
            startAngle: .pi / 2 - angleReduced,
            endAngle: 3 * .pi / 2 + angleReduced,
            clockwise: true
        )
        
        path.addArc(
            center: .init(x: cutOutCornerRadius, y: cutOutPosition - distanceBetweenCenters),
            radius: cutOutCornerRadius,
            startAngle: .pi - cornerAngle,
            endAngle: .pi,
            clockwise: false
        )
        
        path.addArc(
            tangent1End: .zero,
            tangent2End: CGPoint(x: width, y: .zero),
            radius: cornerRadius
        )
        
        path.addArc(
            tangent1End: CGPoint(x: width, y: .zero),
            tangent2End: CGPoint(x: width, y: cutOutPosition - cutOutRadius),
            radius: cornerRadius
        )
        
        path.addLine(to: .init(x: width, y: cutOutPosition - distanceBetweenCenters))
        path.addArc(
            center: .init(x: width - cutOutCornerRadius, y: cutOutPosition - distanceBetweenCenters),
            radius: cutOutCornerRadius,
            startAngle: 0,
            endAngle: cornerAngle,
            clockwise: false
        )
        
        path.addArc(
            center: .init(x: width, y: cutOutPosition),
            radius: cutOutRadius,
            startAngle: 3 * .pi / 2 - angleReduced,
            endAngle: .pi / 2 + angleReduced,
            clockwise: true
        )
    
        path.addArc(
            center: .init(x: width - cutOutCornerRadius, y: cutOutPosition + distanceBetweenCenters),
            radius: cutOutCornerRadius,
            startAngle: 3 * .pi / 2,
            endAngle: 3 * .pi / 2 + cornerAngle,
            clockwise: false
        )
        
        path.addArc(
            tangent1End: CGPoint(x: width, y: height),
            tangent2End: CGPoint(x: .zero, y: height),
            radius: cornerRadius
        )
        
        path.addArc(
            tangent1End: CGPoint(x: .zero, y: height),
            tangent2End: .zero,
            radius: cornerRadius
        )
        
        path.closeSubpath()
        
        return path
    }