SwiftUI aspectRatio, scaledToFit and scaledToFill
In SwiftUI, aspect fit is .scaledToFit() and aspect fill is .scaledToFill(). Both are shorthands for .aspectRatio(contentMode:), and both do nothing useful to an Image until you add .resizable().
Start with .resizable()
An Image is drawn at its own size in points by default, whatever frame it is given. .resizable() turns it into a view that accepts any size, and only then do the aspect modifiers have something to work with:
// 1. Natural size: a 1200-point-wide image stays 1200 points wide
Image("harbor")
// 2. Stretched to the frame, ratio ignored (like UIKit .scaleToFill)
Image("harbor")
.resizable()
.frame(width: 400, height: 300)
// 3. Aspect fit inside the frame
Image("harbor")
.resizable()
.scaledToFit()
.frame(width: 400, height: 300)Order matters because each modifier wraps the view before it. .resizable() must come directly after Image: it is a method on Image itself, not a general view modifier, so it will not compile after .frame.
.aspectRatio(contentMode:) and its shorthands
The full modifier is aspectRatio(_ aspectRatio: CGFloat? = nil, contentMode: ContentMode), with a CGSize overload. ContentMode has two cases, .fit and .fill. When the ratio is nil, SwiftUI uses the view's own ideal ratio, which for an image is the image's ratio.
| You write | Same as | Result in a 400 × 300 frame (1200 × 800 image) |
|---|---|---|
.scaledToFit() | .aspectRatio(contentMode: .fit) | 400 × 266.67, centered, empty bands |
.scaledToFill() | .aspectRatio(contentMode: .fill) | 450 × 300, overflowing the frame by 25 on each side |
.aspectRatio(16/9, contentMode: .fit) | the largest 16:9 size inside the frame: 400 × 225 (image stretched to 16:9) |
The last row is easy to misread: passing a ratio that differs from the image's own ratio distorts the image, because the image is resizable and is told to be 16:9. Use an explicit ratio for views that have no ratio of their own (colors, shapes, video players, containers), and leave it out for images.
Fill a frame and crop the rest
.scaledToFill() makes the image larger than the frame on one axis, and SwiftUI draws outside a frame unless told otherwise. Add .clipped() after the frame:
Image("harbor")
.resizable()
.scaledToFill()
.frame(width: 400, height: 300)
.clipped()For rounded corners use .clipShape(RoundedRectangle(cornerRadius: 12)) instead of .clipped(); it clips to the shape.
To keep a different part of the image, give the frame an alignment. The frame positions its oversized child according to it, so .top keeps the top edge and crops only from the bottom:
Image("portrait")
.resizable()
.scaledToFill()
.frame(width: 400, height: 220, alignment: .top)
.clipped()Taps outside the visible image
.clipped() changes what is drawn, not where the view receives touches. A filled image inside a button or with .onTapGesture can still catch taps in the cropped-off area that overlaps its neighbors. Restrict hit testing to the frame with a content shape:
Image("harbor")
.resizable()
.scaledToFill()
.frame(width: 160, height: 160)
.clipped()
.contentShape(Rectangle())
.onTapGesture { open(photo) }Square thumbnails in a grid
A filled image reports its full, overflowing size to its parent, which can push grid cells apart. A reliable pattern is to let a clear, square view define the layout and put the image in an overlay, which does not influence the size:
let columns = [GridItem(.adaptive(minimum: 110), spacing: 2)]
LazyVGrid(columns: columns, spacing: 2) {
ForEach(photos) { photo in
Color.clear
.aspectRatio(1, contentMode: .fit) // the cell is a square
.overlay {
Image(photo.name)
.resizable()
.scaledToFill()
}
.clipped()
}
}The trailing-closure form of overlay needs iOS 15 or macOS 12. Change the ratio (for example 4/5) for portrait cards.
Giving any view a ratio
aspectRatio is not only for images. With .fit the view becomes the largest rectangle of that ratio inside the space offered; with .fill, the smallest rectangle of that ratio that covers it.
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.2))
.aspectRatio(16/9, contentMode: .fit) // a 16:9 placeholder, full width
.overlay { ProgressView() }AsyncImage
AsyncImage (iOS 15 and later) hands you an Image in its content closure, so the same modifiers apply. Put the frame and clipping on the AsyncImage so the placeholder gets the same size:
AsyncImage(url: photo.url) { image in
image
.resizable()
.scaledToFill()
} placeholder: {
Color.gray.opacity(0.15)
}
.frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 10))Scale down only, fit width, fit height
SwiftUI has no single modifier for "shrink large images but never enlarge small ones", or for "scale by width and let the height overflow". Compute the scale with the same formulas and set the size explicitly; it is three lines and leaves no doubt about the result:
let imageSize = CGSize(width: 1200, height: 800) // in points
let box = CGSize(width: 400, height: 300)
let s = min(1, box.width / imageSize.width, box.height / imageSize.height)
Image("harbor")
.resizable()
.frame(width: imageSize.width * s, height: imageSize.height * s)
.frame(width: box.width, height: box.height)The visualizer writes this code for the box and image sizes you enter, including the alignment.
SwiftUI and UIKit side by side
| Behavior | SwiftUI | UIKit |
|---|---|---|
| Aspect fit | .resizable().scaledToFit() | .scaleAspectFit |
| Aspect fill | .resizable().scaledToFill() + .clipped() | .scaleAspectFill + clipsToBounds = true |
| Stretch | .resizable() | .scaleToFill (default) |
| Natural size | Image without .resizable() | .center, .top, .topLeft and the other position modes |
| Anchor the crop | .frame(…, alignment:) | no built-in option |
The math behind both columns is explained in aspect fit vs aspect fill.
Questions
Why does .scaledToFit() not resize my Image?
An Image keeps its natural size until you call .resizable(). Write Image("name").resizable().scaledToFit(); resizable must come directly after Image.
Is .scaledToFill() the same as .aspectRatio(contentMode: .fill)?
Yes. scaledToFit() and scaledToFill() are shorthands for aspectRatio(contentMode: .fit) and aspectRatio(contentMode: .fill) with no explicit ratio, so the image's own ratio is used.
How do I stop a scaledToFill image from overflowing its frame?
Add .clipped() (or .clipShape for rounded corners) after the .frame modifier. Add .contentShape(Rectangle()) as well if the view handles taps, because clipping does not shrink the tap area.
Want your own numbers? The visualizer draws any box and image, shows each step of the math and writes the code for six toolkits.