This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We create a pattern image shape style from a SwiftUI view.

00:06 We're working on a view in which we explain how HStacks work, and the design uses diagonal stripes to highlight certain areas, like the stack's padding and the spacing between the children:

00:29 There are multiple ways to draw a pattern like this. We could construct a Shape in which we draw each line in a loop. The Shape could expose a parameter that lets us set the distance between the lines.

00:55 But since it looks more like a fill pattern, it makes sense to instead create a shape style. Shape styles can be used to fill any shape we want — be it rectangles, circles, or any other path. We can create our own ShapeStyle using one of SwiftUI's built-in styles — there are elementary styles, like colors and gradients, and styles that are more semantic, such as the selection style or the separator style. There's also an image paint shape style, which is what we'd like to use.

Creating a Pattern

01:36 Let's see what happens when we draw a single diagonal line in a box and repeat that pattern. We first make an HLine view that draws a horizontal line, vertically centered in a square frame. We wrap the whole thing in a frame that can grow to any proposed size:

struct HLine: View {
    var body: some View {
        Rectangle()
            .frame(height: 1)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

// ...

#Preview {
    HLine()
        .frame(width: 50, height: 50)
        .border(Color.red)
        .padding(50)
}

02:53 By rotating this horizontal line 45 degrees, we can avoid writing a custom shape that draws a diagonal line. So, we write another view in which we apply the rotation to create our pattern:

struct DiagonalPattern: View {
    var body: some View {
        HLine()
            .rotationEffect(.degrees(-45))
            .frame(width: 20, height: 20)
    }
}

// ...

#Preview {
    DiagonalPattern()
        .frame(width: 50, height: 50)
        .border(Color.red)
        .padding(50)
}

04:36 Since the diagonal of our frame is longer than the width, we need to draw a line that's longer than the proposed width; otherwise, it won't reach the corners of the pattern:

05:15 We add another, larger frame before the rotation effect to make the line long enough:

struct DiagonalPattern: View {
    var body: some View {
        HLine()
            .frame(width: 30, height: 30)
            .rotationEffect(.degrees(-45))
            .frame(width: 20, height: 20)
            .border(.green)
    }
}

// ...

#Preview {
    DiagonalPattern()
        .frame(width: 50, height: 50)
        .border(Color.red)
        .padding(50)
}

05:41 Next, we want turn this view into a pattern by rendering it to an image and using that in a custom shape style. The ShapeStyle protocol requires us to implement a resolve(in:) method. We construct the shape style this method needs to return by calling the static image function, which expects an Image value. We'll then render our striped image pattern in a helper method we'll write on Image:

struct DiagonalStripes: ShapeStyle {
    func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
        .image(Image.striped())
    }
}

07:26 In the striped method, we pass our pattern view to an ImageRenderer, and we get either a CGImage or an NSImage back. Both properties are marked async, which might seem a little weird, but that's because the image renderer wants to be isolated to the main actor. After we mark our function with @MainActor, the properties can be read synchronously, and we return the renderer's NSImage wrapped in an Image:

extension Image {
    @MainActor
    static func striped() -> Image {
        let renderer =  ImageRenderer(content: DiagonalPattern())
        return Image(nsImage: renderer.nsImage!)
    }
}

struct DiagonalStripes: ShapeStyle {
    @MainActor
    func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
        .image(Image.striped())
    }
}

08:34 Now we should be able to fill a rectangle with our new shape style:

#Preview {
    Rectangle()
        .fill(DiagonalStripes())
        .frame(width: 100, height: 100)
        .border(Color.red)
        .padding(50)
}

09:01 The pattern view doesn't receive the current environment by default, but we can manually set its foreground color to white to make it more visible against the dark background:

extension Image {
    @MainActor
    static func striped() -> Image {
        let renderer = ImageRenderer(content: DiagonalPattern().foregroundColor(.white))
        return Image(nsImage: renderer.nsImage!)
    }
}

Completing the Pattern

09:27 We now see a repeated pattern of diagonal lines, but there are small gaps in the lines. If we make the lines thicker, we can see this more clearly:

09:48 When we render our rotated line into an image, it gets cropped to the 20-by-20 points frame. This results in arrowheads at the lower left and upper right ends of the line. And because the image is repeated in a tiled pattern, we end up with these triangular gaps. To fill these gaps, we should not only draw the line across the center of our tile, but also one line above and one line below it.

11:00 We move the HLine into a ZStack together with two other copies. Because we want to apply the same frame and rotation to all lines, we create the line view in a local variable. Then, we apply offsets to move the first line to the top-left corner and the third line to the bottom right:

struct DiagonalPattern: View {
    var body: some View {
        let line = HLine()
            .frame(width: 30, height: 30)
            .rotationEffect(.degrees(-45))

        ZStack {
            line
                .offset(x: -10, y: -10)
            line
            line
                .offset(x: 10, y: 10)
        }
        .frame(width: 20, height: 20)
    }
}

13:33 That looks much better. We also verify that this continues to look correct when we change the width of the line.

Using Geometry Reader

13:45 By using a geometry reader inside DiagonalPattern, we can get rid of the magic numbers and allow the view's dimension to be controlled from the outside. The offsets of the lines can then be computed from the available space. We assume the view will have a square shape, so we apply equal offsets in the horizontal and vertical directions:

struct DiagonalPattern: View {
    var body: some View {
        let line = HLine()
            .frame(width: 30, height: 30)
            .rotationEffect(.degrees(-45))

        GeometryReader { proxy in
            let o = proxy.size.width/2
            ZStack {
                line
                    .offset(x: -o, y: -o)
                line
                line
                    .offset(x: o, y: o)
            }
        }
    }
}

15:03 Now we can choose the pattern's size when rendering the image by setting a frame on the DiagonalPattern view:

extension Image {
    @MainActor
    static func striped() -> Image {
        let content = DiagonalPattern()
            .foregroundColor(.white)
            .frame(width: 20, height: 20)
        let renderer =  ImageRenderer(content: content)
        return Image(nsImage: renderer.nsImage!)
    }
}

15:32 This frame determines the distance between the diagonal lines; if we change the frame's size to 30 by 30, then the lines are more spread out:

15:47 This creates new gaps in the lines because we're still using a fixed size for the HLine view inside the pattern. We can fix this by moving the line inside the geometry reader and basing its size on the available space:

struct DiagonalPattern: View {
    var body: some View {
        GeometryReader { proxy in
            let line = HLine()
                .frame(width: proxy.size.width*2, height: proxy.size.width*2)
                .rotationEffect(.degrees(-45))
            let o = proxy.size.width/2
            ZStack {
                line
                    .offset(x: -o, y: -o)
                line
                line
                    .offset(x: o, y: o)
            }
        }
    }
}

16:29 We're still seeing some gaps in the pattern, and it has something to do with the frame around the HLine view. We're setting this frame's width to twice the available space because we want to be sure it's long enough to span the diagonal axis after we rotate it. But it's unnecessary to make the line view taller, so let's remove the extra height from its frame:

struct DiagonalPattern: View {
    var body: some View {
        GeometryReader { proxy in
            let line = HLine()
                .frame(width: proxy.size.width*2)
                .rotationEffect(.degrees(-45))
            let o = proxy.size.width/2
            ZStack {
                line
                    .offset(x: -o, y: -o)
                line
                line
                    .offset(x: o, y: o)
            }
        }
    }
}

17:39 Now we can fill any shape with the pattern, e.g. a rounded rectangle:

Rendering Scale

17:59 One thing that's not quite right yet is the scale at which we render the pattern image. We can tell because the preview isn't sharp. By default, the striped image is rendered at scale 1, but our display scale is larger.

18:45 The static image method that creates a shape style has a scale parameter, but that's meant to define a scale at which the image should be displayed, similarly to the sourceRect parameter, which we can use to specify which portion of the image should be used.

19:17 Instead, we should render the image at the correct resolution by using the scale property of the image renderer:

extension Image {
    @MainActor
    static func striped() -> Image {
        let content = DiagonalPattern()
            .foregroundColor(.white)
            .frame(width: 30, height: 30)
        let renderer =  ImageRenderer(content: content)
        renderer.scale = 2
        return Image(nsImage: renderer.nsImage!)
    }
}

20:01 Rather than hardcoding a scale value, we should pass that in. And while we're at it, we can add a second parameter for the size of the pattern:

extension Image {
    @MainActor
    static func striped(size: CGFloat, scale: CGFloat) -> Image {
        let content = DiagonalPattern()
            .foregroundColor(.white)
            .frame(width: size, height: size)
        let renderer =  ImageRenderer(content: content)
        renderer.scale = scale
        return Image(nsImage: renderer.nsImage!)
    }
}

20:31 We can read the display scale from the environment values passed into the shape style's resolve method. And we add a property for the pattern size, with a default value of 30 points:

struct DiagonalStripes: ShapeStyle {
    var size: CGFloat = 30

    @MainActor 
    func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
        .image(Image.striped(size: size, scale: environment.displayScale))
    }
}

Testing on iOS

21:33 When we switch the preview to the iOS simulator, our code no longer compiles, because we're using NSImage in the striped method. We can instead take the renderer's CGImage and pass it to the appropriate Image initializer:

extension Image {
    @MainActor
    static func striped(size: CGFloat, scale: CGFloat) -> Image {
        let content = DiagonalPattern()
            .foregroundColor(.white)
            .frame(width: size, height: size)
        let renderer =  ImageRenderer(content: content)
        renderer.scale = scale
        return Image(renderer.cgImage!, scale: scale, label: Text(""))
    }
}

22:20 This compiles, but we're not seeing any stripes because we're still using a white foreground color in the light appearance of the iOS simulator. If we switch to the dynamic primary color, the pattern is visible in both light and dark appearance:

extension Image {
    @MainActor
    static func striped(size: CGFloat, scale: CGFloat) -> Image {
        let content = DiagonalPattern()
            .foregroundColor(.primary)
            .frame(width: size, height: size)
        let renderer =  ImageRenderer(content: content)
        renderer.scale = scale
        return Image(renderer.cgImage!, scale: scale, label: Text(""))
    }
}

23:25 When we tried running this on an older version of Xcode and iOS 17, the image pattern looked different from running on macOS. Fortunately, Xcode 15.2 seems to have fixed this, because it seems to work just fine now.

23:59 Creating an image shape style feels a bit more complicated when we compare it to just drawing lines one by one. On the other hand, the custom shape style can be used to fill any shape, so it's more versatile in that regard, especially when we add more parameters to specify the color, the spacing, and the line width. Let's see what else we can do with this next time.

Resources

  • Sample Code

    Written in Swift 5.9

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

140 Episodes · 49h57min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes