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

前端 未结 4 1366
故里飘歌
故里飘歌 2020-12-01 14:31

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

相关标签:
4条回答
  • 2020-12-01 14:47

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

    0 讨论(0)
  • 2020-12-01 14:49

    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.

    0 讨论(0)
  • 2020-12-01 14:54

    Absolutely perfect 2019 solution

    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:

    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

    0 讨论(0)
  • 2020-12-01 14:56

    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.

    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.

    0 讨论(0)
提交回复
热议问题