UIBezierPath issue with oval shape, lines sticks out

114 Views Asked by At

I'm trying to create an oval shape / rounded corners rect with UIBezierPath. What i want to achieve is this shape

enter image description here

One of the issues is that i wanst able to find the correct radius to archive my target shape and the second issue i have is that i can see lines sticking out, the code doesn't produce a clean shape

override func layoutSubviews() {
    super.layoutSubviews() 
    
    layer.sublayers?.forEach { $0.removeFromSuperlayer() }

    let path = makePath()
    path.lineJoinStyle = .round
    path.lineCapStyle = .round
    
    let shapeLayer = CAShapeLayer()
    // Enable antialiasing
    shapeLayer.shouldRasterize = true
    shapeLayer.rasterizationScale = UIScreen.main.scale
    
    shapeLayer.lineJoin = .round
    shapeLayer.path = path.cgPath
    //shapeLayer.fillColor = UIColor.clear.cgColor
    shapeLayer.strokeColor = strokeColor.cgColor
    shapeLayer.lineWidth = lineWidth
    shapeLayer.lineCap = .round
    
    
    layer.addSublayer(shapeLayer)
    layer.backgroundColor = overlayColor.cgColor
    
    //backgroundPath is the blur overlay
    let backgroundPath = UIBezierPath(rect: bounds)
    backgroundPath.lineJoinStyle = .round
    backgroundPath.lineCapStyle = .round

    let maskLayer = CAShapeLayer()
    // Enable antialiasing
    maskLayer.shouldRasterize = true
    maskLayer.rasterizationScale = UIScreen.main.scale
    
    maskLayer.frame = bounds
    maskLayer.lineJoin = .round

    //backgroundPath.append(path)
    maskLayer.fillRule = .evenOdd
    maskLayer.path = backgroundPath.cgPath
    layer.mask = maskLayer

    addAdditionalLayersIfNeeded(rect)
}


override func makePath(rect: CGRect) -> UIBezierPath {
    UIBezierPath(roundedRect: preferedSize, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: preferedSize.width * 0.33, height: preferedSize.height * 0.33))
}

dimensions that i'm using to create the shape

preferedSize: CGRect(x: 32, y: 278, width: 320, height: 400)

This is what the code will render

enter image description here

1

There are 1 best solutions below

2
DonMag On BEST ANSWER

You've run into a couple bugs with UIBezierPath(roundedRect: ...

First, the height value of cornerRadii is ignored. So, let's ignore that as well and use:

var cr: CGFloat = 0.25

let r: CGRect = CGRect(x: 20.0, y: 20.0, width: 320.0, height: 400.0)
let path = UIBezierPath(roundedRect: r, cornerRadius: cr * r.width)
shapeLayer.path = path.cgPath

Basic CAShapeLayer with:

  • .lineWidth = 20.0
  • .strokeColor = UIColor.orange.cgColor
  • .fillColor = UIColor.cyan.cgColor
  • view background color yellow

So, we start with a corner radius of 25% and we'll increment it as we go:

enter image description here

As you see, when we hit corner radius of 29.5%, the actual radius jumps and we get a weird "gap" -- which also leaves a small misalignment, which is the "bump" you see on the sides.

As we keep incrementing the percentage-of-width radius value, the actual radius remains constant until we get to 36.5% -- at which point the radius changes and the misalignment goes away (we get a smooth edge). Although, as we notice, the actual radius doesn't change after 36.5%

Note that this will vary based on the actual size of the path and the width of the stroke.

If we do this: print(path.cgPath (at 25%) we get this in the debug console:

  moveto (154.828, 20)
    lineto (205.172, 20)
    curveto (243.995, 20) (263.407, 20) (284.302, 26.6072)
    lineto (284.302, 26.6072)
    curveto (307.117, 34.9111) (325.089, 52.8831) (333.393, 75.6978)
    curveto (340, 96.5935) (340, 116.005) (340, 154.828)
    lineto (340, 285.172)
    curveto (340, 323.995) (340, 343.407) (333.393, 364.302)
    lineto (333.393, 364.302)
    curveto (325.089, 387.117) (307.117, 405.089) (284.302, 413.393)
    curveto (263.407, 420) (243.995, 420) (205.172, 420)
    lineto (154.828, 420)
    curveto (116.005, 420) (96.5935, 420) (75.6978, 413.393)
    lineto (75.6978, 413.393)
    curveto (52.8831, 405.089) (34.9111, 387.117) (26.6072, 364.302)
    curveto (20, 343.407) (20, 323.995) (20, 285.172)
    lineto (20, 154.828)
    curveto (20, 116.005) (20, 96.5935) (26.6072, 75.6978)
    lineto (26.6072, 75.6978)
    curveto (34.9111, 52.8831) (52.8831, 34.9111) (75.6978, 26.6072)
    curveto (96.5935, 20) (116.005, 20) (154.828, 20)
    lineto (154.828, 20)

As we see, the "rounded rect" path is actually a series of line-to and curve-to instructions.

My guess is that Apple's internal roundedRect algorithm is hitting a floating-point error.

One way to avoid the bugs is to use this extension to build the path ourselves:

extension UIBezierPath {
    static func roundedRect(rect: CGRect, cornerRadius: CGFloat) -> UIBezierPath {
        // use shorter of width or height as max corner radius value
        //  and don't exceed 50%
        let v: CGFloat = min(rect.width, rect.height)
        let cr: CGFloat = min(v * 0.5, cornerRadius)
        let path = CGMutablePath()
        let start = CGPoint(x: rect.midX, y: rect.minY)
        path.move(to: start)
        path.addArc(tangent1End: rect.topRight, tangent2End: rect.bottomRight, radius: cr)
        path.addArc(tangent1End: rect.bottomRight, tangent2End: rect.bottomLeft, radius: cr)
        path.addArc(tangent1End: rect.bottomLeft, tangent2End: rect.topLeft, radius: cr)
        path.addArc(tangent1End: rect.topLeft, tangent2End: start, radius: cr)
        path.closeSubpath()
        return UIBezierPath(cgPath: path)
    }
}

// uses this "convenience" extension
extension CGRect {
    var topRight: CGPoint { CGPoint(x: maxX, y: minY) }
    var topLeft: CGPoint { CGPoint(x: minX, y: minY) }
    var bottomRight: CGPoint { CGPoint(x: maxX, y: maxY) }
    var bottomLeft: CGPoint { CGPoint(x: minX, y: maxY) }
}

Now, a 33% of width corner radius path generated like this:

var cr: CGFloat = 0.33

let r: CGRect = CGRect(x: 20.0, y: 20.0, width: 320.0, height: 400.0)
let path = UIBezierPath.roundedRect(rect: r, cornerRadius: cr * r.width)
shapeLayer.path = path.cgPath

gives us this:

enter image description here

Worth noting: Apple's algorithm generates a "continuous curve" rounded corner, which is slightly different.

This extension:

extension UIBezierPath {
    static func roundedRect(
        rect: CGRect,
        corners: UIRectCorner = .allCorners,
        cornerRadius: CGFloat
    ) -> UIBezierPath {
        // use shorter of width or height as max corner radius value
        //  and don't exceed 50%
        let v: CGFloat = min(rect.width, rect.height)
        let cr: CGFloat = min(v * 0.5, cornerRadius)
        let tweak: CGFloat = 1.2 // could experiment with this
        let offset = cr * tweak
        if rect.width > 2 * offset { // less than that, my algorithm starts to break down — but theirs works!
            let path = CGMutablePath()
            let start = CGPoint(x: rect.midX, y: rect.minY)
            path.move(to: start)
            if corners.contains(.topRight) {
                path.addLine(to: rect.topRight.offset(x: -offset, y: 0))
                path.addQuadCurve(to: rect.topRight.offset(x: 0, y: offset), control: rect.topRight)
            } else {
                path.addLine(to: rect.topRight)
            }
            if corners.contains(.bottomRight) {
                path.addLine(to: rect.bottomRight.offset(x: 0, y: -offset))
                path.addQuadCurve(to: rect.bottomRight.offset(x: -offset, y: 0), control: rect.bottomRight)
            } else {
                path.addLine(to: rect.bottomRight)
            }
            if corners.contains(.bottomLeft) {
                path.addLine(to: rect.bottomLeft.offset(x: offset, y: 0))
                path.addQuadCurve(to: rect.bottomLeft.offset(x: -0, y: -offset), control: rect.bottomLeft)
            } else {
                path.addLine(to: rect.bottomLeft)
            }
            if corners.contains(.topLeft) {
                path.addLine(to: rect.topLeft.offset(x: 0, y: offset))
                path.addQuadCurve(to: rect.topLeft.offset(x: offset, y: 0), control: rect.topLeft)
            } else {
                path.addLine(to: rect.topLeft)
            }
            path.closeSubpath()
            return UIBezierPath(cgPath: path)
        }
        
        return UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
    }
}

// uses these "convenience" extensions
extension CGRect {
    var topRight: CGPoint { CGPoint(x: maxX, y: minY) }
    var topLeft: CGPoint { CGPoint(x: minX, y: minY) }
    var bottomRight: CGPoint { CGPoint(x: maxX, y: maxY) }
    var bottomLeft: CGPoint { CGPoint(x: minX, y: maxY) }
}

extension CGPoint {
    func offset(x xOffset: CGFloat, y yOffset: CGFloat) -> CGPoint {
        CGPoint(x: x + xOffset, y: y + yOffset)
    }
}

Gives this result:

enter image description here

Please Note: those extensions are slightly modified versions from the discussion here UIBezierPath bezierPathWithRoundedRect: the cornerRadius value is not consistent


Edit - ugh... I made the screen caps with some values typos...

Here is complete code to play around and compare:


// convenience extensions
extension CGRect {
    var topRight: CGPoint { CGPoint(x: maxX, y: minY) }
    var topLeft: CGPoint { CGPoint(x: minX, y: minY) }
    var bottomRight: CGPoint { CGPoint(x: maxX, y: maxY) }
    var bottomLeft: CGPoint { CGPoint(x: minX, y: maxY) }
}

extension CGPoint {
    func offset(x xOffset: CGFloat, y yOffset: CGFloat) -> CGPoint {
        CGPoint(x: x + xOffset, y: y + yOffset)
    }
}

// UIBezierPath extension

extension UIBezierPath {
    
    // rounded rect path, using quad curves
    static func roundedRectQ(
        rect: CGRect,
        corners: UIRectCorner = .allCorners,
        cornerRadius: CGFloat
    ) -> UIBezierPath {
        // use shorter of width or height as max corner radius value
        //  and don't exceed 50%
        let v: CGFloat = min(rect.width, rect.height)
        let cr: CGFloat = min(v * 0.5, cornerRadius)
        let tweak: CGFloat = 1.2 // could experiment with this
        let offset = cr * tweak
        if rect.width > 2 * offset { // less than that, my algorithm starts to break down — but theirs works!
            let path = CGMutablePath()
            let start = CGPoint(x: rect.midX, y: rect.minY)
            path.move(to: start)
            if corners.contains(.topRight) {
                path.addLine(to: rect.topRight.offset(x: -offset, y: 0))
                path.addQuadCurve(to: rect.topRight.offset(x: 0, y: offset), control: rect.topRight)
            } else {
                path.addLine(to: rect.topRight)
            }
            if corners.contains(.bottomRight) {
                path.addLine(to: rect.bottomRight.offset(x: 0, y: -offset))
                path.addQuadCurve(to: rect.bottomRight.offset(x: -offset, y: 0), control: rect.bottomRight)
            } else {
                path.addLine(to: rect.bottomRight)
            }
            if corners.contains(.bottomLeft) {
                path.addLine(to: rect.bottomLeft.offset(x: offset, y: 0))
                path.addQuadCurve(to: rect.bottomLeft.offset(x: -0, y: -offset), control: rect.bottomLeft)
            } else {
                path.addLine(to: rect.bottomLeft)
            }
            if corners.contains(.topLeft) {
                path.addLine(to: rect.topLeft.offset(x: 0, y: offset))
                path.addQuadCurve(to: rect.topLeft.offset(x: offset, y: 0), control: rect.topLeft)
            } else {
                path.addLine(to: rect.topLeft)
            }
            path.closeSubpath()
            return UIBezierPath(cgPath: path)
        }
        
        return UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
    }

    // rounded rect path, using arc tangents
    static func roundedRectT(rect: CGRect, cornerRadius: CGFloat) -> UIBezierPath {
        // use shorter of width or height as max corner radius value
        //  and don't exceed 50%
        let v: CGFloat = min(rect.width, rect.height)
        let cr: CGFloat = min(v * 0.5, cornerRadius)
        let path = CGMutablePath()
        let start = CGPoint(x: rect.midX, y: rect.minY)
        path.move(to: start)
        path.addArc(tangent1End: rect.topRight, tangent2End: rect.bottomRight, radius: cr)
        path.addArc(tangent1End: rect.bottomRight, tangent2End: rect.bottomLeft, radius: cr)
        path.addArc(tangent1End: rect.bottomLeft, tangent2End: rect.topLeft, radius: cr)
        path.addArc(tangent1End: rect.topLeft, tangent2End: start, radius: cr)
        path.closeSubpath()
        return UIBezierPath(cgPath: path)
    }
    
}

// Corner Type enum

enum CornerType: Int {
    case def, tang, quad
}

// Custom View class

class SampleView: UIView {
    
    var cornerType: CornerType = .def
    
    var useDefault: Bool = true
    
    var strokeColor: UIColor = .orange { didSet { shapeLayer.strokeColor = strokeColor.cgColor } }
    var fillColor: UIColor = .cyan { didSet { shapeLayer.fillColor = fillColor.cgColor } }
    var overlayColor: UIColor = UIColor(white: 0.95, alpha: 1.0) { didSet { layer.backgroundColor = overlayColor.cgColor } }
    
    var lineWidth: CGFloat = 10 { didSet { shapeLayer.lineWidth = lineWidth } }
    
    var cornerRadiusPct: CGFloat = 0.25 {
        didSet {
            label.text = String(format: "%0.3f", cornerRadiusPct)
            setNeedsLayout()
        }
    }
    
    let label = UILabel()
    let shapeLayer = CAShapeLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() {
        layer.backgroundColor = overlayColor.cgColor
        
        shapeLayer.fillColor = fillColor.cgColor
        shapeLayer.strokeColor = strokeColor.cgColor
        shapeLayer.lineWidth = lineWidth
        
        // Enable antialiasing
        shapeLayer.shouldRasterize = true
        shapeLayer.rasterizationScale = UIScreen.main.scale
        
        layer.addSublayer(shapeLayer)
        
        label.font = .monospacedDigitSystemFont(ofSize: 18.0, weight: .regular)
        label.textAlignment = .center
        label.text = "0.0"
        label.translatesAutoresizingMaskIntoConstraints = false
        addSubview(label)
        NSLayoutConstraint.activate([
            label.topAnchor.constraint(equalTo: topAnchor, constant: 4.0),
            label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4.0),
        ])
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        let r: CGRect = bounds.insetBy(dx: lineWidth, dy: lineWidth)
        let rad: CGFloat = r.width * cornerRadiusPct
        
        var pth: UIBezierPath!
        
        switch cornerType {
        case .def:
            pth = UIBezierPath(roundedRect: r, cornerRadius: rad)
        case .tang:
            pth = UIBezierPath.roundedRectT(rect: r, cornerRadius: rad)
        case .quad:
            pth = UIBezierPath.roundedRectQ(rect: r, cornerRadius: rad)
        }
        
        shapeLayer.path = pth.cgPath
    }
    
}

// Example View Controller

class PathBugVC: UIViewController {
    let samp1 = SampleView()
    let samp2 = SampleView()
    let samp3 = SampleView()
    
    var curRadiusPct: CGFloat = 0.25 {
        didSet {
            [samp1, samp2, samp3].forEach { v in
                v.cornerRadiusPct = curRadiusPct
            }
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBackground
        
        var cfg = UIButton.Configuration.filled()
        
        cfg.title = "Increment"
        let btnA = UIButton(configuration: cfg, primaryAction: UIAction() { _ in
            if self.curRadiusPct < 0.5 {
                self.curRadiusPct += 0.005
            }
        })
        
        cfg = UIButton.Configuration.filled()
        
        cfg.title = "Decrement"
        let btnB = UIButton(configuration: cfg, primaryAction: UIAction() { _ in
            if self.curRadiusPct > 0.1 {
                self.curRadiusPct -= 0.005
            }
        })
        
        let btnStackView = UIStackView()
        btnStackView.spacing = 20.0
        btnStackView.distribution = .fillEqually
        
        btnStackView.addArrangedSubview(btnA)
        btnStackView.addArrangedSubview(btnB)
        
        let seg = UISegmentedControl(items: ["Default", "Tangents", "QuadCurves"])
        seg.addTarget(self, action: #selector(segChanged(_:)), for: .valueChanged)
        
        btnStackView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(btnStackView)
        seg.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(seg)
        
        let lineWidth: CGFloat = 20.0
        
        let g = view.safeAreaLayoutGuide
        
        NSLayoutConstraint.activate([
            
            btnStackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            btnStackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            btnStackView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            
            seg.topAnchor.constraint(equalTo: btnStackView.bottomAnchor, constant: 20.0),
            seg.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            seg.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            
        ])
        
        [samp1, samp2, samp3].forEach { v in
            v.lineWidth = lineWidth
            v.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(v)
            NSLayoutConstraint.activate([
                v.topAnchor.constraint(equalTo: seg.bottomAnchor, constant: 20.0),
                v.widthAnchor.constraint(equalToConstant: 320.0 + lineWidth * 2.0),
                v.heightAnchor.constraint(equalToConstant: 400.0 + lineWidth * 2.0),
                v.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            ])
            v.isHidden = true
        }
        
        samp1.fillColor = .green
        samp2.fillColor = .cyan
        samp3.fillColor = .yellow

        samp1.cornerType = .def
        samp2.cornerType = .tang
        samp3.cornerType = .quad

        curRadiusPct = 0.25
        seg.selectedSegmentIndex = 0
        segChanged(seg)
    }
    
    @objc func segChanged(_ sender: UISegmentedControl) {
        samp1.isHidden = sender.selectedSegmentIndex != 0
        samp2.isHidden = sender.selectedSegmentIndex != 1
        samp3.isHidden = sender.selectedSegmentIndex != 2
    }
    
}

Looks like this when running:

enter image description here

enter image description here

enter image description here