SwiftUI LazyVGrid content compression

321 views Asked by At

I am trying to build a grid, consisting of items with image and text under it. I want all images be the same size and the text to expand cell's height as much as text is needed. But Image is always compressed to preserve all cells equal height and the text not to be truncated.

I've tried a lot of modifiers for LazyVGrid and also setting size parameters for GridItem but still not reached desired result

What should I do to build described UI? Should I use LazyVGrid or it is better to use Grid (the content is static and items quantity is relatively small to think about optimization)?

enter image description here

    let layout = [
        GridItem(),
        GridItem(),
        GridItem()
    ]
    
    var body: some View {
        ScrollView {
            LazyVGrid(columns: layout, spacing: 10) {
                ForEach(cardsContainer.cards) { card in
                    VStack {
                        if let uiImage = UIImage(named: card.imageName) {
                            Image(uiImage: uiImage)
                                .resizable()
                                .scaledToFill()
                                .clipped()
                        }
                        Text(card.name)
                        Text(card.arcana.rawValue)
                    }
                }
            }
        }
    }

PS May be smbdy can advice some good resources about advanced aspects of collections building with SwiftUI cuz I find only basic info about it and only a little info with the comparison of Grids vs Stacks in SwiftUI.

1

There are 1 answers

5
Benzy Neez On BEST ANSWER

It might help if you use change the alignment of the GridItem to .top and then use .scaledToFit instead of .scaledToFill on the images. Like this:

let layout = [
    GridItem(alignment: .top),
    GridItem(alignment: .top),
    GridItem(alignment: .top)
]
VStack {
    if let uiImage = UIImage(named: card.imageName) {
        Image(uiImage: uiImage)
            .resizable()
            .scaledToFit() // not .scaledToFill
            .clipped()
    }
    Text(card.name)
    Text(card.arcana.rawValue)
}

Otherwise, the way to fix the misaligned grid contents is to make sure the labels always occupy the same space. This is done by establishing a footprint for a standard label, then showing the actual label in an overlay.

There would be two variations of this approach, depending on how you want to handle names that are too long for a single line.

1. Allow the font to shrink

You can constrain a name to a single line by applying .lineLimit(1). Then, to avoid truncation (with ellipses), you can allow the font to shrink by using .minimumScaleFactor.

VStack {
    if let uiImage = UIImage(named: card.imageName) {
        Image(uiImage: uiImage)
            .resizable()
            .scaledToFill()
            .clipped()
    }
    VStack {
        Text(card.name)
        Text(card.arcana.rawValue)
    }
    .lineLimit(1)
    .hidden() // Hide the footprint
    .overlay {
        VStack {
            Text(card.name)
            Text(card.arcana.rawValue)
        }
        .lineLimit(1)
        .minimumScaleFactor(0.5)
    }
}

Screenshot

2. Allow names to wrap

If you want to keep the font size constant and allow names to wrap, then the footprint needs to be based on the longest possible name:

VStack {
    if let uiImage = UIImage(named: card.imageName) {
        Image(uiImage: uiImage)
            .resizable()
            .scaledToFill()
            .clipped()
    }
    VStack {
        Text("The longest possible name")
        Text(card.arcana.rawValue)
    }
    .hidden() // Hide the footprint
    .overlay(alignment: .top) {
        VStack {
            Text(card.name)
            Text(card.arcana.rawValue)
        }
    }
}

ConstantFontSize

Regarding your question about whether to use LazyVGrid or Grid, this is probably answered by the documentation:

Only use a lazy grid if profiling your app shows that a Grid view performs poorly because it tries to load too many views at once.

If you use a Grid then I think you are responsible for ordering the contents into rows, so it is a bit more overhead. But if you want all columns of the grid to have the same width then you don't really need to use a Grid at all. You could use a combination of VStack (for the overall container) and HStack (for the rows) and set .frame(maxWidth: .infinity) on every grid item. This will cause them to share the width of an HStack equally. This way, you could probably achieve the same result as both the solutions above, but without needing any hidden footprints.


EDIT The explanation for the first suggestion (using .scaledToFit and alignment .top) is as follows:

  • By default, the GridItem have a .flexible size. According to the documentation, this means:

The size of this item is the size of the grid with spacing and inflexible items removed, divided by the number of flexible items, clamped to the provided bounds.

...so the columns will each be given one third of the available width.

  • By using .scaledToFit, it seems that the size of the image is determined by the width and all images have the same size.

  • When the label of an image needs to wrap, it causes the height of the row to increase. This means, if there are any cells in the same row which only have 2-line labels then these cells will have some spare vertical space. By using alignment: .top, the content of the cell is aligned at the top and the spare space is all at the bottom. This keeps all the images in the row aligned with each other.

  • When .scaledToFill is used instead of scaledToFit and a label needs to wrap, it seems the images are scaled a bit smaller to give more space for the labels. This spoils the layout. This surprises me a bit, I would have expected the images to be cropped top-and-bottom. But if .scaledToFit gives the required result and works reliably then it doesn't really matter how it looks with .scaledToFill.