iosswiftswiftui

Draw repeating shapes with inset in SwiftUI


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


Solution

  • 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