EXPEDIA GROUP TECHNOLOGY — SOFTWARE
Resizing Images in SwiftUI
How to resize images to any aspect ratio without distortion
In the Vrbo™️ (part of Expedia Group™) iOS app, we’ve recently converted some of our existing UIKit views over to SwiftUI and encountered issues when resizing images. The problem arises when we need to resize an image to a certain size and maintain the aspect ratio without skewing.
We initially encountered this while working on Vrbo’s iOS 14 Widget, when we needed the image to be a perfect square, and again when building a SwiftUI version of one of our property cards. We display property images on nearly every screen in the app and at varying sizes depending on the context, meaning it was important to solve this problem in a scalable way.
iOS Widget
Property Card
The Road to Resizing
Starting from scratch, this is what you get when you render an Image
in SwiftUI without any modifiers.
// original size: 338 x 360
Image("bungalows")
Now things are going to start getting weird. First, we explicitly set the size via .frame()
.
// try to override size
Image("bungalows")
.frame(width: 100, height: 100)
Note: the blue square is there to indicate where the frame is. This wouldn’t be visible in the UI.
Since nothing happens when you set the .frame()
explicitly, let’s see what applying the .clipped()
modifier gets us.
// this sorta works
Image("bungalows")
.frame(width: 100, height: 100)
.clipped()
Better….but our image just got cropped and was not resized. SwiftUI has a .resizable()
view modifier, so what happens when we use that instead?
Image("bungalows")
.resizable()
Not what we want, but at least the image is resized now. By default, the resizingMode is .stretch
, which is what we see above, but you could also set this to .tile
.
// .stretch is default
Image("bungalows")
.resizable(resizingMode: .tile)
The image looks better, but this is still not what we want. Let’s try explicitly setting the .frame()
again, but this time in conjunction with the .resizable()
modifier.
// specify an exact size
Image("bungalows")
.resizable()
.frame(width: 338, height: 200)
It’s skewed! That’s not going to work. Let’s try to resize by aspect ratio.
// use aspect ratio + .fit to resize
Image("bungalows")
.resizable()
.aspectRatio(4/3, contentMode: .fit)
Using .fit
still gets us a skewed image. Instead, let's try using .fill
// use aspect ratio + .fill to resize
Image("bungalows")
.resizable()
.aspectRatio(4/3, contentMode: .fill)
Well that went wrong. We’re going to need to try something different…or Rectangle
.
// let the trickery begin
ZStack {
Rectangle()
.fill(Color(.gray))
.aspectRatio(4/3, contentMode: .fit)
Image("bungalows")
}
ZStack
is how we stack things on top of each other in SwiftUI. The Rectangle
is exactly the size we want our image to be. Can we make our image the same size as the Rectangle
?
// getting closer...
ZStack {
Rectangle()
.fill(Color(.gray))
.aspectRatio(4/3, contentMode: .fit)
Image("bungalows")
.resizable()
.layoutPriority(-1) // special sauce
}
We can! We’re giving .layoutPriority()
to the Rectangle
so that it is dictating the size instead of the Image
. But now our image is skewed again! We need to fix that.
// Come on!!!
ZStack {
Rectangle()
.fill(Color(.gray))
.aspectRatio(4/3, contentMode: .fit)
Image("bungalows")
.resizable()
.aspectRatio(contentMode: .fill) // fix skewed image
.layoutPriority(-1)
}
Note: the blue square is there to indicate where the Rectangle is. This wouldn’t be visible in the UI.
One last modifier and we’ve got this! We’re bringing .clipped()
back!
// Yes!
ZStack {
Rectangle()
.fill(Color(.gray))
.aspectRatio(4/3, contentMode: .fit)
Image("bungalows")
.resizable()
.aspectRatio(contentMode: .fill)
.layoutPriority(-1)
}
.clipped() // magic
The Solution
Now that we finally have a working solution, we need to make this reusable so that we never have to go through this again.
Let’s convert this to a ViewModifier
so that we can apply it to any Image
.
A few things to note about the final solution:
- We added an
AspectRatio
enum for the most common usages in the Vrbo iOS app. This solution supports both our predefined aspect ratios as well as a custom aspect ratio. .resizable()
was moved to theImage
extension..resizable()
can only be applied to anImage
, so it cannot be applied inside of theViewModifier
, which expectsContent
that could be any type ofView
..aspectRatio(contentMode: .fill)
was replaced with its shortcut modifier,.scaleToFill()
- We set the
Rectangle
fill color to be.clear
instead of.gray
. The only time theRectangle
would ever show would be if the image failed to render. For the Vrbo iOS app, we typically combine this modifier with other modifiers for providing a fallback state. However, this modifier could be expanded to support a fallback image or a fallback color.
Usage
Image("resorts")
.fitToAspectRatio(.square)
Image("resorts")
.fitToAspectRatio(.fourToThree)
Image("resorts")
.fitToAspectRatio(.threeToFour)
Image("resorts")
.fitToAspectRatio(1.5)
.clipShape(RoundedRectangle(cornerRadius: 8))
Now, no matter the size of a container View
, our image will always render to our desired aspect ratio without becoming distorted. Thanks for reading!