Create a Star cutout shape in SwiftUI
Path can be used in SwiftUI to create practically any shape. I found creating a cutout shape difficult until I found that FillStyle can be used with the even-odd rule. This article shows how to create a shape outline where the outside can be colored and the center kept transparent to allow other content to show through. Shape cutouts can be used to frame content and help highlight the content for the user.
Using the SF Symbols
I first thought I could use the SF Symbol shapes by setting foreground and background
colors. When using one of the sf-symbols like star.fill
setting the opacity of
the foreground just removes the foreground shape and reveals the background. The
star
symbol does show the content inside the Star, but also outside the star and
setting a color on the background once again blocks out the whole background.
Finally, there is an sf-symbol for star.square.fill
, which has the star as the
foreground as a square around a Star cutout. This is close to the desired effect with
the content underneath shown through the star shape. There is a lot of padding around
the shapes and configuration is limited.
1struct CakeView: View {
2 var body: some View {
3 Image("cake")
4 .resizable()
5 .scaledToFit()
6 .frame(width: 100, height: 100, alignment: .center)
7 }
8}
1struct SFSymbolView: View {
2 var body: some View {
3 VStack(spacing:20) {
4 CakeView()
5
6 HStack(spacing:30) {
7 ZStack {
8 CakeView()
9 Image(systemName: "star.fill")
10 .font(.system(size: 130))
11 .foregroundColor(Color.clear)
12 .background(Color.green)
13 }
14 .frame(width: 150, height: 150, alignment: .center)
15
16 ZStack {
17 CakeView()
18 Image(systemName: "star.fill")
19 .font(.system(size: 130))
20 .foregroundColor(Color.red.opacity(0.5))
21 .background(Color.green)
22 }
23 .frame(width: 150, height: 150, alignment: .center)
24 }
25
26 HStack(spacing:30) {
27 ZStack {
28 CakeView()
29 Image(systemName: "star")
30 .font(.system(size: 130))
31 .foregroundColor(Color.red)
32 }
33 .frame(width: 150, height: 150, alignment: .center)
34
35 ZStack {
36 CakeView()
37 Image(systemName: "star")
38 .font(.system(size: 130))
39 .foregroundColor(Color.red)
40 .background(Color.green)
41 }
42 .frame(width: 150, height: 150, alignment: .center)
43 }
44
45 HStack(spacing:30) {
46 ZStack {
47 CakeView()
48 Image(systemName: "star.square.fill")
49 .font(.system(size: 130))
50 .foregroundColor(Color.red)
51 .background(Color.green.opacity(0.3))
52 }
53 .frame(width: 150, height: 150, alignment: .center)
54
55 ZStack {
56 CakeView()
57 Image(systemName: "star.square.fill")
58 .font(.system(size: 150))
59 .foregroundColor(Color.red)
60 }
61 .frame(width: 150, height: 150, alignment: .center)
62 }
63
64 Spacer()
65 }
66 }
67}
Using SF Symbol as cutout shape in a ZStack
Using clipShape to frame an image
Use of shape rather than SF-symbol does provide more flexibility. The first two images use the stroke method to outline a shape over the background image. Increasing the linewidth of the shape can create a shape cutout, when it is combined with a clipped method so the view is clipped within its bounding frame. This is somewhat crude with less control over the size of the cutout as the stroke line width extends in both directions.
1 HStack(spacing:30) {
2 ZStack {
3 Image("cake")
4 .resizable()
5 .scaledToFit()
6 .frame(width: 120, height: 120, alignment: .center)
7 Circle()
8 .stroke(Color.green, lineWidth: 3.0)
9 .frame(width: 120, height: 120, alignment: .center)
10 }
11 .frame(width: 150, height: 150, alignment: .center)
12
13 ZStack {
14 Image("cake")
15 .resizable()
16 .scaledToFit()
17 .frame(width: 120, height: 120, alignment: .center)
18 Circle()
19 .stroke(Color.green, lineWidth: 60.0)
20 .frame(width: 150, height: 150, alignment: .center)
21 .clipped()
22 }
23 .frame(width: 150, height: 150, alignment: .center)
24 }
Instead of using a separate cutout shape over the image in a ZStack, we could use the clipShape method as was discussed in Displaying Images in SwiftUI to frame an image. This clipShape approach provides the ability to clip the view to any shape including a custom shape. This may be enough for most circumstances. Note that this is one view, so it will not work if the desire is to have a separate draggable cutout view.
1 HStack(spacing:30) {
2 Image("cake")
3 .resizable()
4 .scaledToFit()
5 .frame(width: 120, height: 120, alignment: .center)
6 .clipShape(Circle())
7 .frame(width: 150, height: 150, alignment: .center)
8 .background(Color.green)
9
10 Image("cake")
11 .resizable()
12 .scaledToFit()
13 .frame(width: 120, height: 120, alignment: .center)
14 .frame(width: 140, height: 80, alignment: .center)
15 .background(Color.white)
16 .clipShape(Ellipse())
17 .frame(width: 150, height: 130, alignment: .center)
18 .background(Color.green)
19 }
Another approach is to reverse the order of the views and clip the image over a view of the desired background. Again this is not a cutout shape, but may be sufficient if the entire view is static.
1 HStack(spacing:30) {
2 ZStack {
3 Color.green
4 .frame(width: 150, height: 150, alignment: .center)
5 Image("cake")
6 .resizable()
7 .scaledToFit()
8 .frame(width: 120, height: 120, alignment: .center)
9 .clipShape(Circle())
10 }
11 .frame(width: 150, height: 150, alignment: .center)
12
13 ZStack {
14 Color.green
15 .frame(width: 130, height: 150, alignment: .center)
16 Image("cake")
17 .resizable()
18 .scaledToFit()
19 .frame(width: 120, height: 120, alignment: .center)
20 .frame(width: 80, height: 120, alignment: .center)
21 .clipShape(Rectangle())
22 }
23 .frame(width: 150, height: 150, alignment: .center)
24 }
Using SwiftUI shapes to frame a view such as an image
Using shapes in a Path with FillStyle
The solution to creating a stand-alone cutout shape is to use two paths with a
FillStyle structure setting the Even–odd rule property to true. The first two
images define a path to contain the paths of two shapes; a circle over a square and a
square over a circle. Using the FillStyle with eoFill
property set to true, the
first shape is filled and the second shape is left transparent.
1 HStack(spacing:30) {
2 ZStack {
3 Image("cake")
4 .resizable()
5 .scaledToFit()
6 .frame(width: 120, height: 120, alignment: .center)
7 Path { path in
8 path.addPath(Rectangle().path(in: CGRect(x: 0, y: 0, width: 150, height: 150)))
9 path.addPath(Circle().path(in: CGRect(x: 25, y: 25, width: 100, height: 100)))
10 }
11 .fill(Color.purple,
12 style: FillStyle(eoFill: true, antialiased: true))
13 }
14 .frame(width: 150, height: 150, alignment: .center)
15
16 ZStack {
17 Image("cake")
18 .resizable()
19 .scaledToFit()
20 .frame(width: 120, height: 120, alignment: .center)
21 Path { path in
22 path.addPath(Circle().path(in: CGRect(x: 0, y: 0, width: 150, height: 150)))
23 path.addPath(Rectangle().path(in: CGRect(x: 25, y: 25, width: 100, height: 100)))
24 }
25 .fill(Color.purple,
26 style: FillStyle(eoFill: true, antialiased: true))
27 }
28 .frame(width: 150, height: 150, alignment: .center)
29 }
The second two images use Path to define custom shapes for the cutout shape. The next step would be to embed these path definitions into shapes of their own to simplify the main view.
1 HStack(spacing:30) {
2 ZStack {
3 Image("cake")
4 .resizable()
5 .scaledToFit()
6 .frame(width: 120, height: 120, alignment: .center)
7 Path { path in
8 path.addPath(Rectangle().path(in: CGRect(x: 0, y: 0, width: 150, height: 150)))
9 path.move(to: CGPoint(x: 0, y: 75))
10 path.addLine(to: CGPoint(x: 75, y: 0))
11 path.addLine(to: CGPoint(x: 150, y: 75))
12 path.addLine(to: CGPoint(x: 75, y: 150))
13 path.addLine(to: CGPoint(x: 0, y: 75))
14 }
15 .fill(Color.purple,
16 style: FillStyle(eoFill: true, antialiased: true))
17 }
18 .frame(width: 150, height: 150, alignment: .center)
19
20 ZStack {
21 Image("cake")
22 .resizable()
23 .scaledToFit()
24 .frame(width: 120, height: 120, alignment: .center)
25 Path { path in
26 path.addPath(Rectangle().path(in: CGRect(x: 0, y: 0, width: 150, height: 150)))
27 path.move(to: CGPoint(x: 0, y: 75))
28 path.addQuadCurve(to: CGPoint(x: 75, y: 0),
29 control: CGPoint(x: 75, y: 75))
30 path.addQuadCurve(to: CGPoint(x: 150, y: 75),
31 control: CGPoint(x: 75, y: 75))
32 path.addQuadCurve(to: CGPoint(x: 75, y: 150),
33 control: CGPoint(x: 75, y: 75))
34 path.addQuadCurve(to: CGPoint(x: 0, y: 75),
35 control: CGPoint(x: 75, y: 75)) }
36 .fill(Color.purple,
37 style: FillStyle(eoFill: true, antialiased: true))
38 }
39 .frame(width: 150, height: 150, alignment: .center)
40 }
Combining shapes in a path to frame a view such as an image
Defining Star Shapes
A star shape is defined similar to the regular polygon defined in Animating a shape change in SwiftUI. In addition, to the outer points of the star, we need to define corresponding inner points. This is achieved by setting an inner circle (with a radius 0.4 times that of the outer circle) for the inner points of the star. By making the number of points in the star a variable the same shape can be used to create any star. Note there is a vertical adjustment down for the center of the circles when the number of points on the star is odd and less than 8. This is because although the star is centered inside a circle, which is centered in the frame - the star appears off-center when viewed in a solid Square.
1struct StarShape: Shape {
2 var points = 5
3
4 func Cartesian(length:Double, angle:Double) -> CGPoint {
5 return CGPoint(x: length * cos(angle),
6 y: length * sin(angle))
7 }
8
9 func path(in rect: CGRect) -> Path {
10 // centre of the containing rect
11 var center = CGPoint(x: rect.width/2.0, y: rect.height/2.0)
12 // Adjust center down for odd number of sides less than 8
13 if points%2 == 1 && points < 8 {
14 center = CGPoint(x: center.x, y: center.y * ((Double(points) * (-0.04)) + 1.3))
15 }
16
17 // radius of a circle that will fit in the rect
18 let outerRadius = Double(min(rect.width,rect.height)) / 2.0
19 let innerRadius = outerRadius * 0.4
20 let offsetAngle = (Double.pi / Double(points)) + Double.pi/2.0
21
22 var vertices:[CGPoint] = []
23 for i in 0..<points{
24 // Calculate the angle in Radians
25 let angle1 = (2.0 * Double.pi/Double(points)) * Double(i) + offsetAngle
26 let outerPoint = Cartesian(length: outerRadius, angle: angle1)
27 vertices.append(CGPoint(x: outerPoint.x + center.x, y: outerPoint.y + center.y))
28
29 let angle2 = (2.0 * Double.pi/Double(points)) * (Double(i) + 0.5) + offsetAngle
30 let innerPoint = Cartesian(length: (innerRadius),
31 angle: (angle2))
32 vertices.append(CGPoint(x: innerPoint.x + center.x, y: innerPoint.y + center.y))
33 }
34
35 let path = Path() { path in
36 for (n, pt) in vertices.enumerated() {
37 n == 0 ? path.move(to: pt) : path.addLine(to: pt)
38 }
39 path.closeSubpath()
40 }
41 return path
42 }
43}
A number of stars are displayed with different number of points to exercise the use
of the StarShape
1 var body: some View {
2 VStack(spacing:20) {
3 ForEach([3,4,5], id: \.self) { i in
4 HStack(spacing:30) {
5 VStack(spacing:0) {
6 let points = i
7 StarShape(points: points)
8 .fill(Color.orange)
9 .frame(width: 150, height: 150)
10 Text("\(points) points")
11 }
12
13 VStack(spacing:0) {
14 let points = i * 3
15 StarShape(points: points)
16 .fill(Color.orange)
17 .frame(width: 150, height: 150)
18 Text("\(points) points")
19 }
20 }
21 }
22 Spacer()
23 }
24 }
Star shapes with different number of points - solid color
The same stars are displayed using stroke to show the stars outline.
1 var body: some View {
2 VStack(spacing:20) {
3 ForEach([3,4,5], id: \.self) { i in
4 HStack(spacing:30) {
5 VStack(spacing:0) {
6 let points = i
7 StarShape(points: points)
8 .stroke(Color.orange, lineWidth: 3.0)
9 .frame(width: 150, height: 150)
10 Text("\(points) points")
11 }
12
13 VStack(spacing:0) {
14 let points = i * 3
15 StarShape(points: points)
16 .stroke(Color.orange, lineWidth: 3.0)
17 .frame(width: 150, height: 150)
18 Text("\(points) points")
19 }
20 }
21 }
22 Spacer()
23 }
24 }
Star shapes with different number of points - Outline using stroke
Star cutout shape
One simple change can be made to the StarShape
to allow it to be displayed as a
cutout shape rather than a solid shape. Add an extra boolean parameter cutout
with
a default of false, so the existing shapes stay the same. Add a conditional step to
add a Rectangle path for the containing frame to the start of the path definition
when the cutout parameter is set to true.
1struct StarShape: Shape {
2 var points = 5
3 var cutout = false
4
5 func Cartesian(length:Double, angle:Double) -> CGPoint {
6 return CGPoint(x: length * cos(angle),
7 y: length * sin(angle))
8 }
9
10 func path(in rect: CGRect) -> Path {
11 // centre of the containing rect
12 var center = CGPoint(x: rect.width/2.0, y: rect.height/2.0)
13 // Adjust center down for odd number of sides less than 8
14 if points%2 == 1 && points < 8 {
15 center = CGPoint(x: center.x, y: center.y * ((Double(points) * (-0.04)) + 1.3))
16 }
17
18 // radius of a circle that will fit in the rect
19 let outerRadius = Double(min(rect.width,rect.height)) / 2.0
20 let innerRadius = outerRadius * 0.4
21 let offsetAngle = (Double.pi / Double(points)) + Double.pi/2.0
22
23 var vertices:[CGPoint] = []
24 for i in 0..<points{
25 // Calculate the angle in Radians
26 let angle1 = (2.0 * Double.pi/Double(points)) * Double(i) + offsetAngle
27 let outerPoint = Cartesian(length: outerRadius, angle: angle1)
28 vertices.append(CGPoint(x: outerPoint.x + center.x, y: outerPoint.y + center.y))
29
30 let angle2 = (2.0 * Double.pi/Double(points)) * (Double(i) + 0.5) + offsetAngle
31 let innerPoint = Cartesian(length: (innerRadius),
32 angle: (angle2))
33 vertices.append(CGPoint(x: innerPoint.x + center.x, y: innerPoint.y + center.y))
34 }
35
36 let path = Path() { path in
37 if cutout {
38 path.addPath(Rectangle().path(in: rect))
39 }
40 for (n, pt) in vertices.enumerated() {
41 n == 0 ? path.move(to: pt) : path.addLine(to: pt)
42 }
43 path.closeSubpath()
44 }
45 return path
46 }
47}
Setting the cutout
to true renders the shape with the outside filled in and the
inside star shape transparent. When the cutout
parameter is set to false then the
star is rendered as before.
1 var body: some View {
2 VStack(spacing:20) {
3 ForEach([5,6,7], id: \.self) { i in
4 HStack(spacing:30) {
5 VStack(spacing:0) {
6 let points = i
7 StarShape(points: points, cutout: true)
8 .fill(Color(red: 0.68, green: 0.29, blue: 0.29),
9 style: FillStyle(eoFill: true))
10 .frame(width: 150, height: 150)
11 Text("\(points) points")
12 }
13
14 VStack(spacing:0) {
15 let points = i
16 StarShape(points: points, cutout: false)
17 .fill(Color(red: 0.68, green: 0.29, blue: 0.29),
18 style: FillStyle(eoFill: true))
19 .frame(width: 150, height: 150)
20 Text("\(points) points")
21 }
22 }
23 }
24 Spacer()
25 }
26 }
Star cutout shapes for 5, 6 and 7-sided stars
Using Cutout Shapes
This cutout behavior can used with any custom shape such as the heart defined in Create a blob shape in SwiftUI. The cutout parameter needs to be added and the rectangle path added to the start of the path when the cutout parameter is true. That's it! - the heart shape can now be used as a cutout shape.
1struct HeartShape: Shape {
2 var cutout = false
3
4 func path(in rect: CGRect) -> Path {
5 let size = min(rect.width, rect.height)
6 let xOffset = (rect.width > rect.height) ? (rect.width - rect.height) / 2.0 : 0.0
7 let yOffset = (rect.height > rect.width) ? (rect.height - rect.width) / 2.0 : 0.0
8
9 func offsetPoint(p: CGPoint) -> CGPoint {
10 return CGPoint(x: p.x + xOffset, y: p.y+yOffset)
11 }
12 var path = Path()
13
14 if cutout {
15 path.addPath(Rectangle().path(in: rect))
16 }
17
18 path.move(to: offsetPoint(p: (CGPoint(x: (size * 0.50), y: (size * 0.25)))))
19 path.addCurve(to: offsetPoint(p: CGPoint(x: 0, y: (size * 0.25))),
20 control1: offsetPoint(p: CGPoint(x: (size * 0.50), y: (-size * 0.10))),
21 control2: offsetPoint(p: CGPoint(x: 0, y: 0)))
22 path.addCurve(to: offsetPoint(p: CGPoint(x: (size * 0.50), y: size)),
23 control1: offsetPoint(p: CGPoint(x: 0, y: (size * 0.60))),
24 control2: offsetPoint(p: CGPoint(x: (size * 0.50), y: (size * 0.80))))
25 path.addCurve(to: offsetPoint(p: CGPoint(x: size, y: (size * 0.25))),
26 control1: offsetPoint(p: CGPoint(x: (size * 0.50), y: (size * 0.80))),
27 control2: offsetPoint(p: CGPoint(x: size, y: (size * 0.60))))
28 path.addCurve(to: offsetPoint(p: CGPoint(x: (size * 0.50), y: (size * 0.25))),
29 control1: offsetPoint(p: CGPoint(x: size, y: 0)),
30 control2: offsetPoint(p: CGPoint(x: (size * 0.50), y: (-size * 0.10))))
31 return path
32 }
33}
Here the star and heart cutout shapes are used to frame the cake image.
1 var body: some View {
2 VStack(spacing:40) {
3 Text("Cutout Shapes")
4 .font(.title2)
5 .fontWeight(.bold)
6
7 ZStack {
8 Image("cake")
9 .resizable()
10 .scaledToFit()
11 .frame(width: 200, height: 200)
12 .frame(width: 180, height: 180)
13 .clipped()
14
15 StarShape(points: 5, cutout: true)
16 .fill(Color(red: 0.68, green: 0.29, blue: 0.29),
17 style: FillStyle(eoFill: true))
18 .frame(width: 200, height: 180)
19 }
20
21 ZStack {
22 Image("cake")
23 .resizable()
24 .scaledToFit()
25 .frame(width: 200, height: 200)
26 .frame(width: 180, height: 180)
27 .clipped()
28
29 HeartShape(cutout: true)
30 .fill(Color(red: 0.68, green: 0.29, blue: 0.29),
31 style: FillStyle(eoFill: true))
32 .frame(width: 200, height: 180)
33 }
34
35 Spacer()
36 }
37 }
Star and Heart cutout shapes used to frame an image
Conclusion
It is easier than I first perceived to create a cutout shape in SwiftUI. The trick is
to use multiple paths in the shape definition and to set the eoFill
parameter to
true of the FillStyle on the shape.