gooberboobbutt
gooberboobbutt

Reputation: 787

UIBezierPath: How to add a border around a view with rounded corners?

I am using UIBezierPath to have my imageview have round corners but I also want to add a border to the imageview. Keep in mind the top is a uiimage and the bottom is a label.

Currently using this code produces:

let rectShape = CAShapeLayer()
rectShape.bounds = myCell2.NewFeedImageView.frame
rectShape.position = myCell2.NewFeedImageView.center
rectShape.path = UIBezierPath(roundedRect: myCell2.NewFeedImageView.bounds,
    byRoundingCorners: .TopRight | .TopLeft,
    cornerRadii: CGSize(width: 25, height: 25)).CGPath
myCell2.NewFeedImageView.layer.mask = rectShape

current

I want to add a green border to that but I cant use

myCell2.NewFeedImageView.layer.borderWidth = 8
myCell2.NewFeedImageView.layer.borderColor = UIColor.greenColor().CGColor

because it cuts off the top left and top right corner of the border as seen in this image:

issue

Is there a way too add in a border with UIBezierPath along with my current code?

Upvotes: 26

Views: 32405

Answers (5)

Fattie
Fattie

Reputation: 12296

Perfect 2024 solution:

enter image description here

The only full solution:

  1. Do not use use, in any way, the "standard" .layer that comes with the view
  2. Make a new layer only for the image.
  3. Make a new layer for the border as such.
  4. It is very, very tricky to do the arithmetic for the radii match.

The key facts are:

  1. With a CALayer, you can indeed apply a MASK as such and it only affects that layer.
  2. When drawing a circle (or indeed any border), you have to attend very carefully to the fact that you only get "half the width" - in short never crop using the same path you draw with. Very few programmers bother with this critical detail when working with curves.
  3. Notice the original cat image is exactly as wide as the horizontal yellow arrow. You have to be careful to paint the image so that the whole image appears in the roundel, which is smaller than the overall custom control.

So, setup in the usual way

import UIKit

@IBDesignable class GreenCirclePerson: UIView {
    
    @IBInspectable var borderColor: UIColor = UIColor.black { didSet { setup() } }
    @IBInspectable var trueBorderThickness: CGFloat = 2.0 { didSet { setup() } }
    @IBInspectable var trueGapThickness: CGFloat = 2.0 { didSet { setup() } }
    
    @IBInspectable var picture: UIImage? = nil { didSet { setup() } }
    
    override func layoutSubviews() {
         super.layoutSubviews()
         setup()
    }
    
    var imageLayer: CALayer? = nil
    var border: CAShapeLayer? = nil
    
    func setup() {
        
        if (imageLayer == nil) {
            imageLayer = CALayer()
            self.layer.addSublayer(imageLayer!)
        }
        if (border == nil) {
            border = CAShapeLayer()
            self.layer.addSublayer(border!)
        }
        

Now carefully make the layer for the circularly-cropped image:

        // the ultimate size of our custom control:
        let box = self.bounds.aspectFit()
        
        let totalInsetOnAnyOneSide = trueBorderThickness + trueGapThickness
        
        let boxInWhichImageSits = box.inset(by:
           UIEdgeInsets(top: totalInsetOnAnyOneSide, left: totalInsetOnAnyOneSide,
           bottom: totalInsetOnAnyOneSide, right: totalInsetOnAnyOneSide))
        
        // just a note. that version of inset#by is much clearer than the
        // confusing dx/dy variant, so best to use that one
        
        imageLayer!.frame = boxInWhichImageSits
        imageLayer!.contents = picture?.cgImage
        imageLayer?.contentsGravity = .resizeAspectFill
        
        let halfImageSize = boxInWhichImageSits.width / 2.0
        
        let maskPath = UIBezierPath(roundedRect: imageLayer!.bounds,
           cornerRadius:halfImageSize)
        let maskLayer = CAShapeLayer()
        maskLayer.path = maskPath.cgPath
        imageLayer!.mask = maskLayer
        

Next as a completely separate layer, draw the border as you wish:

        // now create the border
        
        border!.frame = bounds
        
        // To draw the border, you must inset it by half the width of the border,
        // otherwise you'll be drawing only half the border. (Indeed, as an additional
        // subtle problem you are clipping rather than rendering the outside edge.)
        
        let halfWidth = trueBorderThickness / 2.0
        let borderCenterlineBox = box.inset(by:
            UIEdgeInsets(top: halfWidth, left: halfWidth,
            bottom: halfWidth, right: halfWidth))
        
        let halfBorderBoxSize = borderCenterlineBox.width / 2.0
        
        let borderPath = UIBezierPath(roundedRect: borderCenterlineBox,
          cornerRadius:halfBorderBoxSize)
        
        border!.path = borderPath.cgPath
        border!.fillColor = UIColor.clear.cgColor
        
        border!.strokeColor = borderColor.cgColor
        border!.lineWidth = trueBorderThickness
    }
}

Everything works perfectly as in iOS standard controls:

enter image description here

Everything which is invisible is invisible; you can see-through the overall custom control to any material behind, there are no "half thickness" problems or missing image material, you can set the custom control background color in the usual way, etc etc. The inspector controls all work properly. (Phew!)

As a footnote, you'd write this using lazy var idiom as with any custom control. I wrote it as above for didactic reasons.

Similar solutions:

https://stackoverflow.com/a/57465440/294884 - image + rounded + shadows
https://stackoverflow.com/a/41553784/294884 - two-corner problem
https://stackoverflow.com/a/59092828/294884 - "shadows + hole" or "glowbox" problem
https://stackoverflow.com/a/57400842/294884 - the "border AND gap" problem
https://stackoverflow.com/a/57514286/294884 - basic "adding" beziers

Upvotes: 4

Fattie
Fattie

Reputation: 12296

Older 2017 solution:

See the OTHER ANSWER for a more up-to-date, perfect solution.


This

  • correctly addresses the issue that you are drawing HALF OF THE BORDER LINE

  • is totally usable in autolayout

  • completely re-works itself when the size of the view changes or animates

for 2019 ...

@IBDesignable
class RoundedCornersAndTrueBorder: UIView {
    @IBInspectable var cornerRadius: CGFloat = 10 {
        didSet { setup() }
    }
    @IBInspectable var borderColor: UIColor = UIColor.black {
        didSet { setup() }
    }
    @IBInspectable var trueBorderWidth: CGFloat = 2.0 {
        didSet { setup() }
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        setup()
    }
    
    var border:CAShapeLayer? = nil
    
    func setup() {
        // make a path with round corners
        let path = UIBezierPath(
          roundedRect: self.bounds, cornerRadius:cornerRadius)
        
        // note that it is >exactly< the size of the whole view
        
        // mask the whole view to that shape
        // note that you will ALSO be masking the border we'll draw below
        let mask = CAShapeLayer()
        mask.path = path.cgPath
        self.layer.mask = mask
        
        // add another layer, which will be the border as such
        
        if (border == nil) {
            border = CAShapeLayer()
            self.layer.addSublayer(border!)
        }
        // IN SOME APPROACHES YOU would INSET THE FRAME
        // of the border-drawing layer by the width of the border
        // border.frame = bounds.insetBy(dx: borderWidth, dy: borderWidth)
        // so that when you draw the line, ALL of the WIDTH of the line
        // DOES fall within the actual mask.
        
        // here, we will draw the border-line LITERALLY ON THE EDGE
        // of the path. that means >HALF< THE LINE will be INSIDE
        // the path and HALF THE LINE WILL BE OUTSIDE the path
        border!.frame = bounds
        let pathUsingCorrectInsetIfAny =
          UIBezierPath(roundedRect: border!.bounds, cornerRadius:cornerRadius)
        
        border!.path = pathUsingCorrectInsetIfAny.cgPath
        border!.fillColor = UIColor.clear.cgColor
        
        // the following is not what you want:
        // it results in "half-missing corners"
        // (note however, sometimes you do use this approach):
        //border.borderColor = borderColor.cgColor
        //border.borderWidth = borderWidth
        
        // this approach will indeed be "inside" the path:
        border!.strokeColor = borderColor.cgColor
        border!.lineWidth = trueBorderWidth * 2.0
        // HALF THE LINE will be INSIDE the path and HALF THE LINE
        // WILL BE OUTSIDE the path. so MAKE IT >>TWICE AS THICK<<
        // as requested by the consumer class.
        
    }
}

So that's it.

(Note - don't forget IBDesignable is completely broken (2023) in Xcode storyboard, so, as with any IBDesignable, you won't "see it on storyboard".)

Similar solutions:

https://stackoverflow.com/a/57465440/294884 - image + rounded + shadows
https://stackoverflow.com/a/41553784/294884 - two-corner problem
https://stackoverflow.com/a/59092828/294884 - "shadows + hole" or "glowbox" problem
https://stackoverflow.com/a/57400842/294884 - the "border AND gap" problem
https://stackoverflow.com/a/57514286/294884 - basic "adding" beziers

And please also see the alternate answer below! :)

Upvotes: 9

Akshay Khadke
Akshay Khadke

Reputation: 139

**use this extension for round borders and corner**



       
       
    
 extension UIView {

   func roundCorners(corners: UIRectCorner, radius: CGFloat) {
        let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
        let mask = CAShapeLayer()
        mask.path = path.cgPath
        layer.mask = mask
    }
    
    func roundCornersWithBorder(corners: UIRectCorner, radius: CGFloat) {
        let maskLayer = CAShapeLayer()
        maskLayer.frame = bounds
        maskLayer.path = UIBezierPath(roundedRect: bounds, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: radius, height: radius)).cgPath
        
        layer.mask = maskLayer
        
        // Add border
        let borderLayer = CAShapeLayer()
        borderLayer.path = maskLayer.path // Reuse the Bezier path
        borderLayer.fillColor = UIColor.clear.cgColor
        borderLayer.strokeColor = UIColor(red:3/255, green:33/255, blue:70/255, alpha: 0.15).cgColor
        borderLayer.lineWidth = 2
        borderLayer.frame = bounds
        layer.addSublayer(borderLayer)
    }
    
}

Use like this

myView.roundCornersWithBorder(corners: [.topLeft, .topRight], radius: 8.0)

myView.roundCorners(corners: [.topLeft, .topRight], radius: 8.0)

Upvotes: 0

hennes
hennes

Reputation: 9352

You can reuse the UIBezierPath path and add a shape layer to the view. Here is an example inside a view controller.

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // Create a view with red background for demonstration
        let v = UIView(frame: CGRectMake(0, 0, 100, 100))
        v.center = view.center
        v.backgroundColor = UIColor.redColor()
        view.addSubview(v)

        // Add rounded corners
        let maskLayer = CAShapeLayer()
        maskLayer.frame = v.bounds
        maskLayer.path = UIBezierPath(roundedRect: v.bounds, byRoundingCorners: .TopRight | .TopLeft, cornerRadii: CGSize(width: 25, height: 25)).CGPath
        v.layer.mask = maskLayer

        // Add border
        let borderLayer = CAShapeLayer()
        borderLayer.path = maskLayer.path // Reuse the Bezier path
        borderLayer.fillColor = UIColor.clearColor().CGColor
        borderLayer.strokeColor = UIColor.greenColor().CGColor
        borderLayer.lineWidth = 5
        borderLayer.frame = v.bounds
        v.layer.addSublayer(borderLayer)   
    }

}

The end result looks like this.

Simulator screenshot

Note that this only works as expected when the view's size is fixed. When the view can resize, you will need to create a custom view class and resize the layers in layoutSubviews.

Upvotes: 63

Clay Ellis
Clay Ellis

Reputation: 5340

There sure is! Every view has a layer property (which you know from giving your layer rounded corners). Another two properties on layer are borderColor and borderWidth. Just by setting those you can add a border to your view! (The border will follow the rounded corners.) Be sure to use UIColor.CGColor for borderColor as a plain UIColor won't match the type.

Upvotes: 0

Related Questions