Building Custom SwiftUI Shapes with Path and Shape

SwiftUI provides a robust set of built-in shapes such as Rectangle, Circle, and RoundedRectangle. However, the real power of SwiftUI lies in its ability to create custom shapes tailored to your specific design needs. By using the Path and Shape protocols, developers can create anything from simple polygons to complex, intricate designs.

What are Paths and Shapes in SwiftUI?

In SwiftUI, the Path and Shape protocols are fundamental for drawing vector-based graphics:

  • Path: Represents the outline of a shape as a series of drawing commands (e.g., move to, line to, curve to). It’s the raw data structure that defines the visual appearance of a shape.
  • Shape: A protocol that Views can conform to. A Shape is defined by its path(in:) method, which specifies how to draw the shape within a given rectangle. By conforming to the Shape protocol, a custom type can be used anywhere a View is expected, inheriting capabilities like applying modifiers (e.g., fill, stroke).

Why Create Custom Shapes?

  • Design Flexibility: Tailor UI elements to perfectly match design specifications.
  • Unique UI Elements: Create distinctive and memorable user interfaces.
  • Enhanced User Experience: Implement complex animations and transitions using custom shapes.

How to Build Custom SwiftUI Shapes

Here’s how to create custom shapes in SwiftUI using Path and Shape:

Step 1: Create a Shape Struct

First, create a struct that conforms to the Shape protocol. This struct will define the shape’s characteristics and how it should be drawn.

import SwiftUI

struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()

        path.move(to: CGPoint(x: rect.midX, y: rect.minY))
        path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))

        return path
    }
}

Explanation:

  • We create a Triangle struct that conforms to the Shape protocol.
  • The path(in:) method is required by the Shape protocol. It defines the drawing instructions within the provided rectangle rect.
  • A Path instance is created. The path is built using a series of drawing commands:
    • move(to:) sets the starting point of the path.
    • addLine(to:) draws a line from the current point to the specified point.
  • Finally, the method returns the constructed Path.

Step 2: Use the Custom Shape in Your View

Now, you can use the custom shape in your SwiftUI views like any other built-in shape.

struct ContentView: View {
    var body: some View {
        Triangle()
            .fill(.blue)
            .frame(width: 200, height: 150)
            .padding()
    }
}

Explanation:

  • A ContentView is created.
  • The Triangle shape is instantiated.
  • The fill(.blue) modifier sets the fill color of the triangle to blue.
  • The frame(width:height:) modifier sets the size of the frame containing the triangle.
  • The padding() modifier adds padding around the shape.

Example 2: Creating a Rounded Polygon

Let’s create a more complex shape, a rounded polygon with a specified number of sides and corner radius.

struct RoundedPolygon: Shape {
    let sides: Int
    let cornerRadius: CGFloat

    func path(in rect: CGRect) -> Path {
        guard sides >= 3 else { return Path() }

        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2
        var path = Path()

        for i in 0..<sides {
            let angle = CGFloat(i) * (360 / CGFloat(sides)) * .pi / 180
            let x = center.x + radius * cos(angle)
            let y = center.y + radius * sin(angle)

            let point = CGPoint(x: x, y: y)

            if i == 0 {
                path.move(to: point)
            } else {
                path.addLine(to: point)
            }
        }

        path.closeSubpath()

        // Apply corner radius
        return path.applying(CGAffineTransform(translationX: -rect.origin.x, y: -rect.origin.y))
            .roundedCorners(radius: cornerRadius)
            .applying(CGAffineTransform(translationX: rect.origin.x, y: rect.origin.y))
    }
}

extension Path {
    func roundedCorners(radius: CGFloat) -> Path {
        var path = self
        guard let currentPoint = path.currentPoint else { return path }

        let boundingBox = path.boundingRect
        let insetRect = boundingBox.insetBy(dx: radius, dy: radius)
        let minX = insetRect.minX
        let minY = insetRect.minY
        let maxX = insetRect.maxX
        let maxY = insetRect.maxY

        let roundedRect = CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY)
        
        // Remove all path segments.
        var tempPath = Path()

        // swiftlint:disable:next identifier_name
        func roundCorner(_ point: CGPoint, _ corner: CGPoint) -> CGPoint {
           return CGPoint(
                x: point.x + (corner.x - point.x).normalized * radius,
                y: point.y + (corner.y - point.y).normalized * radius)
        }

        func moveAndDraw(from point: CGPoint, to cornerPoint: CGPoint) {
           // Move to start of curved line segment in relative point form.
           let relativeToOriginalPoint: CGPoint = CGPoint(x: (point.x - currentPoint.x) / radius,
                                              y: (point.y - currentPoint.y) / radius)
           let curve: CGPoint = roundCorner(point, cornerPoint)
            tempPath.move(to: curve)
        }
            return tempPath
    }
}

extension CGFloat {
    /// A normalized float.
    var normalized: CGFloat {
        guard self != 0 else { return 0 }
        return self / abs(self)
    }
}

Explanation:

  • RoundedPolygon struct is created with customizable sides and cornerRadius.
  • The path(in:) method calculates the points of the polygon using trigonometric functions.
  • The applying(_:) modifier applies a transform to shift path.

Usage:

struct ContentView: View {
    var body: some View {
        RoundedPolygon(sides: 6, cornerRadius: 15)
            .fill(.orange)
            .frame(width: 200, height: 200)
            .padding()
    }
}

Example 3: Creating a Star Shape with Animated Properties

Let’s create a star shape where you can customize the number of points, and inner radius ratio. Make it animatable to change the pointCount using AnimatableData.


import SwiftUI

struct Star: Shape, Animatable {
    var pointCount: Int
    var innerRadiusRatio: CGFloat

    var animatableData: AnimatablePair {
        get {
            AnimatablePair(Double(pointCount), Double(innerRadiusRatio))
        }
        set {
            pointCount = Int(newValue.first)
            innerRadiusRatio = CGFloat(newValue.second)
        }
    }

    func path(in rect: CGRect) -> Path {
        guard pointCount >= 2 else { return Path() }

        let center = CGPoint(x: rect.midX, y: rect.midY)
        let outerRadius = min(rect.width, rect.height) / 2
        let innerRadius = outerRadius * innerRadiusRatio

        var path = Path()

        for i in 0..<pointCount * 2 {
            let radius = i.isMultiple(of: 2) ? outerRadius : innerRadius
            let angle = CGFloat(i) * (360 / CGFloat(pointCount * 2)) * .pi / 180

            let x = center.x + radius * cos(angle)
            let y = center.y + radius * sin(angle)

            let point = CGPoint(x: x, y: y)

            if i == 0 {
                path.move(to: point)
            } else {
                path.addLine(to: point)
            }
        }

        path.closeSubpath()
        return path
    }
}

Usage Example


struct ContentView: View {
    @State private var starPoints: Int = 5
    @State private var innerRatio: CGFloat = 0.4

    var body: some View {
        VStack {
            Star(pointCount: starPoints, innerRadiusRatio: innerRatio)
                .fill(.red)
                .frame(width: 200, height: 200)
                .padding()

            Slider(value: $innerRatio, in: 0.1...0.9, step: 0.1) {
                Text("Inner Ratio: \(innerRatio, specifier: "%.1f")")
            }

            Stepper("Star Points: \(starPoints)", value: $starPoints, in: 3...12)
                .padding()
        }
        .padding()
    }
}

Conclusion

Creating custom shapes with Path and Shape in SwiftUI allows for unparalleled design flexibility. By mastering these fundamental concepts, developers can bring complex and unique UI elements to life. Whether it’s simple geometric figures or intricate, animated designs, SwiftUI’s shape-drawing capabilities empower you to create outstanding user experiences.