I'm trying to create an oval shape / rounded corners rect with UIBezierPath. What i want to achieve is this shape
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
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
So, we start with a corner radius of 25% and we'll increment it as we go:
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:
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:
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: