How do I prevent an image from being squashed in a LazyVGrid cell?

67 Views Asked by At

I have a simple grid view where I have cells that show a square image with a couple of lines of text below it.

I'm trying to get the image to fill the square, clipped and not squashed in any way. I have got close, but the image seems to be squashed:

enter image description here

struct CollectionsView: View {
    @StateObject var viewModel: CollectionsViewModel = CollectionsViewModel()
    var body: some View {
        ScrollView {
            let columns = [GridItem(spacing: 16), GridItem(spacing: 16)]
            LazyVGrid(columns: columns) {
                ForEach(viewModel.collections) { collection in
                    Button {
                        print("go to collection")
                    } label: {
                        CollectionsViewCell(collection: collection)
                    }
                    .onAppear {
                        if collection == viewModel.collections.last {
                            Task {
                                try await viewModel.getCollections(nextPage: true)
                            }
                        }
                    }
                }
            }
            .padding(16)
        }
        .navigationTitle("Collections")
        .toolbar {
            ToolbarItem(placement: .navigationBarTrailing) {
                Button {
                    // more button pressed
                } label: {
                    Image(systemName: "ellipsis.circle")
                }
            }
        }
    }
}

struct CollectionsViewCell: View {
    let collection: HashableCollection
    var body: some View {
        VStack(alignment: .leading) {
            AsyncImage(url: URL(string: collection.imageUrlString)) { image in
                image
                    .resizable()
                    .aspectRatio(1, contentMode: .fill)
            } placeholder: {
                Rectangle()
            }
            .background(.secondary)
            .clipped()
            .cornerRadius(11)
            Text(collection.title)
                .lineLimit(1)
                .truncationMode(.tail)
            Text("\(collection.itemCount) item\(collection.itemCount == 1 ? "" : "s")")
                .foregroundColor(.secondary)
        }
    }
}

I have tried using Geometry Reader, but it doesn't behave well inside the ScrollView - is there a better way to achieve this?

// UPDATE //

I have come up with solution, but it seems a bit messy... is there a better way?

struct CollectionsView: View {
    @StateObject var viewModel: CollectionsViewModel = CollectionsViewModel()
    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                let spacing: Double = 16
                let columns = [GridItem(spacing: spacing), GridItem(spacing: spacing)]
                LazyVGrid(columns: columns) {
                    ForEach(viewModel.collections) { collection in
                        Button {
                            print("go to collection")
                        } label: {
                            let geometryInfo = GeometryInfo(size: geometry.size, columnCount: Double(columns.count), spacing: spacing)
                            CollectionsViewCell(geometryInfo: geometryInfo, collection: collection)
                        }
                        .onAppear {
                            if collection == viewModel.collections.last {
                                Task {
                                    try await viewModel.getCollections(nextPage: true)
                                }
                            }
                        }
                    }
                }
                .padding(spacing)
            }
            .navigationTitle("Collections")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button {
                        // more button pressed
                    } label: {
                        Image(systemName: "ellipsis.circle")
                    }
                }
            }
        }
    }
}

struct CollectionsViewCell: View {
    let geometryInfo: GeometryInfo
    let collection: HashableCollection
    var body: some View {
        VStack(alignment: .leading) {
            AsyncImage(url: URL(string: collection.imageUrlString)) { image in
                image
                    .resizable()
                    .scaledToFill()
            } placeholder: {
                Rectangle()
            }
            .frame(width: getImageSize(), height: getImageSize())
            .background(.secondary)
            .clipped()
            .cornerRadius(11)
            Text(collection.title)
                .lineLimit(1)
                .truncationMode(.tail)
            Text("\(collection.itemCount) item\(collection.itemCount == 1 ? "" : "s")")
                .foregroundColor(.secondary)
        }
    }
    func getImageSize() -> Double {
        (geometryInfo.size.width - ((geometryInfo.columnCount + 1) * geometryInfo.spacing)) / geometryInfo.columnCount
    }
}
2

There are 2 best solutions below

1
On

Just use these modifiers for your image:

image
    .resizable()
    .scaledToFill()
    .frame(minHeight: 0, maxHeight: .infinity)
    .aspectRatio(1, contentMode: .fill)

result:

enter image description here

The complete example snippet (starting from your code) is:

struct CollectionsView: View {
    var body: some View {
        ScrollView {
            let spacing: Double = 16
            let columns = [GridItem(spacing: spacing), GridItem(spacing: spacing)]
            LazyVGrid(columns: columns) {
                ForEach(0...10, id: \.self) { collection in
                    CollectionsViewCell()
                }
            }
            .padding(spacing)
        }
    }
}

struct CollectionsViewCell: View {
    var body: some View {
        VStack(alignment: .leading) {
            AsyncImage(url: URL(string: "https://picsum.photos/id/237/200/300")) { image in
                image
                    .resizable()
                    .scaledToFill()
                    .frame(minHeight: 0, maxHeight: .infinity)
                    .aspectRatio(1, contentMode: .fill)
            } placeholder: {
                Rectangle()
            }
            .background(.secondary)
            .clipped()
            .cornerRadius(11)
            Text("Title")
                .lineLimit(1)
                .truncationMode(.tail)
            Text("18 items")
                .foregroundColor(.secondary)
        }
    }
}
0
On

The trick is to make the image accept a given size coming from higher in the view hierarchy. This solution puts the image in a ZStack together with something that does accept given sizes, e.g. just a Color.

Then make the inner image .fill – and give it a lower .layoutPriority, so it accepts the size defines by the color.

Then add .aspectRatio(1, contentMode: .fit) and other modifiers to the outer view.

enter image description here

struct CollectionsViewCell: View {
    let collection: HashableCollection
    var body: some View {
        VStack(alignment: .leading) {
            ZStack { // new ZStack
                AsyncImage(url: URL(string: collection.imageUrlString)) { image in
                    image
                        .resizable()
                        .scaledToFill()
                } placeholder: {
                    Rectangle()
                }
                .layoutPriority(-1) // THIS is the important line

                Color.clear // dummy view to activate layoutPriority on other view
            }
            .clipped()
            .aspectRatio(1, contentMode: .fit)
            .cornerRadius(11)
            
            Text(collection.title)
                .lineLimit(1)
                .truncationMode(.tail)
            Text("\(collection.itemCount) item\(collection.itemCount == 1 ? "" : "s")")
                .foregroundColor(.secondary)
        }
    }
}