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.
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()
}
}
}
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()
}
}
}
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.
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.
Thanks for reading!
You can find my code on github.