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.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:

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
x = rect.width / 2
y += innerHeight(w: rect.width, h: rect.height)
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: