` UIBezierPath(roundedRect:cornerRadius:)` renders Inconsistently at Specific Size-to-Radius Ratios

Hello everyone,

I've encountered a fascinating and perplexing rendering anomaly when using UIBezierPath(roundedRect:cornerRadius:) to create a CGPath.

Summary of the Issue:

When the shortest side of the rectangle (min(width, height)) is just under a certain multiple of the cornerRadius (empirically, around 3x), the algorithm for generating the path seems to change entirely. This results in a path with visually different (and larger) corners than when the side is slightly longer, even with the same cornerRadius parameter.

How to Reproduce:

The issue is most clearly observed with a fixed cornerRadius while slightly adjusting the rectangle's height or width across a specific threshold.

  1. Create a UIView (contentView) and another UIView (shadowView) behind it.
  2. Set the shadowView.layer.shadowPath using UIBezierPath(roundedRect: contentView.bounds, cornerRadius: 16).cgPath.
  3. Adjust the height of the contentView.
  4. Observe the shadowPath at height 48 vs. height 49

Minimal Reproducible Example:

Here is a simple UIViewController to demonstrate the issue. You can drop this into a project. Tapping the "Toggle Height" button will switch between the two states and print the resulting CGPath to the console.

import UIKit
 
class PathTestViewController: UIViewController {
 
    private let contentView = UIView()
    private let shadowView = UIView()
    private var heightConstraint: NSLayoutConstraint!
    
    private let cornerRadius: CGFloat = 16.0
    private let normalHeight: CGFloat = 49
    private let anomalyHeight: CGFloat = 48
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemGray5
        setupViews()
        setupButton()
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        updateShadowPath()
    }
    
    private func updateShadowPath() {
        let newPath = UIBezierPath(roundedRect: contentView.bounds, cornerRadius: cornerRadius).cgPath
        shadowView.layer.shadowPath = newPath
    }
 
    private func setupViews() {
        // ContentView (the visible rect)
        contentView.backgroundColor = .systemBlue
        contentView.translatesAutoresizingMaskIntoConstraints = false
        contentView.isHidden = true
        
        // ShadowView (to render the path)
        shadowView.layer.shadowColor = UIColor.black.cgColor
        shadowView.layer.shadowOpacity = 1
        shadowView.layer.shadowRadius = 2
        shadowView.layer.shadowOffset = .zero
        shadowView.translatesAutoresizingMaskIntoConstraints = false
 
        view.addSubview(shadowView)
        view.addSubview(contentView)
        
        heightConstraint = contentView.heightAnchor.constraint(equalToConstant: normalHeight)
 
        NSLayoutConstraint.activate([
            contentView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            contentView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            contentView.widthAnchor.constraint(equalToConstant: 300),
            heightConstraint,
            
            shadowView.topAnchor.constraint(equalTo: contentView.topAnchor),
            shadowView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            shadowView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            shadowView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
        ])
    }
    
    private func setupButton() {
        let button = UIButton(type: .system, primaryAction: UIAction(title: "Toggle Height", handler: { [unowned self] _ in
            let newHeight = self.heightConstraint.constant == self.normalHeight ? self.anomalyHeight : self.normalHeight
            self.heightConstraint.constant = newHeight
            
            UIView.animate(withDuration: 0.3) {
                self.view.layoutIfNeeded()
            }
        }))
        button.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(button)
        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20)
        ])
    }
}

Evidence: CGPath Analysis

Note: The CGPath data below is from my initial observation. At that time, height 48.7 produced a path with straight edges. Now, this "correct" path is only produced at height 49.0 or greater. The inconsistency now occurs at 48.7.*

The key difference lies in the raw CGPath data.

  1. Path for Height = 48.7 (Expected Behavior)

The path is constructed with lineto commands for the straight edges between the curved corners.

// Path for Height 48.7
Path 0x60000300a0a0:
  moveto (24.4586, 0)
    lineto (24.5414, 0) // <-- Straight line on top edge
    curveto (31.5841, 0) (35.1055, 0) (38.8961, 1.19858)
    ...
  1. Path for Height = 48.6 (Anomalous Behavior)

The lineto commands for the short edges disappear. The path is composed of continuous curveto commands, as if the two corners have merged into a single, larger curve. This creates the visual discrepancy.

// Path for Height 48.6
Path 0x600003028630:
  moveto (24.1667, 0)
    lineto (24.1667, 0) // <-- Zero-length line
    curveto (24.1667, 0) (24.1667, 0) (24.1667, 0)
    lineto (25.375, 1.44329e-15)
    curveto (34.8362, -2.77556e-16) (43.2871, 5.9174) (46.523, 14.808) // <-- First curve
    curveto (48.3333, 20.5334) (48.3333, 25.8521) (48.3333, 36.4896) // <-- Second curve, no straight line in between
    ...
min.length == 48min.length == 49

My Questions:

  1. Is this change in the path-generation algorithm at this specific size/radius threshold an intended behavior, or is it a bug?
  2. Is this behavior documented anywhere? The threshold doesn't seem to be a clean side/radius == 2.0, so it's hard to predict.
  3. Is there a recommended workaround to ensure consistent corner rendering across these small size thresholds?

Any insight would be greatly appreciated. Thank you!

Environment:

  • Xcode: 16.4
  • iOS: 16.5.1(iPad), 18.4(iphone simulator)

Please note: My initial interpretation of the CGPath differences in the 'Evidence: CGPath Analysis' section below seems to have been based on a misunderstanding of the exact path changes.

To provide a more precise analysis and ensure clarity, I'm re-attaching the full po {shadowPath} output for both cases below.

Original Path Descriptions


Here are the original Path descriptions you provided, which were used for the analysis.

Path 1: Path for Height = 48.7 (Expected Behavior)

Path 0x60000300a0a0:
 moveto (24.4586, 0)
  lineto (24.5414, 0)
  curveto (31.5841, 0) (35.1055, 0) (38.8961, 1.19858)
  lineto (38.8961, 1.19858)
  curveto (43.0348, 2.70495) (46.295, 5.96518) (47.8014, 10.1039)
  curveto (49, 13.8945) (49, 17.4159) (49, 24.4586)
  lineto (49, 325.541)
  curveto (49, 332.584) (49, 336.105) (47.8014, 339.896)
  lineto (47.8014, 339.896)
  curveto (46.295, 344.035) (43.0348, 347.295) (38.8961, 348.801)
  curveto (35.1055, 350) (31.5841, 350) (24.5414, 350)
  lineto (24.4586, 350)
  curveto (17.4159, 350) (13.8945, 350) (10.1039, 348.801)
  lineto (10.1039, 348.801)
  curveto (5.96518, 347.295) (2.70495, 344.035) (1.19858, 339.896)
  curveto (0, 336.105) (0, 332.584) (0, 325.541)
  lineto (0, 24.4586)
  curveto (0, 17.4159) (0, 13.8945) (1.19858, 10.1039)
  lineto (1.19858, 10.1039)
  curveto (2.70495, 5.96518) (5.96518, 2.70495) (10.1039, 1.19858)
  curveto (13.8945, 0) (17.4159, 0) (24.4586, 0)
  lineto (24.4586, 0)

Path 2: Path for Height = 48.6 (Anomalous Behavior)

Path 0x600003028630:
 moveto (24.1667, 0)
  lineto (24.1667, 0)
  curveto (24.1667, 0) (24.1667, 0) (24.1667, 0)
  lineto (25.375, 1.44329e-15)
  curveto (34.8362, -2.77556e-16) (43.2871, 5.9174) (46.523, 14.808)
  curveto (48.3333, 20.5334) (48.3333, 25.8521) (48.3333, 36.4896)
  lineto (48.3333, 325.541)
  curveto (48.3333, 324.148) (48.3333, 329.467) (46.523, 335.192)
  lineto (46.523, 335.192)
  curveto (43.2871, 344.083) (34.8362, 350) (25.375, 350)
  curveto (24.1667, 350) (24.1667, 350) (24.1667, 350)
  lineto (24.1667, 350)
  curveto (24.1667, 350) (24.1667, 350) (24.1667, 350)
  lineto (22.9583, 350)
  curveto (13.4972, 350) (5.04626, 344.083) (1.81036, 335.192)
  curveto (0, 329.467) (0, 324.148) (0, 313.51)
  lineto (0, 24.4586)
  curveto (0, 25.8521) (0, 20.5334) (1.81036, 14.808)
  lineto (1.81036, 14.808)
  curveto (5.04626, 5.9174) (13.4972, 3.16414e-15) (22.9583, 1.44329e-15)
  curveto (24.1667, 0) (24.1667, 0) (24.1667, 0)
  lineto (24.1667, 0)

That's a long known issue, when radius is between 1/3 and 1/2 of the side size.

Detailed analysis here and, even more interesting, a solution with addArc(tangent1End: …):

https://stackoverflow.com/questions/74623415/uibezierpath-bezierpathwithroundedrect-the-cornerradius-value-is-not-consistent

&#96; UIBezierPath(roundedRect:cornerRadius:)&#96; renders Inconsistently at Specific Size-to-Radius Ratios
 
 
Q