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.
Create a UIView (contentView) and another UIView (shadowView) behind it.
Set the shadowView.layer.shadowPath using UIBezierPath(roundedRect: contentView.bounds, cornerRadius: 16).cgPath.
Adjust the height of the contentView.
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.
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)
...
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 == 48
min.length == 49
My Questions:
Is this change in the path-generation algorithm at this specific size/radius threshold an intended behavior, or is it a bug?
Is this behavior documented anywhere? The threshold doesn't seem to be a clean side/radius == 2.0, so it's hard to predict.
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)