Search code examples
swiftuibezierpath

UIBezierPath issue with oval shape, lines sticks out


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


Solution

  • 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