ImageRenderer

It seems this year that apple has decided to go around everything that I still use from UIKit, and give me an alternative. Until now, rendering images in SwiftUI meant you'd have to capture the window or hierarchy and utilise UIGraphicsImageRenderer. ImageRenderer changes that.

The basics

ImageRenderer is a simple object that is capable of rendering a given SwiftUI view into a UIImage or CGImage.

Usage is as simple as creating a renderer, giving it some content, and then asking for an image.

let renderer = ImageRenderer(content: content)
self.image = renderer.uiImage
let renderer = ImageRenderer(content: content)
self.image = renderer.uiImage

To see a complete view in action using the renderer, here im using a piece of text as my content, rendering it as soon as it appears, and then displaying that.

struct ContentView: View {
    @State private var image: UIImage?

    var body: some View {
        HStack(alignment: .center) {
            content
            Divider()
                .padding()
            renderedImage
        }
        .padding()
        .frame(maxWidth: .infinity)
        .background(
            Color(UIColor.systemGroupedBackground)
        )
        .onAppear {
            let renderer = ImageRenderer(content: content)
            self.image = renderer.uiImage
        }
    }

    var content: some View {
        Text("Hello!")
            .font(.largeTitle)
    }

    @ViewBuilder
    var renderedImage: some View {
        if let image = image {
            Image(uiImage: image)
        } else {
            EmptyView()
        }
    }
}
struct ContentView: View {
    @State private var image: UIImage?

    var body: some View {
        HStack(alignment: .center) {
            content
            Divider()
                .padding()
            renderedImage
        }
        .padding()
        .frame(maxWidth: .infinity)
        .background(
            Color(UIColor.systemGroupedBackground)
        )
        .onAppear {
            let renderer = ImageRenderer(content: content)
            self.image = renderer.uiImage
        }
    }

    var content: some View {
        Text("Hello!")
            .font(.largeTitle)
    }

    @ViewBuilder
    var renderedImage: some View {
        if let image = image {
            Image(uiImage: image)
        } else {
            EmptyView()
        }
    }
}

Here's what that looks like.

simple-example

Thats actually all you need to know to get started, but we can push things a little further.

Lets take a more advanced view, and see what happens if we try to clone that.

struct ContentView: View {
    @State private var image: UIImage?

    var body: some View {
        VStack(alignment: .center) {
            content
            Divider()
                .padding()
            renderedImage
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(
            Color(UIColor.systemGroupedBackground)
        )
        .onAppear {
            let renderer = ImageRenderer(content: content)
            self.image = renderer.uiImage
        }
    }

    var content: some View {
        VStack(alignment: .leading) {
            Text("Recipe")
                .font(.headline.weight(.semibold))
            BrewElementGrid()
        }
        .padding()
        .background(.white, in: RoundedRectangle(cornerRadius: 12))
    }

    @ViewBuilder
    var renderedImage: some View {
        if let image = image {
            Image(uiImage: image)
        } else {
            EmptyView()
        }
    }
}
struct ContentView: View {
    @State private var image: UIImage?

    var body: some View {
        VStack(alignment: .center) {
            content
            Divider()
                .padding()
            renderedImage
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(
            Color(UIColor.systemGroupedBackground)
        )
        .onAppear {
            let renderer = ImageRenderer(content: content)
            self.image = renderer.uiImage
        }
    }

    var content: some View {
        VStack(alignment: .leading) {
            Text("Recipe")
                .font(.headline.weight(.semibold))
            BrewElementGrid()
        }
        .padding()
        .background(.white, in: RoundedRectangle(cornerRadius: 12))
    }

    @ViewBuilder
    var renderedImage: some View {
        if let image = image {
            Image(uiImage: image)
        } else {
            EmptyView()
        }
    }
}

simple-example

You can see that this view gets rendered at the incorrect size, even though we've provided the renderer with the exact same view to make our image. You could see this as a bug, but there's actually a way around this built right into ImageRenderer.

There's a special property on ImageRenderer that will help us fix this, and its proposedSize.

This allows us to pass a specific size to the ImageRenderer without having to set explicit sizes on our views in both axis.

The simplest way to use this, would be to read the size of your content using GeometryReader and only render your image once you have a size - lets see how that would work.

struct ContentView: View {
    @State private var imageSize: CGSize?
    @State private var image: UIImage?

    var body: some View {
        VStack(alignment: .center) {
            content
                .background(
                    GeometryReader { geometry in
                        Color.clear
                            .onAppear {
                                self.imageSize = geometry.frame(in: .global).size
                            }
                            .onChange(of: geometry.frame(in: .global).size) { newValue in
                                self.imageSize = newValue
                            }
                    }
                )
            Divider()
                .padding()
            renderedImage
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(
            Color(UIColor.systemGroupedBackground)
        )
        .onChange(of: imageSize) { newSize in
            if let newSize {
                let renderer = ImageRenderer(content: content)
                renderer.proposedSize = ProposedViewSize(newSize)
                self.image = renderer.uiImage
            }
        }
    }

    var content: some View {
        VStack(alignment: .leading) {
            Text("V60 Recipe")
                .font(.subheadline.weight(.semibold))
            BrewElementGrid()
        }
        .padding()
        .background(.white, in: RoundedRectangle(cornerRadius: 12))
    }

    @ViewBuilder
    var renderedImage: some View {
        if let image = image {
            Image(uiImage: image)
        } else {
            EmptyView()
        }
    }
}
struct ContentView: View {
    @State private var imageSize: CGSize?
    @State private var image: UIImage?

    var body: some View {
        VStack(alignment: .center) {
            content
                .background(
                    GeometryReader { geometry in
                        Color.clear
                            .onAppear {
                                self.imageSize = geometry.frame(in: .global).size
                            }
                            .onChange(of: geometry.frame(in: .global).size) { newValue in
                                self.imageSize = newValue
                            }
                    }
                )
            Divider()
                .padding()
            renderedImage
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(
            Color(UIColor.systemGroupedBackground)
        )
        .onChange(of: imageSize) { newSize in
            if let newSize {
                let renderer = ImageRenderer(content: content)
                renderer.proposedSize = ProposedViewSize(newSize)
                self.image = renderer.uiImage
            }
        }
    }

    var content: some View {
        VStack(alignment: .leading) {
            Text("V60 Recipe")
                .font(.subheadline.weight(.semibold))
            BrewElementGrid()
        }
        .padding()
        .background(.white, in: RoundedRectangle(cornerRadius: 12))
    }

    @ViewBuilder
    var renderedImage: some View {
        if let image = image {
            Image(uiImage: image)
        } else {
            EmptyView()
        }
    }
}

simple-example

If this looks familiar, its because its the actual code from my app Coffee Book.

You can see that when the view appears, I read its size, and then render an image based on that exact size.

There's one last trick, and thats to set the quality of the image. You'll notice so far that everything has been a little blurry. Its looked ok, but the end result doesn't look quite as sharp as we'd like.

To fix this, we can set the renderers scale property.

renderer.scale = UIScreen.main.scale
renderer.scale = UIScreen.main.scale

I've also set the max width of my content to 300 pixels just so it looks a little nicer.

simple-example

This is now the exact same qualty as our original image, and we can share that to wherever we please.

Using the image

We've shown using the image, but what about if I wanted to share it?

We can use ShareLink and the new Transferable protocol to get really fancy here.

Where we have our image already, lets add a share button that users can tap to share the image somewhere, and give it a nice preview icon. Here im just using an SFSymbol, but you could replace this with your app icon or similar.

@ViewBuilder
var renderedImage: some View {
    if let image = image {
        Image(uiImage: image)
        ShareLink(
            item: Image(uiImage: image),
            preview: SharePreview("Your Recipe", icon: sharePreview)
        )
    } else {
        EmptyView()
    }
}

var sharePreview: some Transferable {
    Image(systemName: "text.book.closed.fill")
}
@ViewBuilder
var renderedImage: some View {
    if let image = image {
        Image(uiImage: image)
        ShareLink(
            item: Image(uiImage: image),
            preview: SharePreview("Your Recipe", icon: sharePreview)
        )
    } else {
        EmptyView()
    }
}

var sharePreview: some Transferable {
    Image(systemName: "text.book.closed.fill")
}

To see that in action, here's that running on my iPhone, sending a recipe to my friends.

simple-example


Thanks for reading!

You can find my code on github.