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

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.


As it says above:

It is not easy to do this perfectly.

Here's a drop-in 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

  • is totally IBDesignable - you can see it in realtime in your storyboard

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() {
        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.


Beginner help for the question in the comments ...

  1. Make a "new Swift file" called "Fattie.swift". (Note, funnily enough it actually makes no difference what you call it. If you are at the stage of "don't know how to make a new file" seek basic Xcode tutorials.)

  2. Put all of the above code in the file

  3. You've just added a class "RoundedCornersAndTrueBorder" to your project.

  4. On your story board. Add an ordinary UIView to your scene. In fact, make it actually any size/shape whatsoever, anything you prefer.

  5. Look at the Identity Inspector. (If you do not know what that is, seek basic tutorials.) Simply change the class to "RoundedCornersAndTrueBorder". (Once you start typing "Roun...", it will guess which class you mean.

  6. You're done - run the project.

Note that you have to, of course, add complete and correct constraints to the UIView, just as with absolutely anything you do in Xcode. Enjoy!

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! :)


Absolutely perfect 2019 solution

enter image description here

Without further ado, here's exactly how you do this.

  1. Don't actually use the "basic" layer that comes with the view
  2. Make a new layer only for the image. You can now mask this (circularly) without affecting the next layer
  3. Make a new layer for the border as such. It will safely not be masked by the picture layer.

The key facts are

  1. With a CALayer, you can indeed apply a .mask and it only affects that layer
  2. When drawing a circle (or indeed any border), 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.
  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() { 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!)

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