skip to Main Content

I have the following shape drawn with some color & linewidth:

struct CustomShape: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: .zero)
        path.addLine(to: CGPoint(x: rect.width, y: 0))
        path.addLine(to: CGPoint(x: rect.width/2, y: rect.height))
        path.closeSubpath()
        return path
    }
}

struct ContentView: View {    
    var body: some View {
        ZStack {
            CustomShape()
                .stroke(Color.blue, lineWidth: 5)
                .frame(width: 300, height: 200)
        }
        .ignoresSafeArea()
    }
}

I’d like to draw 2 (possibly more) more rectangles inside the first one each with a different color & line width? Seems like I need the shape to conform to InsettableShape and I could then use stroke border but I’m not sure how.

Representative image:
enter image description here

2

Answers


  1. I wasn’t familiar with InsettableShape, but with the help of this answer I learned how it could be used.

    The difficult part of this problem is working out the position for the triangle so that the edges of an inner triangle are equally spaced from the edges of an outer triangle. This requires a bit of trigonometry!

    So here are two possible solutions, both using a ZStack to super-impose the shapes.

    1. The first solution uses the basic Shape that you provided. A y-offset is then applied to adjust the position of the nested shapes. For this solution, the ZStack uses alignment: .top:
    struct ContentView: View {
    
        let w: CGFloat = 250
        let h: CGFloat = 220
    
        private func yOffset(scalingFactor: CGFloat) -> CGFloat {
            let dW = w - (w * scalingFactor)
            let dH = h - (h * scalingFactor)
            let angle = atan2(2 * h, w)
            let sideSpace = (dW / 2) * sin(angle)
            return (dH * sideSpace) / (dH + sideSpace)
        }
    
        private func customShape(color: Color, scalingFactor: CGFloat = 1.0) -> some View {
            CustomShape()
                .fill(color)
                .overlay(
                    CustomShape()
                        .stroke(.black, lineWidth: 5)
                )
                .frame(width: w * scalingFactor, height: h * scalingFactor)
                .offset(y: scalingFactor == 1.0 ? 0 : yOffset(scalingFactor: scalingFactor))
        }
    
        var body: some View {
            ZStack(alignment: .top) {
                customShape(color: .purple)
                customShape(color: .pink, scalingFactor: 0.8)
                customShape(color: .orange, scalingFactor: 0.6)
                customShape(color: .yellow, scalingFactor: 0.4)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(.blue)
        }
    }
    
    1. In the second solution, the Shape has been converted to an InsettableShape. The function yTop is derived from the function yOffset from above. The ZStack uses default alignment this time.
    struct CustomShape: InsettableShape {
        var insetAmount: CGFloat = 0
    
        private func innerHeight(w: CGFloat, h: CGFloat) -> CGFloat {
            let ratio = h / w
            return ratio * (w - insetAmount - insetAmount)
        }
    
        private func yTop(w: CGFloat, h: CGFloat) -> CGFloat {
            let dH = h - innerHeight(w: w, h: h)
            let angle = atan2(2 * h, w)
            let sideSpace = insetAmount * sin(angle)
            return (dH * sideSpace) / (dH + sideSpace)
        }
    
        func path(in rect: CGRect) -> Path {
            var path = Path()
            let yTop = insetAmount == 0 ? 0 : yTop(w: rect.width, h: rect.height)
            var x: CGFloat = insetAmount
            var y: CGFloat = yTop
            path.move(to: CGPoint(x: x, y: y))
            x = rect.width - insetAmount
            path.addLine(to: CGPoint(x: x, y: y))
            x = rect.width / 2
            y += innerHeight(w: rect.width, h: rect.height)
            path.addLine(to: CGPoint(x: x, y: y))
            path.closeSubpath()
            return path
        }
    
        func inset(by amount: CGFloat) -> some InsettableShape {
            var shape = self
            shape.insetAmount += amount
            return shape
        }
    }
    
    struct ContentView: View {
    
        private func customShape(color: Color, insetAmount: CGFloat = 0) -> some View {
            CustomShape()
                .inset(by: insetAmount)
                .fill(color)
                .strokeBorder(.black, lineWidth: 5)
        }
    
        var body: some View {
            ZStack {
                customShape(color: .purple)
                customShape(color: .pink, insetAmount: 25)
                customShape(color: .orange, insetAmount: 50)
                customShape(color: .yellow, insetAmount: 75)
            }
            .frame(width: 250, height: 220)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(.blue)
        }
    }
    

    I would say, implementing the shape in this way is more complicated. However, the calling code (in other words, ContentView) is now simpler and I like the way the knowledge of the shape is all contained inside the shape itself.

    Both solutions give the same result:

    Triangles

    Login or Signup to reply.
  2. This question is more about trigonometry than about coding. To create a good inset for the triangle, we first calculate the center of its inscribed circle. By converting the inset to a scale, we can then simply scale the shape around the center.

    func square<T: Numeric>(_ value: T) -> T { value * value }
    
    struct CustomShape: InsettableShape {
        var insetAmount: CGFloat = 0
        
        func inset(by amount: CGFloat) -> Self {
            var shape = self
            shape.insetAmount += amount
            return shape
        }
        
        func path(in rect: CGRect) -> Path {
            guard !rect.isEmpty else { return Path() }
    
            // create the triangle
            var path = Path()
            path.move(to: .zero)
            path.addLine(to: CGPoint(x: rect.maxX, y: 0))
            path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
            path.closeSubpath()
            
            // calculate the center of the inscribed circle
            // see: https://en.wikipedia.org/wiki/Incircle_and_excircles
            let length = sqrt(square(rect.width / 2) + square(rect.height))
            let s = (rect.width + length * 2) / 2
            let r = sqrt((s - rect.width) * square(s - length) / s)
            
            let inCenter: CGPoint = .init(x: rect.midX, y: rect.minY + r)
            
            // convert insetAmount to scale
            let scale = (rect.height - insetAmount * 2) / rect.height
            
            // scale around inCenter
            let transform: CGAffineTransform = .identity
                .translatedBy(x: inCenter.x, y: inCenter.y)
                .scaledBy(x: scale, y: scale)
                .translatedBy(x: -inCenter.x, y: -inCenter.y)
            return path.applying(transform)
        }
    }
    
    struct CustomShapeView: View {
        
        let colors: [Color] = [.purple, .red, .orange, .yellow]
        
        var body: some View {
            ZStack {
                ForEach(colors.enumerated().map { $0 }, id: .0) { index, color in
                    CustomShape()
                        .inset(by: 25 * CGFloat(index))
                        .fill(color)
                    CustomShape()
                        .inset(by: 25 * CGFloat(index))
                        .stroke(Color.black, lineWidth: 5)
                }
            }
                .frame(width: 300, height: 200)
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search