Create a blob shape in SwiftUI

Is it possible to create any shape using the Path in SwiftUI? I slipped down a bit of a rabbit hole when creating shapes for card suits in SwiftUI. In this article I will explore how to create a random blob shape in SwiftUI.

I still think the answer is yes, any shape can be created in SwiftUI using Path. It may not be the best use of one's time to recreate the Mona Lisa by plotting every coordinate, but it is interesting to explore some basic shapes.



Simple shape

Start with a simple shape such as the heart shape from the card suits explored in Create patterns from basic shapes in SwiftUI. This uses Path to join a number of points using the Path.addCurve method to add cubic Bézier curves to the path to create the shape. The color of the shape can be set using the fill modifier or the outline set using stroke modifier.

 1struct HeartShape: Shape {
 2    func path(in rect: CGRect) -> Path {
 3        let size = min(rect.width, rect.height)
 4        let xOffset = (rect.width > rect.height) ? (rect.width - rect.height) / 2.0 : 0.0
 5        let yOffset = (rect.height > rect.width) ? (rect.height - rect.width) / 2.0 : 0.0
 6
 7        func offsetPoint(p: CGPoint) -> CGPoint {
 8            return CGPoint(x: p.x + xOffset, y: p.y+yOffset)
 9        }
10        var path = Path()
11
12        path.move(to: offsetPoint(p: (CGPoint(x: (size * 0.50), y: (size * 0.25)))))
13        path.addCurve(to: offsetPoint(p: CGPoint(x: 0, y: (size * 0.25))),
14                      control1: offsetPoint(p: CGPoint(x: (size * 0.50), y: (-size * 0.10))),
15                      control2: offsetPoint(p: CGPoint(x: 0, y: 0)))
16        path.addCurve(to: offsetPoint(p: CGPoint(x: (size * 0.50), y: size)),
17                      control1: offsetPoint(p: CGPoint(x: 0, y: (size * 0.60))),
18                      control2: offsetPoint(p: CGPoint(x: (size * 0.50), y: (size * 0.80))))
19        path.addCurve(to: offsetPoint(p: CGPoint(x: size, y: (size * 0.25))),
20                      control1: offsetPoint(p: CGPoint(x: (size * 0.50), y: (size * 0.80))),
21                      control2: offsetPoint(p: CGPoint(x: size, y: (size * 0.60))))
22        path.addCurve(to: offsetPoint(p: CGPoint(x: (size * 0.50), y: (size * 0.25))),
23                      control1: offsetPoint(p: CGPoint(x: size, y: 0)),
24                      control2: offsetPoint(p: CGPoint(x: (size * 0.50), y: (-size * 0.10))))
25        return path
26    }
27}

Heart shape created using coordinates joined with cubic Bézier curves
Heart shape created using coordinates joined with cubic Bézier curves



Random shape

The first attempt at a random shape is to simply generate a set of random points and join them to generate a shape. This does not generate a coherent shape, although it can generate some interesting patterns. The reason a shape is not produced is because this is just creating a random set of points within the defined frame and then joining the points together. A solid 2D shape can only be created if the lines in the path do not cross each other and the points are connected in some kind of sequence.

 1struct RandomStraightShape: Shape {
 2    var NumberOfPoints:Int
 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
13        var path = Path()
14
15        let x1 = Double.random(in: 1.0...Double(size))
16        let y1 = Double.random(in: 1.0...Double(size))
17        path.move(to: offsetPoint(p: (CGPoint(x: x1, y: y1))))
18
19        for _ in 0...NumberOfPoints {
20            let x = Double.random(in: 0.0...Double(size))
21            let y = Double.random(in: 0.0...Double(size))
22            path.addLine(to: offsetPoint(p: (CGPoint(x: x, y: y))))
23        }
24        path.closeSubpath()
25
26        return path
27    }
28}

Shapes created by joining random points
Shapes created by joining random points



Regular Polygon

I looked at regular polygons in Animating a shape change in SwiftUI. The following is an example of creating regular polygons. The creation of the shape starts with polar coordinates, where the first point is at an angle of zero and each subsequent point is got be adding to the angle until a complete circle is reached. the angle to use for each polygon depends on the number of sides and is calculated by dividing 360 degrees by the number of sides.

 1struct RegularPolygonShape: Shape {
 2    var sides:Int
 3
 4    func path(in rect: CGRect) -> Path {
 5        let c = CGPoint(x: rect.width/2.0, y: rect.height/2.0)
 6        let r = Double(min(rect.width,rect.height)) / 2.0
 7        var vertices:[CGPoint] = []
 8        for i in 0...sides {
 9            let angle = (2.0 * Double.pi * Double(i)/Double(sides))
10            let pt = Cartesian(length: r, angle: angle)
11            // Move the point relative to the center of the rect and add to vertices
12            vertices.append(CGPoint(x: pt.x + c.x, y: pt.y + c.y))
13        }
14        var path = Path()
15        for (n, pt) in vertices.enumerated() {
16            print("\(n)   vertices.append(CGPoint(x: \(pt.x), y:\(pt.y)))")
17            n == 0 ? path.move(to: pt) : path.addLine(to: pt)
18        }
19        path.closeSubpath()
20        return path
21    }
22}
1func Cartesian(length:Double, angle:Double) -> CGPoint {
2    return CGPoint(x: length * cos(angle), y: length * sin(angle))
3}

Regular polygon shapes centered in containing view
Regular polygon shapes centered in containing view



Irregular Polygon

Using the code for the creation of regular polygons as a starting point, we can generate irregular polygons if we randomly change the distance the points are from the center. This does create solid shapes as shown below. The points are ordered around a central point with the distance from the center changing, but the angles remain constant. The points are still connected using straight lines, which gives the shapes a jagged fell to them. The code to create the vertices is split out from the code to create the shape from the vertices.

 1fileprivate func CreateRandomPolygon(sides:Int,
 2                                     radius r: Double,
 3                                     center c: CGPoint) -> [CGPoint] {
 4    var vertices:[CGPoint] = []
 5    for i in 0..<sides {
 6        let angle = (2.0 * Double.pi * Double(i)/Double(sides))
 7        let radius = Double.random(in: r/3.0...r)
 8        let pt = Cartesian(length: radius, angle: angle)
 9        // Move the point relative to the center of the rect and add to vertices
10        vertices.append(CGPoint(x: pt.x + c.x, y: pt.y + c.y))
11    }
12    vertices.append(vertices[0])
13    return vertices
14}
 1struct RandomPolygonShape: Shape {
 2    var sides:Int
 3
 4    func path(in rect: CGRect) -> Path {
 5        let c = CGPoint(x: rect.width/2.0, y: rect.height/2.0)
 6        let r = Double(min(rect.width,rect.height)) / 2.0
 7        let vertices = CreateRandomPolygon(
 8            sides: sides,
 9            radius: r,
10            center: c)
11        var path = Path()
12        for (n, pt) in vertices.enumerated() {
13            n == 0 ? path.move(to: pt) : path.addLine(to: pt)
14        }
15        path.closeSubpath()
16        return path
17    }
18}

Irregular polygons shapes centered in containing view
Irregular polygons shapes centered in containing view



Curved lines - Quadratic Curves

Building on the irregular polygons, curved lines are used to connect the points. The addQuadCurve method is used that creates a path from current point to a destination point using a third point as a control point. A segment struct consisting of a point and a control point is defined to hold the data for the segments in the shape.

1struct Segment {
2    let point: CGPoint
3    let control: CGPoint
4}

Polar coordinates are once again used to create the segments for the shape. The distance from the center is varied for each segment using Random method. The control point for each segment is set at a varied angle around the mid-angle between the points. The distance for the control points are also varied for every other segment to pull the curve in to the center or out to the circumference.

These shapes are looking better, but the shapes with odd number of points still have a sharp corner.

 1fileprivate func CreateRandomShape(sides:Int, radius r: Double, center c: CGPoint) -> [Segment] {
 2    var segments:[Segment] = []
 3
 4    let segmentAngle = 2.0 * Double.pi / Double(sides)
 5    for i in 0..<sides {
 6        let angle = segmentAngle * Double(i)
 7        let radius = r * Double.random(in: 0.7...1.0)
 8        let pt = Cartesian(length: radius, angle: angle)
 9
10        let ctlAngle = angle - (segmentAngle * Double.random(in: 0.3...0.8))
11        let distance = i%2==0 ? radius*0.3 : radius*1.2
12        let ctl = Cartesian(length: distance, angle: ctlAngle)
13        let s:Segment = Segment(point: CGPoint(x: pt.x + c.x, y: pt.y + c.y),
14                                control: CGPoint(x: ctl.x + c.x, y: ctl.y + c.y))
15        segments.append(s)
16    }
17    segments.append(segments[0])
18    return segments
19}
 1struct RandomShape: Shape {
 2    var sides:Int
 3
 4    func path(in rect: CGRect) -> Path {
 5        let c = CGPoint(x: rect.width/2.0, y: rect.height/2.0)
 6        let r = Double(min(rect.width,rect.height)) / 2.0
 7        let segments = CreateRandomShape(
 8            sides: sides,
 9            radius: r,
10            center: c)
11
12        var path = Path()
13        for (n, seg) in segments.enumerated() {
14            n == 0 ? path.move(to: seg.point) : path.addQuadCurve(to: seg.point, control: seg.control)
15        }
16        path.closeSubpath()
17        return path
18    }
19}

Irregular shape joining points with quadratic curves
Irregular shape joining points with quadratic curves



Curved lines - Cubic Curves

The use of Quadratic curves to join the points has improved the shapes created, but there are still some sharp corners and some curves seem too circular. Path also has a addCurve method that adds a cubic Bézier curve, which takes the destination point and two control points. A CubicSegment struct is defined to contain the required parameters for the cubic Bézier.

1struct CubicSegment {
2    let point: CGPoint
3    let control1: CGPoint
4    let control2: CGPoint
5}

The shape is once again created using polar coordinates around the central point of the containing View. The distance from the center is alternated from a point near the middle of the radius to a point near the circumference. The control points for each segment are also alternated so that one control point is outside the path and the second one is inside the path. This improves the shapes, but there still seems to be a regular pattern and there are some sharp corners.

 1fileprivate func CreateRandomShape2(sides:Int, radius r: Double, center c: CGPoint) -> [CubicSegment] {
 2    var segments:[CubicSegment] = []
 3
 4    let segmentAngle = 2.0 * Double.pi / Double(sides)
 5    for i in 0..<sides {
 6        let angle = segmentAngle * Double(i)
 7        let radius = i%2==0 ? r * Double.random(in: 0.45...0.55) : r * Double.random(in: 0.75...0.8)
 8        let pt = Cartesian(length: radius, angle: angle)
 9
10        let ctlAngle = angle - (segmentAngle * Double.random(in: 0.75...0.85))
11        let distance = radius * Double.random(in: 0.5...1.2)
12        let ctl = Cartesian(length: distance, angle: ctlAngle)
13
14        let ctlAngle2 = angle - (segmentAngle * Double.random(in: 0.15...0.35))
15        let distance2 = radius * Double.random(in: 0.5...1.2)
16        let ctl2 = Cartesian(length: distance2, angle: ctlAngle2)
17
18        let s:CubicSegment = CubicSegment(point: CGPoint(x: pt.x + c.x, y: pt.y + c.y),
19                                          control1: CGPoint(x: ctl.x + c.x, y: ctl.y + c.y),
20                                          control2: CGPoint(x: ctl2.x + c.x, y: ctl2.y + c.y))
21        segments.append(s)
22    }
23    segments.append(segments[0])
24    return segments
25}
 1struct RandomShape2: Shape {
 2    var sides:Int
 3
 4    func path(in rect: CGRect) -> Path {
 5        let c = CGPoint(x: rect.width/2.0, y: rect.height/2.0)
 6        let r = Double(min(rect.width,rect.height)) / 2.0
 7        let segments = CreateRandomShape2(
 8            sides: sides,
 9            radius: r,
10            center: c)
11        var path = Path()
12        path.move(to: segments[0].point)
13        for i in 1..<segments.count {
14            path.addCurve(to: segments[i].point,
15                          control1: segments[i].control1,
16                          control2: segments[i].control2)
17        }
18        path.closeSubpath()
19        return path
20    }
21}

Irregular shape joining points with cubic curves
Irregular shape joining points with cubic curves



Curved lines - Cubic Curves

The final step is to build on the previous code using polar coordinates. The distance for the main points is no longer alternating, but a random distance between 0.45 and 0.85 times the radius. In order to smooth the transition from one segment to the next, the direction of the previous segment is captured and the first control point in the next segment is set to the opposite direction. This results in a smoother curve between segments.

 1fileprivate func CreateRandomShape3(sides:Int, radius r: Double, center c: CGPoint) -> [CubicSegment] {
 2    var segments:[CubicSegment] = []
 3
 4    let sectorAngle = 2.0 * Double.pi / Double(sides)
 5    var previousSectorIn = true
 6    for i in 0..<sides {
 7        let segmentAngle = sectorAngle * Double.random(in: 0.7...1.3)
 8        let angle = (sectorAngle * Double(i-0)) + segmentAngle
 9        let radius = r * Double.random(in: 0.45...0.85)
10        let pt = Cartesian(length: radius, angle: angle)
11
12        let ctlAngle1 = angle - (segmentAngle * 0.75)
13        let ctlDistance1 = previousSectorIn ? radius*1.45 : radius*0.55
14        let ctl1 = Cartesian(length: ctlDistance1, angle: ctlAngle1)
15
16        let ctlAngle2 = angle - (segmentAngle * 0.25)
17        let ctlDistance2 = radius * Double.random(in: 0.55...1.45)
18        previousSectorIn = ctlDistance2 < radius
19        let ctl2 = Cartesian(length: ctlDistance2, angle: ctlAngle2)
20
21        let s:CubicSegment = CubicSegment(point: CGPoint(x: pt.x + c.x, y: pt.y + c.y),
22                                          control1: CGPoint(x: ctl1.x + c.x, y: ctl1.y + c.y),
23                                          control2: CGPoint(x: ctl2.x + c.x, y: ctl2.y + c.y))
24        segments.append(s)
25    }
26    segments.append(segments[0])
27    return segments
28}
 1struct RandomShape3: Shape {
 2    var sides:Int
 3
 4    func path(in rect: CGRect) -> Path {
 5        let c = CGPoint(x: rect.width/2.0, y: rect.height/2.0)
 6        let r = Double(min(rect.width,rect.height)) / 2.0
 7        let segments = CreateRandomShape3(
 8            sides: sides,
 9            radius: r,
10            center: c)
11        var path = Path()
12        path.move(to: segments[0].point)
13        for i in 1..<segments.count {
14            path.addCurve(to: segments[i].point,
15                          control1: segments[i].control1,
16                          control2: segments[i].control2)
17        }
18        path.closeSubpath()
19        return path
20    }
21}

Irregular shape with cubic curves where first segment control point is opposite to last one
Irregular shape with cubic curves where first segment control point is opposite to last one



Overview of shapes created in SwiftUI in this article
Overview of shapes created in SwiftUI in this article




Conclusion

I still think it is possible to create any shape in SwiftUI, but some shapes can take longer than others to create. This article showed a couple of ways of defining paths to create blob-like shapes. The final solution relies on using the the polar coordinate system to define a series of points with increasing angles and then joining these points with cubic Bézier curves. The direction of the curves between segments is kept in the same direction by reversing the location of the control points.

The generation of random points and then connecting these points does not result in a solid shape. A variation on this approach could be to divide the view into a grid and set a random point in a series of sections in the grid. These points could be joined to create a continuous shape. However, there would still be challenges with the curves and smooth connections between points.

As a side-note, the shapes with 5 to 15 points look good as blobs, but the shape created with 50 to 100 points look great as an ink splatter or perhaps as some representation of an explosion. This code uses random numbers to generate different shapes every time the views are shown. Once a desired shape is created, the segments could be saved and defined as static resources to use in an App. These shapes can then be used anywhere in an app and scaled and colored as required.