23

I am trying to put multiple cells next to each other where each cell consists of an image and a text below. The cell itself should be a square and the image should be scaled to fill the remaining space (cutting a part of the image).

First I tried just making the image square and the text below. Now my problem is, that I don't know how to properly achieve that in SwiftUI. I can get it to work, when using this code:

VStack {
    Image(uiImage: recipe.image!)
        .resizable()
        .aspectRatio(contentMode: .fill)
        .frame(width: 200, height: 200, alignment: .center)
        .clipped()
    Text(recipe.name)
}

Image 1 The problem is, that I have to specify a fixed frame size. What I want is a way to make a cell, that keeps an aspect ratio of 1:1 and is resizable, so I can fit a dynamic amount of them on a screen next to each other.

I also tried using

VStack {
    Image(uiImage: recipe.image!)
        .resizable()
        .aspectRatio(1.0, contentMode: .fit)
        .clipped()
    Text(recipe.name)
}

Image 2 which gives me square images, that scale dynamically. But the problem is, that the image now gets stretched to fill the square and not scaled to fill it.

My next idea was to clip it to a square shape. For that I first tried to clip it into a circle shape (because apparently there is not square shape):

VStack {
    Image(uiImage: recipe.image!)
        .resizable()
        .aspectRatio(contentMode: .fit)
        .clipShape(Circle())
    Text(recipe.name)
}

Image 3

But for some odd reason, it didn't really clip the image but instead kept the remaining space...

So am I not seeing something or is the only option to clip an image square the frame modifier?

EDIT

To clarify: I don't care about the text as much, as about the whole cell (or if it's simpler the image) being square, without having to specify its size via .frame and without the non-square original image being stretched to make it fit.

So the perfect solution would be that the VStack is square but getting a square image would also be okay. It should look like Image 1, but without having to use the .frame modifier.

shim
  • 9,289
  • 12
  • 69
  • 108
iComputerfreak
  • 890
  • 2
  • 8
  • 22

6 Answers6

34

A ZStack will help solve this by allowing us to layer views without one effecting the layout of the other.

For the text:

.frame(minWidth: 0, maxWidth: .infinity) to expand the text horizontally to its parent's size

.frame(minHeight: 0, maxHeight: .infinity) is useful in other situations

As for the image:

.aspectRatio(contentMode: .fill) to make the image maintain its aspect ratio rather than squashing to the size of its frame.

.layoutPriority(-1) to de-prioritize laying out the image to prevent it from expanding its parent (the ZStack within the ForEach in our case).

The value for layoutPriority just needs to be lower than the parent views which will be set to 0 by default. We have to do this because SwiftUI will layout a child before its parent, and the parent has to deal with the child size unless we manually prioritize differently.

The .clipped() modifier uses the bounding frame to mask the view so you'll need to set it to clip any images that aren't already 1:1 aspect ratio.

    var body: some View {
        HStack {
            ForEach(0..<3, id: \.self) { index in
                ZStack {
                    Image(systemName: "doc.plaintext")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .layoutPriority(-1)
                    VStack {
                        Spacer()
                        Text("yes")
                            .frame(minWidth: 0, maxWidth: .infinity)
                            .background(Color.white)
                    }
                }
                .clipped()
                .aspectRatio(1, contentMode: .fit)
                .border(Color.red)
            }
        }
    }

Edit: While geometry readers are super useful I think they should be avoided whenever possible. It's cleaner to let SwiftUI do the work. This is my initial solution with a Geometry Reader that works just as well.

        HStack {
            ForEach(0..<3, id: \.self) { index in
                ZStack {
                    GeometryReader { proxy in
                        Image(systemName: "pencil")
                            .resizable()
                            .scaledToFill()
                            .frame(width: proxy.size.width)
                        VStack {
                            Spacer()
                            Text("yes")
                                .frame(width: proxy.size.width)
                                .background(Color.white)
                        }
                    }
                }
                .clipped()
                .aspectRatio(1, contentMode: .fit)
                .border(Color.red)
            }
        }

ethoooo
  • 536
  • 4
  • 12
  • The Text isn't the problem. My main problem is to make the image square without having to specify its size manually (via `.frame`). If I find a solution to cut the image square with SwiftUI and then put a text under/on top of it, I'm fine with that. Also your solution still stretches the image. If you don't use "pencil", but an image, that is not square, it will stretch it to make it square, not cut it. – iComputerfreak Oct 08 '19 at 19:38
  • @iComputerfreak Updated my answer to better address your question. – ethoooo Oct 08 '19 at 19:49
  • I updated my question aswell. Your solution still requires me so specify the size of the ZStack via `.frame`, but I want a solution, so I can let SwiftUI scale the cells dynamically. E.g. by putting three cells next to each other in a HStack, so that they can fill the whole width of the screen. I will look into `GeometryReader` – iComputerfreak Oct 08 '19 at 19:56
  • @iComputerfreak please see my updated code example. I think that is what you're looking for, I'm not aware of a better way to do it. – ethoooo Oct 08 '19 at 20:06
  • Thank you! The GeometryReader solution was exactly what I was looking for. I had to move the `.clipped()` to the end of the `ZStack` though, otherwise the image would clip out of the cell. – iComputerfreak Oct 08 '19 at 20:14
  • Dope! Glad I was able to help. What order did you put the zStack modifiers in? I'll update my answer for others. – ethoooo Oct 08 '19 at 20:18
  • 1
    I put `clipped()` first, but it doesn't seem to matter. Also, if you add the `height: proxy.size.height` argument for the frame of the image, the image will get centered vertically. – iComputerfreak Oct 08 '19 at 20:20
  • 1
    Could reproduce the effect using both techniques. Here is a runnable version (with cookie images!): https://github.com/ralfebert/SwiftUIPlayground/blob/master/SwiftUIPlayground/Views/ClipImageSquareView.swift – Ralf Ebert May 16 '20 at 04:45
  • 2
    layoutPriority(-1) did the trick. Thank you so much: you just saved me sooooooo much time and nerves. – Konstantin Loginov Sep 15 '21 at 08:05
  • setting layout priority did not work for me. – Chad Oct 06 '21 at 13:19
16

Here's another solution I found on Reddit and improved a bit:

Color.clear
    .aspectRatio(1, contentMode: .fit)
    .overlay(
        Image(imageName)
            .resizable()
            .scaledToFill()
        )
    .clipShape(Rectangle())

It is similar to Chads answer but differs in the way you put image relatively to the clear color (background vs overlay)

Bonus: to let it have circular shape just use .clipShape(Circle()) as the last modifier. Everything else stays unchanged

ramzesenok
  • 5,469
  • 4
  • 30
  • 41
3

It works for me, but I don't know why cornerRadius is necessary...

import SwiftUI

struct ClippedImage: View {
    let imageName: String
    let width: CGFloat
    let height: CGFloat

    init(_ imageName: String, width: CGFloat, height: CGFloat) {
        self.imageName = imageName
        self.width = width
        self.height = height
    }
    var body: some View {
        ZStack {
            Image(imageName)
                .resizable()
                .aspectRatio(contentMode: .fill)
                .frame(width: width, height: height)
        }
        .cornerRadius(0) // Necessary for working
        .frame(width: width, height: height)
    }
}

struct ClippedImage_Previews: PreviewProvider {
    static var previews: some View {
        ClippedImage("dishLarge1", width: 100, height: 100)
    }
}

enter image description here

kazuwombat
  • 1,515
  • 1
  • 16
  • 19
3

This is the answer that worked for me:

VStack {
  Color.clear
    .aspectRatio(1, contentMode: .fit)
    .background(Image(uiImage: recipe.image!)
                  .resizable()
                  .aspectRatio(contentMode: .fill))
    .clipped()
  Text(recipe.name)
}

I use clear color, then set the aspect ratio using fit. That makes the container square. Then I added a background of the image set it to fill. To top it off, I add clipped so the background doesn't spill over the edges of the square.

Chad
  • 382
  • 1
  • 11
3

Answer based on the one by @ramzesenok but wrapped into a view modifier

Modifier:

struct FitToAspectRatio: ViewModifier {

    let aspectRatio: Double
    let contentMode: SwiftUI.ContentMode

    func body(content: Content) -> some View {
        Color.clear
            .aspectRatio(aspectRatio, contentMode: .fit)
            .overlay(
                content.aspectRatio(nil, contentMode: contentMode)
            )
            .clipShape(Rectangle())
    }

}

You can optionally also add an extension function for easy access

extension Image {
    func fitToAspect(_ aspectRatio: Double, contentMode: SwiftUI.ContentMode) -> some View {
        self.resizable()
            .scaledToFill()
            .modifier(FitToAspectRatio(aspectRatio: aspectRatio, contentMode: contentMode))
    }
}

and then simply

Image(...).fitToAspect(1, contentMode: .fill)
Hamzah Malik
  • 2,540
  • 3
  • 28
  • 46
0

GeometryReader for frame

 var body: some View {
    GeometryReader { geometry in
       ScrollView {
           VStack(spacing: 1) {
               ForEach(recipes) { recipe in
                   GalleryView(width: geometry.size.width, recipe: recipe)
                }
            }
        }
        .edgesIgnoringSafeArea(.all)
     }
 }

Overlapping gestures

If you have gesture actions and images close each other, add .contentShape() modifier to assign the tappable area.

Width and height get from GeometryReader of the previous View.

struct GalleryView: View {

    var width: CGFloat
    var recipe: Recipe

    private enum C {
        static let goldenRatio = CGFloat(0.67)
    }

    var body: some View {
       VStack(spacing: 1) {
          Image(uiImage: recipe.image)
              .resizable()
              .scaledToFill()
              .frame(width: width, height: height, alignment: .center)
              .clipped()
              .contentShape(Rectangle())

          Text(recipe.name)
        }
        .frame(height: width * C.goldenRatio + 1)
        .frame(width: width * C.goldenRatio / 2)
      }
 }
Valerika
  • 366
  • 1
  • 3
  • 8