Search code examples
iosswiftcashapelayercgpath

How to set correct path for triangle along with stroke in CAShapLayer?


I've to create triangle with some border that fit's into another view. But the problem is, Once I set stroke then the Triangle is bigger than the container. So, I decided to calculate the inner triangle size

After applied border we'll have two triangle like Homothetic Transformation. I just want to calculate the size of the small triangle. So, the border will be fit into the container.

I tried to set it up, but I can't get it done for triangle. Because of the inner triangle center is not in the container center.

Please note that this is first try with equilateral Triangle. Originally I want solve with same solution with any shape of Polygons.

I attached full demo xcode project at here.

Please help me to solve this problem. Thank you...

ShapeView.swift

import UIKit

class ShapeView: UIView {
   
    var path: CGPath? {
        
        didSet{
            
            setNeedsDisplay(bounds)
        }
    }
    
    var cornerRadius: CGFloat = .zero
    
    var borderWidth: CGFloat{

        set{

            shapeLayer.lineWidth = newValue
             
            makeTriangle()

        }

        get{

           return shapeLayer.lineWidth
        }
    }
    
    var borderColor: UIColor{

        set{

            shapeLayer.strokeColor = newValue.cgColor
        }

        get{

            return UIColor(cgColor: shapeLayer.strokeColor ?? UIColor.clear.cgColor)
        }
    }
    
    var background: UIColor{

        set{

            shapeLayer.fillColor = newValue.cgColor
            setNeedsDisplay(bounds)
        }

        get{

            return UIColor(cgColor: shapeLayer.fillColor ?? UIColor.clear.cgColor)
        }
    }
    
    private lazy var shapeLayer: CAShapeLayer = {
        
        let shapeLayer =  CAShapeLayer()
        shapeLayer.lineWidth = .zero
        layer.addSublayer(shapeLayer)
        return shapeLayer
    }()
     
    func makeTriangle(){
 
        // Homothetic Transformation
        // https://math.stackexchange.com/questions/3952129/calculate-bounds-of-the-inner-rectangle-of-the-polygon-based-on-its-constant-bo
        // https://stackoverflow.com/questions/65315093/cashapelayer-stroke-issue
        
         
        let lineWidth: CGFloat = borderWidth/2
         
        let size  = (frame.width)-lineWidth

        let rect = CGRect(x: lineWidth, y: lineWidth , width:size, height: size)
         
        var cornerRadius: CGFloat = self.cornerRadius-lineWidth
        cornerRadius = max(cornerRadius, 0)
        
        
        let path = CGMutablePath()
        path.move(to: CGPoint(x: rect.width/2.0, y: lineWidth))
        path.addLine(to: CGPoint(x: lineWidth, y: rect.height - lineWidth))
        path.addLine(to: CGPoint(x: rect.width - lineWidth, y: rect.height - lineWidth))
        path.closeSubpath()
        
        self.path  = path
    }
    
    override func draw(_ rect: CGRect) {
        super.draw(rect)
        
        guard let path = path else{

            return
        }
        
        shapeLayer.path = path
    }
    
     
}

Current Out:

enter image description here


Solution

  • Note that the expressions for the coordinates of the bounds of the inner triangle in the Math.SE post that you linked is not very useful here, because that post assumes an equilateral triangle. However, your triangle is not equilateral. Its base has the same length as its height. The view that it is in is a square after all.

    After doing some trigonometry, I've found that the top of the inner triangle's bounding rect is insetted by lineWidth divided by sin(atan(0.5)). The bottom is insetted by lineWidth, obviously. And since the inner bounding rect also has to be a square, the left and right insets are both half of the sum of the top and bottom insets.

    Here is a GeoGebra screenshot illustrating where sin(atan(0.5)) comes from (I'm rather bad with GeoGebra, so please forgive the mess):

    enter image description here

    The right half of the outer triangle has a base that is half of its height, so beta = atan(0.5). Then consider the triangle E'EA2, and notice that sin(beta) = lineWidth / EE', and EE' is the top inset that we want.

    // makeTriangle
    
    let lineWidth: CGFloat = borderWidth / 2
    let top = lineWidth / sin(atan(0.5))
    let bottom = lineWidth
    let horizontal = (top + bottom) / 2
    let rect = bounds.inset(by:
                UIEdgeInsets(
                    top: top,
                    left: horizontal,
                    bottom: bottom,
                    right: horizontal
                )
    )
    
    let path = CGMutablePath()
    path.move(to: CGPoint(x: rect.midX, y: rect.minY))
    path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
    path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
    path.closeSubpath()()
    
    self.path = path
    

    Now the outer triangle should fit neatly inside the square. But note that by default, CALayer property changes are animated, and the outer triangle could exceed the bounds of the square if you are sliding the slider too quickly, and the animations can't keep up. You can disable the animations by wrapping the code that sets CALayer properties into a CATransaction, and setDisableActions(true). For example in the setter of borderWidth:

    set{
    
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        
        shapeLayer.lineWidth = newValue
         
        makeTriangle()
        CATransaction.commit()
    }