How to make this shape in iOS swift?

210 Views Asked by At

I want to create this badge or direction-type view in swift. I cannot use image because text can be long in other languages. I want to make it with UIView. I want to achieve this:

enter image description here

I managed to make it with sharp points with this code

class ArrowView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.commonInit()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.commonInit()
    }

    private func commonInit() -> Void {
        let path = UIBezierPath()
        path.move(to: CGPoint(x: 0, y: 0)) // Starting point: Top left
        path.addLine(to: CGPoint(x: self.frame.width - self.frame.height/2, y: 0)) // Move to the top right before the arrow point
        path.addLine(to: CGPoint(x: self.frame.width, y: self.frame.height/2)) // Move to the tip of the arrow
        path.addLine(to: CGPoint(x: self.frame.width - self.frame.height/2, y: self.frame.height)) // Move to the bottom right before the arrow point
        path.addLine(to: CGPoint(x: 0, y: self.frame.height)) // Move to the bottom left
        path.close()

        let shapeLayer = CAShapeLayer()
        shapeLayer.path = path.cgPath
        shapeLayer.fillColor = UIColor.black.cgColor // Choose the color of the arrow

        self.layer.addSublayer(shapeLayer)
    }

    override func layoutSubviews() {
        self.layer.sublayers?.forEach { $0.removeFromSuperlayer() }
        self.commonInit()
    }
}

How to make line joint rounded like the actual design?

2

There are 2 best solutions below

0
DonMag On BEST ANSWER

Another option...

Work with CGMutablePath and take advantage of tangent curves:

@IBDesignable
class ArrowView: UIView {
    
    @IBInspectable var cornerRadius: CGFloat = 6.0 { didSet { setNeedsLayout() } }
    @IBInspectable var color: UIColor = .black { didSet { setNeedsLayout() } }
    
    override class var layerClass: AnyClass { CAShapeLayer.self }
    var shapeLayer: CAShapeLayer { layer as! CAShapeLayer }

    override func layoutSubviews() {
        super.layoutSubviews()

        // may want to include this to make sure self's background is clear
        self.backgroundColor = .clear
        
        // set the shape fill color
        shapeLayer.fillColor = color.cgColor

        // use rect as reference to bounds
        //  makes it easy if we want to inset the shape
        let rect: CGRect = bounds
        
        let arrowInset: CGFloat = rect.height / 2.0 //- cornerRadius / 2.0
        
        // top-left corner
        let pt1: CGPoint = .init(x: rect.minX, y: rect.minY)
        // top-right curve
        let pt2: CGPoint = .init(x: rect.maxX - arrowInset, y: rect.minY)
        // arrow point
        let pt3: CGPoint = .init(x: rect.maxX, y: rect.midY)
        // bottom-right curve
        let pt4: CGPoint = .init(x: rect.maxX - arrowInset, y: rect.maxY)
        // bottom-left corner
        let pt5: CGPoint = .init(x: rect.minX, y: rect.maxY)
        
        let cPath: CGMutablePath = CGMutablePath()
        
        cPath.move(to: pt1)
        cPath.addArc(tangent1End: pt2, tangent2End: pt3, radius: cornerRadius)
        cPath.addArc(tangent1End: pt3, tangent2End: pt4, radius: cornerRadius)
        cPath.addArc(tangent1End: pt4, tangent2End: pt5, radius: cornerRadius)
        cPath.addLine(to: pt5)
        cPath.closeSubpath()
        
        shapeLayer.path = cPath
    }
    
}

enter image description here

Note: I used the same @IBInspectable properties to make it easy for you to compare with Rob's implementation.

4
Rob On

A few observations:

  • One way to do this is to stroke the outline of the curved arrow; that entails drawing lines for the straight edges and arcs for the corners, requiring in 10 strokes (i.e., five edges and five corners);

  • Another (simpler) way to do it is with a path that consists of just five straight edges, but round the corners of the joins. The trick is, given a desired “corner radius”, to:

    • inset the path by the amount of the corner radius,
    • stroke the path with a lineWidth of twice the corner radius, and
    • use a lineJoin of .round.

    That requires only 5 strokes. To help visualize what is going on, here it is using a different color for the stroke than the fill:

    enter image description here

    Or if you set the stroke to be the same color, you get the shape you intended:

    enter image description here

    And you can achieve that with:

    @IBDesignable
    class ArrowView: UIView {
        override class var layerClass: AnyClass { CAShapeLayer.self }
        var shapeLayer: CAShapeLayer { layer as! CAShapeLayer }
    
        @IBInspectable var cornerRadius: CGFloat = 6 { didSet { setNeedsLayout() } }
        @IBInspectable var color: UIColor = .black { didSet { setNeedsLayout() } }
    
        override func layoutSubviews() {
            super.layoutSubviews()
    
            let rect = bounds.insetBy(dx: cornerRadius, dy: cornerRadius)
            let arrowInset = rect.height / 2
    
            let path = UIBezierPath()
            path.move(to: CGPoint(x: rect.minX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.maxX - arrowInset, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
            path.addLine(to: CGPoint(x: rect.maxX - arrowInset, y: rect.maxY))
            path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
            path.close()
    
            shapeLayer.lineWidth = cornerRadius * 2
            shapeLayer.path = path.cgPath
            shapeLayer.fillColor = color.cgColor
            shapeLayer.strokeColor = color.cgColor
            shapeLayer.lineJoin = .round
        }
    }
    

A few additional observations:

  1. I have made that @IBDesignable (so I can add it in IB and set the color and radius on the property sheet), but that is optional.

  2. Note, you want to use bounds, not frame in this path routine. The former is in the coordinate system of the current view, but the latter is the coordinate system of its superview (which is not what you want).

  3. I am setting the base layerClass to be a CAShapeLayer, which saves me from having to add another layer in the view. That is, again, optional, but simplifies the code a little.

  4. In layoutSubviews, make sure to call super rendition.


If you do not want the left edges rounded, too, the easiest way is to (a) not close the path; and (b) do not inset from left:

override func layoutSubviews() {
    super.layoutSubviews()

    let rect = bounds.inset(by: UIEdgeInsets(top: cornerRadius, left: 0, bottom: cornerRadius, right: cornerRadius))
    let arrowInset = rect.height / 2

    let path = UIBezierPath()
    path.move(to: CGPoint(x: rect.minX, y: rect.minY))
    path.addLine(to: CGPoint(x: rect.maxX - arrowInset, y: rect.minY))
    path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
    path.addLine(to: CGPoint(x: rect.maxX - arrowInset, y: rect.maxY))
    path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
    // path.close()

    shapeLayer.lineWidth = cornerRadius * 2
    shapeLayer.path = path.cgPath
    shapeLayer.fillColor = color.cgColor
    shapeLayer.strokeColor = color.cgColor
    shapeLayer.lineJoin = .round
}

Yielding (with black stroke):

enter image description here

Or with same colored stroke:

enter image description here