How the iOS system draw the view corner shape?

Viewed 129

Sorry that my description is not clear, my purpose is to display some unusual borders, such as dashed lines, dots, gradients, etc., but using Bezier curves to draw cannot work with layer.cornerRadius. Of course, it can use layer.mask. to solve, for performance considerations and my curiosity, I want to combine them perfectly.

Original intention

I want to draw a custom border to UIView by my self. In this process, I have encountered some problems that confuse me:

  • The edge of the Bezier curve cannot be aligned with the edge of the view.
  • The rounded edge of the system is larger than the rounded range drawn by the Bezier curve.

Try to solve

Q1:

I have solved the first problem, because the pen tip for drawing the curve is in the middle of the line width, so it cannot be drawn along the edge of the view. It needs a certain distance。

To solve it, I separate the line segment from the view so that I can better observe:

let bgView = UIView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
bgView.backgroundColor = UIColor.red
bgView.layer.cornerRadius = 10
view.addSubview(bgView)
let imageView = UIImageView()
imageView.frame = CGRect(x: 100, y: 100, width: 100, height: 100)
imageView.backgroundColor = UIColor.clear
view.addSubview(imageView)
bgView.center = imageView.center

//draw a 80 * 80 image in the context center
UIGraphicsBeginImageContextWithOptions(CGSize(width: 100, height: 100), false, UIScreen.main.scale)
let borderWidth = 4
let rect = CGRect(x: 10 , y: 10, width: 80, height: 80)
let roundPath = UIBezierPath(roundedRect: rect, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: 10, height: 10))
roundPath.lineWidth = 4
roundPath.lineJoinStyle = .round
UIColor.black.withAlphaComponent(0.3).setStroke()
roundPath.stroke()

imageView.image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

Run and enlarge, you will see:

result of Q1

You can see that the edge of the view is in the middle of the line width, so the solution is to adjust the drawing of the Bezier curve rect.

//change
//let rect = CGRect(x: 10 , y: 10, width: 80, height: 80)
//to
let rect = CGRect(x: 10 + 2, y: 10 + 2, width: 80 - 4, height: 80 - 4)

but I did not find the relevant documentation to explain it. Does anyone know?

Q2

Because of Q1’s solution, Q2 was also found:

Q2 problem

Only the rounded corners of the View exceed the range of the Bezier curve.

At first I thought it was a problem with the way of drawing the Bezier curve, so I broke down the drawing steps:

let roundPath3 = UIBezierPath()
roundPath3.lineWidth = 4
roundPath3.lineJoinStyle = .round
roundPath3.move(to: CGPoint(x: rect.minX + 10, y: rect.minY))
roundPath3.addLine(to: CGPoint(x: rect.maxX - 10, y: rect.minY))
roundPath3.addArc(withCenter: CGPoint(x: rect.maxX - 10, y: rect.minY + 10), radius: 10, startAngle: 1.5 * CGFloat.pi, endAngle: 2 * CGFloat.pi, clockwise: true)
roundPath3.move(to: CGPoint(x: rect.maxX, y: rect.minY + 10))
roundPath3.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 10))
roundPath3.addArc(withCenter: CGPoint(x: rect.maxX - 10, y: rect.maxY - 10), radius: 10, startAngle: 0 * CGFloat.pi, endAngle: 0.5 * CGFloat.pi, clockwise: true)
roundPath3.move(to: CGPoint(x: rect.maxX - 10, y: rect.maxY))
roundPath3.addLine(to: CGPoint(x: rect.minX + 10, y: rect.maxY))
roundPath3.addArc(withCenter: CGPoint(x: rect.minX + 10, y: rect.maxY - 10), radius: 10, startAngle: 0.5 * CGFloat.pi, endAngle: 1 * CGFloat.pi, clockwise: true)
roundPath3.move(to: CGPoint(x: rect.minX, y: rect.maxY - 10))
roundPath3.addLine(to: CGPoint(x: rect.minX, y: rect.minY + 10))
roundPath3.addArc(withCenter: CGPoint(x: rect.minX + 10, y: rect.minY + 10), radius: 10, startAngle: 1 * CGFloat.pi, endAngle: 1.5 * CGFloat.pi, clockwise: true)
UIColor.blue.withAlphaComponent(0.5).setStroke()
roundPath3.stroke()

Unfortunately it is the same as the result above. I also tried to extend the radius of the drawn arc by 1pt. Although it covered the rounded corners of the view, the result was ugly.

I observed carefully, and guess the implementation of the iOS system does not look like a pure circle, more like an ellipse. So I have an idea to adjust the control points of the quadratic Bezier curve to simulate, but I have no clue to calculate the appropriate control point.

1 Answers

You're running into a couple issues here...

First, there are two curve types for .cornerRadius:

  • .round -- the default. Works great for making a "round" view
  • .continuous -- a more pleasing visual curve, best for "rounded corners"

To demonstrate the difference, here is a green view on top of a red view, where the red view uses .round and the green view uses .continuous:

enter image description here

Note that if we set both to .continuous, we get this:

enter image description here

A UIBezierPath(roundedRect: ...) uses the .continuous curve, so if we mask the green view (instead of setting its .cornerRadius) and use the default curve for the red view, it looks the same as the first example:

enter image description here

And if we mask the green view and set the red view to .continuous we get the same result as the second example:

enter image description here

If you're looking closely, you see a faint red "edge" -- this is due to UIKit anti-aliasing. If we set the "green view" background color to white, it's very obvious:

enter image description here

But, that's a different issue, which will probably not affect what you're trying to do.

The next part is trying to "outline" the view. As you already know, the path's stroke gets set to the centerline of the curve:

enter image description here

We have half of the stroke width inside, and half of the stroke width outside.

So, the attempt to "fix" that is to inset the outline view by 1/2 of the stroke width ... but, again as you've seen, we end up with this:

enter image description here

That's because the stroke ends up with 3 Different Radii!

Let's use a corner radius of 40 and a stroke width of 16 to make it easier to see.

Here it is with equal sized views:

enter image description here

If we simply inset the "outline view" by 1/2 the border width and don't change the radius, we get this:

enter image description here

So, let's adjust the bezier path's corner radius by 1/2 of the stroke width:

enter image description here

and our final result (without the center-line shape layer) is:

enter image description here

Here's some code to play around and inspect what's going on -- each tap steps through what I've described above:

class CornersViewController: UIViewController {
    
    let stepLabel = UILabel()
    let infoLabel = UILabel()
    
    let bgViewWidth: CGFloat = 400
    let cornerRadius: CGFloat = 40
    let borderWidth: CGFloat = 16
    
    lazy var viewFrame: CGRect = CGRect(x: -200, y: 260, width: bgViewWidth, height: bgViewWidth)
    
    var step: Int = 1
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .white
        
        stepLabel.translatesAutoresizingMaskIntoConstraints = false
        stepLabel.font = .systemFont(ofSize: 12.0, weight: .bold)
        //stepLabel.textAlignment = .center
        view.addSubview(stepLabel)
        infoLabel.translatesAutoresizingMaskIntoConstraints = false
        infoLabel.numberOfLines = 0
        infoLabel.font = .systemFont(ofSize: 12.0, weight: .light)
        view.addSubview(infoLabel)
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            stepLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            stepLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            stepLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            infoLabel.topAnchor.constraint(equalTo: stepLabel.bottomAnchor, constant: 8.0),
            infoLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            infoLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
        ])

        let t = UITapGestureRecognizer(target: self, action: #selector(gotTap(_:)))
        view.addGestureRecognizer(t)
        
        nextStep()
    }
    
    @objc func gotTap(_ g: UITapGestureRecognizer) -> Void {
        nextStep()
    }
    
    func nextStep() {
        
        // remove existing example views, but not the "info" label
        view.subviews.forEach { v in
            if !(v is UILabel) {
                v.removeFromSuperview()
            }
        }
        
        stepLabel.text = "Step: \(step) of \(infoStrings.count)"
        infoLabel.text = infoStrings[step - 1]
        
        // red:     .cornerCurve = .round (default)
        // green:   .cornerCurve = .continuous
        if step == 1 {
            let redView = UIView(frame: viewFrame)
            view.addSubview(redView)
            
            redView.backgroundColor = UIColor.red
            redView.layer.cornerRadius = cornerRadius
            
            let greenView = UIView(frame: viewFrame)
            view.addSubview(greenView)
            
            greenView.backgroundColor = UIColor.green
            greenView.layer.cornerRadius = cornerRadius
            greenView.layer.cornerCurve = .continuous
        }

        // red:     .cornerCurve = .continuous
        // green:   .cornerCurve = .continuous
        if step == 2 {
            let redView = UIView(frame: viewFrame)
            view.addSubview(redView)
            
            redView.backgroundColor = UIColor.red
            redView.layer.cornerRadius = cornerRadius
            redView.layer.cornerCurve = .continuous

            let greenView = UIView(frame: viewFrame)
            view.addSubview(greenView)
            
            greenView.backgroundColor = UIColor.green
            greenView.layer.cornerRadius = cornerRadius
            greenView.layer.cornerCurve = .continuous
        }
        
        // red:     .cornerCurve = .round (default)
        // green:   masked with UIBezierPath(roundedRect: ...)
        if step == 3 {
            let redView = UIView(frame: viewFrame)
            view.addSubview(redView)
            
            redView.backgroundColor = UIColor.red
            redView.layer.cornerRadius = cornerRadius
            
            let greenView = UIView(frame: viewFrame)

            view.addSubview(greenView)
            
            greenView.backgroundColor = UIColor.green
            
            let maskLayer = CAShapeLayer()
            let maskBez = UIBezierPath(roundedRect: greenView.bounds, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
            maskLayer.path = maskBez.cgPath
            greenView.layer.mask = maskLayer
        }

        // red:     .cornerCurve = .continuous
        // green:   masked with UIBezierPath(roundedRect: ...)
        if step == 4 {
            let redView = UIView(frame: viewFrame)
            view.addSubview(redView)
            
            redView.backgroundColor = UIColor.red
            redView.layer.cornerRadius = cornerRadius
            redView.layer.cornerCurve = .continuous
            
            let greenView = UIView(frame: viewFrame)
            
            view.addSubview(greenView)
            
            greenView.backgroundColor = UIColor.green
            
            let maskLayer = CAShapeLayer()
            let maskBez = UIBezierPath(roundedRect: greenView.bounds, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
            maskLayer.path = maskBez.cgPath
            greenView.layer.mask = maskLayer
        }
        
        // red:         .cornerCurve = .continuous
        // bordered:    sublayer with UIBezierPath(roundedRect: ...)
        //              clear fill, 30%-black stroke, lineWidth == borderWidth
        if step == 5 {
            let redView = UIView(frame: viewFrame)
            view.addSubview(redView)
            
            redView.backgroundColor = UIColor.red
            redView.layer.cornerRadius = cornerRadius
            redView.layer.cornerCurve = .continuous
            
            let borderedView = UIView(frame: viewFrame)

            view.addSubview(borderedView)
            
            borderedView.backgroundColor = UIColor.clear

            borderedView.layer.cornerRadius = cornerRadius
            borderedView.layer.cornerCurve = .continuous

            let borderLayer = CAShapeLayer()
            let roundPath = UIBezierPath(roundedRect: borderedView.bounds, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))

            borderLayer.path = roundPath.cgPath

            borderLayer.lineWidth = borderWidth
            borderLayer.fillColor = UIColor.clear.cgColor
            borderLayer.strokeColor = UIColor.black.withAlphaComponent(0.3).cgColor

            borderedView.layer.addSublayer(borderLayer)
        }

        // red:         .cornerCurve = .continuous
        // bordered:    sublayer with UIBezierPath(roundedRect: ...)
        //              clear fill, 30%-black stroke, lineWidth == borderWidth
        //              frame inset by 1/2 borderWidth
        if step == 6 {
            let redView = UIView(frame: viewFrame)
            view.addSubview(redView)
            
            redView.backgroundColor = UIColor.red
            redView.layer.cornerRadius = cornerRadius
            redView.layer.cornerCurve = .continuous
            
            let borderedView = UIView(frame: viewFrame)

            view.addSubview(borderedView)
            
            borderedView.backgroundColor = UIColor.red
            
            borderedView.layer.cornerRadius = cornerRadius
            borderedView.layer.cornerCurve = .continuous
            
            let borderLayer = CAShapeLayer()
            
            let rect = borderedView.bounds.insetBy(dx: borderWidth * 0.5, dy: borderWidth * 0.5)
            
            let roundPath = UIBezierPath(roundedRect: rect, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
            
            borderLayer.path = roundPath.cgPath
            
            borderLayer.lineWidth = borderWidth
            borderLayer.fillColor = UIColor.clear.cgColor
            borderLayer.strokeColor = UIColor.black.withAlphaComponent(0.3).cgColor
            
            borderedView.layer.addSublayer(borderLayer)
        }
        
        // red:         .cornerCurve = .continuous
        // bordered:    sublayer with UIBezierPath(roundedRect: ...)
        //              clear fill, 30%-black stroke, lineWidth == borderWidth
        //              frame inset by 1/2 borderWidth
        //              showing border centerLine
        if step == 7 {
            let redView = UIView(frame: viewFrame)
            view.addSubview(redView)
            
            redView.backgroundColor = UIColor.red
            redView.layer.cornerRadius = cornerRadius
            redView.layer.cornerCurve = .continuous
            
            let borderedView = UIView(frame: viewFrame)
            
            view.addSubview(borderedView)
            
            borderedView.backgroundColor = UIColor.red
            
            borderedView.layer.cornerRadius = cornerRadius
            borderedView.layer.cornerCurve = .continuous
            
            let borderLayer = CAShapeLayer()
            
            let rect = borderedView.bounds.insetBy(dx: borderWidth * 0.5, dy: borderWidth * 0.5)
            
            let roundPath = UIBezierPath(roundedRect: rect, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
            
            borderLayer.path = roundPath.cgPath
            
            borderLayer.lineWidth = borderWidth
            borderLayer.fillColor = UIColor.clear.cgColor
            borderLayer.strokeColor = UIColor.black.withAlphaComponent(0.3).cgColor
            
            borderedView.layer.addSublayer(borderLayer)
            
            let centerLineLayer = CAShapeLayer()
            
            centerLineLayer.path = roundPath.cgPath
            
            centerLineLayer.lineWidth = 1
            centerLineLayer.fillColor = UIColor.clear.cgColor
            centerLineLayer.strokeColor = UIColor.cyan.cgColor
            
            borderedView.layer.addSublayer(centerLineLayer)
        }
        
        // red:         .cornerCurve = .continuous
        // bordered:    sublayer with UIBezierPath(roundedRect: ...)
        //              clear fill, 30%-black stroke, lineWidth == borderWidth
        //              frame inset by 1/2 borderWidth
        //              radius adjusted by 1/2 borderWidth
        //              showing border centerLine
        if step == 8 {
            let redView = UIView(frame: viewFrame)
            view.addSubview(redView)
            
            redView.backgroundColor = UIColor.red
            redView.layer.cornerRadius = cornerRadius
            redView.layer.cornerCurve = .continuous
            
            let borderedView = UIView(frame: viewFrame)
            
            view.addSubview(borderedView)
            
            borderedView.backgroundColor = UIColor.red
            
            borderedView.layer.cornerRadius = cornerRadius
            borderedView.layer.cornerCurve = .continuous
            
            let borderLayer = CAShapeLayer()
            
            let rect = borderedView.bounds.insetBy(dx: borderWidth * 0.5, dy: borderWidth * 0.5)
            let adjustedRadius = cornerRadius - (borderWidth * 0.5)
            
            let roundPath = UIBezierPath(roundedRect: rect, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: adjustedRadius, height: adjustedRadius))
            
            borderLayer.path = roundPath.cgPath
            
            borderLayer.lineWidth = borderWidth
            borderLayer.fillColor = UIColor.clear.cgColor
            borderLayer.strokeColor = UIColor.black.withAlphaComponent(0.3).cgColor
            
            borderedView.layer.addSublayer(borderLayer)
            
            let centerLineLayer = CAShapeLayer()
            
            centerLineLayer.path = roundPath.cgPath
            
            centerLineLayer.lineWidth = 1
            centerLineLayer.fillColor = UIColor.clear.cgColor
            centerLineLayer.strokeColor = UIColor.cyan.cgColor
            
            borderedView.layer.addSublayer(centerLineLayer)
        }
        
        // red:         .cornerCurve = .continuous
        // bordered:    sublayer with UIBezierPath(roundedRect: ...)
        //              clear fill, 30%-black stroke, lineWidth == borderWidth
        //              frame inset by 1/2 borderWidth
        //              radius adjusted by 1/2 borderWidth
        if step == 9 {
            let redView = UIView(frame: viewFrame)
            view.addSubview(redView)
            
            redView.backgroundColor = UIColor.red
            redView.layer.cornerRadius = cornerRadius
            redView.layer.cornerCurve = .continuous
            
            let borderedView = UIView(frame: viewFrame)

            view.addSubview(borderedView)
            
            borderedView.backgroundColor = UIColor.red
            
            borderedView.layer.cornerRadius = cornerRadius
            borderedView.layer.cornerCurve = .continuous
            
            let borderLayer = CAShapeLayer()
            
            let rect = borderedView.bounds.insetBy(dx: borderWidth * 0.5, dy: borderWidth * 0.5)
            let adjustedRadius = cornerRadius - (borderWidth * 0.5)
            
            let roundPath = UIBezierPath(roundedRect: rect, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: adjustedRadius, height: adjustedRadius))
            
            borderLayer.path = roundPath.cgPath
            
            borderLayer.lineWidth = borderWidth
            borderLayer.fillColor = UIColor.clear.cgColor
            borderLayer.strokeColor = UIColor.black.withAlphaComponent(0.3).cgColor
            
            borderedView.layer.addSublayer(borderLayer)
        }
        
        // red:     .cornerCurve = .continuous
        // white:   .cornerCurve = .continuous
        if step == 10 {
            let redView = UIView(frame: viewFrame)
            view.addSubview(redView)
            
            redView.backgroundColor = UIColor.red
            redView.layer.cornerRadius = cornerRadius
            redView.layer.cornerCurve = .continuous
            
            let whiteView = UIView(frame: viewFrame)
            view.addSubview(whiteView)
            
            whiteView.backgroundColor = UIColor.white
            whiteView.layer.cornerRadius = cornerRadius
            whiteView.layer.cornerCurve = .continuous
        }
        
        step += 1
        if step > infoStrings.count {
            step = 1
        }
        
    }
    
    let infoStrings: [String] = [
        "redView:\n    .cornerCurve = .round (default)\n\ngreenView:\n    .cornerCurve = .continuous",
        "redView:\n    .cornerCurve = .continuous\n\ngreenView:\n    .cornerCurve = .continuous",
        "redView:\n    .cornerCurve = .round (default)\n\ngreenView:\n    masked with UIBezierPath(roundedRect: ...)",
        "redView:\n    .cornerCurve = .continuous\n\ngreenView:\n    masked with UIBezierPath(roundedRect: ...)",
        "redView:\n    .cornerCurve = .continuous\n\nborderedView:\n    sublayer with UIBezierPath(roundedRect: ...)\n    clear fill, 30%-black stroke, lineWidth == borderWidth",
        "redView:\n    .cornerCurve = .continuous\n\nborderedView:\n    sublayer with UIBezierPath(roundedRect: ...)\n    clear fill, 30%-black stroke, lineWidth == borderWidth\n    frame inset by 1/2 borderWidth",
        "redView:\n    .cornerCurve = .continuous\n\nborderedView:\n    sublayer with UIBezierPath(roundedRect: ...)\n    clear fill, 30%-black stroke, lineWidth == borderWidth\n    frame inset by 1/2 borderWidth\n    showing border center line",
        "redView:\n    .cornerCurve = .continuous\n\nborderedView:\n    sublayer with UIBezierPath(roundedRect: ...)\n    clear fill, 30%-black stroke, lineWidth == borderWidth\n    frame inset by 1/2 borderWidth\n    radius adjusted by 1/2 borderWidth\n    showing border center line",
        "redView:\n    .cornerCurve = .continuous\n\nborderedView:\n    sublayer with UIBezierPath(roundedRect: ...)\n    clear fill, 30%-black stroke, lineWidth == borderWidth\n    frame inset by 1/2 borderWidth\n    radius adjusted by 1/2 borderWidth\n    final result",
        "redView:\n    .cornerCurve = .continuous\n\nwhiteView:\n    .cornerCurve = .continuous\n\n    Showing the Anti-Aliasing...",
    ]
}
Related