Chapter 29
Building Grid Layout Using LazyVGrid and LazyHGrid

The initial release of SwiftUI didn't come with a native collection view. You can either build your own solution or use third party libraries. In WWDC 2020, Apple introduced tons of new features for the SwiftUI framework. One of them is to address the need of implementing grid views. SwiftUI now provides developers two new UI components called LazyVGrid and LazyHGrid. One is for creating vertical grid and the other is for horizontal grid. The word Lazy, as mentioned by Apple, refers that the grid view does not create items until they are needed. What this means to you is that the performance of these grid views are already optimized by default.

In this chapter, I will walk you through how to create both horizontal and vertical views. Both LazyVGrid and LazyHGrid are designed to be flexible, so that developers can easily create various types of grid layout. We will also look into that and see how to vary the size of grid items to achieve different layouts. After you manage the basics, we will dive a little bit deeper and create complex layout like that shown in figure 1.

Figure 1. Sample grid layouts
Figure 1. Sample grid layouts

The Essential of Grid Layout in SwiftUI

To create a grid layout, whether it's horizontal or vertical, here are the steps you can follow:

  1. First, you need to prepare the raw data for presenting in the grid. Say, for example, here is an array of SF symbols that we are going to present in the demo app:

    private var symbols = ["keyboard", "hifispeaker.fill", "printer.fill", "tv.fill", "desktopcomputer", "headphones", "tv.music.note", "mic", "plus.bubble", "video"]
    
  2. Create an array of GridItem that describes how the grid looks like. Say, how many columns should the grid have? Here is a sample code snippet for describing a 3-column grid:

    private var threeColumnGrid = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
    
  3. Next, you can layout the grid by using LazyVGrid and ScrollView. Here is the code snippet:

    ScrollView {
        LazyVGrid(columns: threeColumnGrid) {
            // Display the item
        }
    }
    
  4. Alternatively, if you want to build a horizontal grid, you use LazyHGrid like this:

    ScrollView(.horizontal) {
        LazyHGrid(rows: threeColumnGrid) {
            // Display the item
        }
    }
    

Using LazyVGrid to Create Vertical Grids

With some basic understanding of the grid layout, let's put the code to work. We will start with something simple by building a 3-column grid. Open Xcode 12 (or up) and create a new project with the App template. Please make sure you select SwiftUI for the Interface option. Name the project SwiftUIGridLayout or whatever name you prefer.

Figure 2. Creating a new project using the App template
Figure 2. Creating a new project using the App template

Once the project is created, choose ContentView.swift. In ContentView, declare the following variables:

private var symbols = ["keyboard", "hifispeaker.fill", "printer.fill", "tv.fill", "desktopcomputer", "headphones", "tv.music.note", "mic", "plus.bubble", "video"]

private var colors: [Color] = [.yellow, .purple, .green]

private var gridItemLayout = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]

We are going to display a set of SF symbols in a 3-column grid. To present the grid, update the body variable like this:

var body: some View {
    ScrollView {
        LazyVGrid(columns: gridItemLayout, spacing: 20) {
            ForEach((0...9999), id: \.self) {
                Image(systemName: symbols[$0 % symbols.count])
                    .font(.system(size: 30))
                    .frame(width: 50, height: 50)
                    .background(colors[$0 % colors.count])
                    .cornerRadius(10)
            }
        }
    }
}

We use LazyVGrid and tell the vertical grid to use a 3-column layout. We also specify that there is a 20 point space between rows. In the code block, we have a ForEach loop to present a total of 10,000 image views. If you've made the change correctly, you should see a three column grid in the preview.

Figure 3. Displaying a 3-column grid
Figure 3. Displaying a 3-column grid

This is how we can create a vertical grid with three columns. Now the frame size of the image is fixed to 50 by 50 points, which is controlled by the .frame modifier. In case if you want to make a grid item wider, you can alter the frame modifier like this:

.frame(minWidth: 0, maxWidth: .infinity, minHeight: 50)

The image's width will expand to take up the column's width like that shown in figure 4.

Figure 4. Changing the frame size of the grid items
Figure 4. Changing the frame size of the grid items

Note that there is a space between the columns and rows. Sometimes, you may want to create a grid without any spaces. So, how can you achieve that? The space between rows is controlled by the spacing parameter of LazyVGrid. Meanwhile, we set its value to 20 points. You can simply change it to 0 such that there is no space between rows.

The spacing between grid items is controlled by the instances of GridItem initialized in gridItemLayout. You can set the spacing between items by passing a value to the spacing parameter. Therefore, to remove the spacing between rows, you can initialize the gridLayout variable like this:

private var gridItemLayout = [GridItem(.flexible(), spacing: 0), GridItem(.flexible(), spacing: 0), GridItem(.flexible(), spacing: 0)]

For each GridItem, we specify to use a spacing of zero. For simplicity, the code above can be rewritten like this:

private var gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 0), count: 3)

If you've made both changes, your preview canvas should show you a grid view without any spacing.

Figure 5. Removing the spacing between columns and rows
Figure 5. Removing the spacing between columns and rows

Using GridItem to Vary the Grid Layout (Flexible/Fixed/Adaptive)

Let's take a further look at GridItem. You use GridItem instances to configure the layout of items in LazyHGrid and LazyVGrid views. Earlier, we define an array of three GridItem instances and each of which uses the size type .flexible(). The flexible size type enables you to create three columns with equal size. If you want to describe a 6-column grid, you can create the array of GridItem like this:

private var sixColumnGrid: [GridItem] = Array(repeating: .init(.flexible()), count: 6)

.flexible() is just one of the size types for controlling the grid layout. If you want to place as many items as possible in a row, you can use the adaptive size type:

private var gridItemLayout = [GridItem(.adaptive(minimum: 50))]

The adaptive size type requires you to specify the minimize size of a grid item. In the code above, each grid item has a minimum size of 50. If you modify the gridItemLayout variable like above and set the spacing of LazyVGrid back to 20, you should achieve a grid layout similar to the one shown in figure 6.

Figure 6. Using adaptive size to create the grid
Figure 6. Using adaptive size to create the grid

By using .adaptive(minimum: 50), this instructs LazyVGrid to fill as many images as possible in a row such that each item has a minimum size of 50 points.

Note: For me, I used iPhone 11 Pro as the simulator. If you use other iOS simulators with different screen sizes, you may achieve a different result.

On top of .flexible and .adaptive, you can also use .fixed if you want to create fixed width columns. For example, you want to layout the image in two columns such that the first column has a width of 100 points and the second one has a width of 150 points. You write the code like this:

private var gridItemLayout = [GridItem(.fixed(100)), GridItem(.fixed(150))]

Again, if you update the gridItemLayout variable like above, this will result a two-column grid with different size.

Figure 7. A grid with fixed-size items
Figure 7. A grid with fixed-size items

You are allowed to mix different size types to create more complex grid layout. For example, you can define a fixed size GridItem, followed by a GridItem with adaptive size like this:

private var gridItemLayout = [GridItem(.fixed(150)), GridItem(.adaptive(minimum: 50))]

In this case, LazyVGrid creates a fixed size column with 100 point width. And then, it tries to fill as many items as possible for the remaining space.

Figure 8. Mixing a fixed-size item with adaptive size items
Figure 8. Mixing a fixed-size item with adaptive size items

Using LazyHGrid to Create Horizontal Grids

Now that you've created a vertical grid, LazyHGrid has made it so easy to convert a vertical grid to a horizontal one. The usage of horizontal grid is nearly the same as LazyVGrid except that you embed it in a horizontal scroll view. Furthermore, LazyHGrid takes in a parameter named rows instead of columns.

Therefore, you can rewrite a couple lines of code to transform a grid view from vertical orientation to horizontal:

ScrollView(.horizontal) {
    LazyHGrid(rows: gridItemLayout, spacing: 20) {
        ForEach((0...9999), id: \.self) {
            Image(systemName: symbols[$0 % symbols.count])
                .font(.system(size: 30))
                .frame(minWidth: 0, maxWidth: .infinity, minHeight: 50, maxHeight: .infinity)
                .background(colors[$0 % colors.count])
                .cornerRadius(10)
        }
    }
}

Run the demo in the preview or test it on a simulator. You should see a horizontal grid.

Figure 9. Creating a horizontal grid with LazyHGrid
Figure 9. Creating a horizontal grid with LazyHGrid

Switching Between Different Grid Layouts

Now that you should have some experience with LazyVGrid and LazyHGrid, let's create something more complicated. Imagine you are going to build a photo app that displays a collection of coffee photos. In the app, it provides a feature for users to change the layout. By default, it shows the list of photos in a single column. The user can tap a Grid button to change the list view to a grid view with 2 columns. Tapping the same button again to a 3-column layout, followed by a 4-column layout.

Figure 10. Creating a horizontal grid with LazyHGrid
Figure 10. Creating a horizontal grid with LazyHGrid

Let's create a new project for this demo app. Again, choose the App template and name the project SwiftUIPhotoGrid. Next, download this image pack at https://www.appcoda.com/resources/swiftui/coffeeimages.zip. Unzip the images and add them to the asset catalog.

Before creating the grid view, we will create the data model for the collection of photos. In the project navigator, right click SwiftUIGridView and choose New file... to create a new file. Select the Swift File template and name the file Photo.swift.

Insert the following code in the Photo.swift file to create the Photo struct:

struct Photo: Identifiable {
    var id = UUID()
    var name: String
}

let samplePhotos = (1...20).map { Photo(name: "coffee-\($0)") }

We have 20 coffee photos in the image pack, so we initialize an array of 20 Photo instances. With the data model ready, let's switch over to ContentView.swift to build the grid.

First, declare a gridLayout variable to define our preferred grid layout:

@State var gridLayout: [GridItem] = [ GridItem() ]

By default, we want to display a list view. Other than using List, you can actually use LazyVGrid to build a list view. This is why we define the gridLayout with one grid item. By telling LazyVGrid to use a single column grid layout, it will arrange the items like a list view. Insert the following code in body to create the grid view:

NavigationView {
    ScrollView {
        LazyVGrid(columns: gridLayout, alignment: .center, spacing: 10) {

            ForEach(samplePhotos.indices) { index in

                Image(samplePhotos[index].name)
                    .resizable()
                    .scaledToFill()
                    .frame(minWidth: 0, maxWidth: .infinity)
                    .frame(height: 200)
                    .cornerRadius(10)
                    .shadow(color: Color.primary.opacity(0.3), radius: 1)

            }
        }
        .padding(.all, 10)
    }

    .navigationTitle("Coffee Feed")
}

We use LazyVGrid to create a vertical grid with a spacing of 10 points between rows. The grid is used to display coffee photos, so we use ForEach to loop through the samplePhotos array. On top of these, we embed the grid in a scroll view to make it scrollable and wrap it with a navigation view. Once you made the change, you should see a list of photos in the preview canvas.

Figure 11. Creating a list view with LazyVGrid
Figure 11. Creating a list view with LazyVGrid

Now we need to a button for users to switch between different layouts. We will add the button to the navigation bar. In iOS 14, Apple introduced a new modifier called .toolbar for you to populate items to the navigation bar. Right after .navigationTitle, insert the following code to create the bar button:

.toolbar {
    ToolbarItem(placement: .navigationBarTrailing) {
        Button(action: {
            self.gridLayout = Array(repeating: .init(.flexible()), count: self.gridLayout.count % 4 + 1)
        }) {
            Image(systemName: "square.grid.2x2")
                .font(.title)
                .foregroundColor(.primary)
        }
    }
}

In the code above, we update the gridLayout variable and initialize the array of GridItem. Say, the current item count is one, we will create an array of two GridItems to change to a 2-column grid. Since we've marked gridLayout as a state variable, SwiftUI will render the grid view every time we update the variable.

Figure 12. Adding a bar button for switching the grid layout
Figure 12. Adding a bar button for switching the grid layout

You can run the app to have a quick test. Tapping the grid button will now switch to another grid layout. There are couple of things we want to improve. First, the height of the grid item should be adjusted to 100 points for grids with two or more columns. Update the .frame modifier with the height parameter like this:

.frame(height: gridLayout.count == 1 ? 200 : 100)

Right now, when you switch from one grid layout to another, SwiftUI simply redraw the grid view without any animation. Wouldn't it be great if we add a nice transition between layout changes? To do that, you just need to add a line of code. Insert the following code after .padding(.all, 10):

.animation(.interactiveSpring())

This is the power of SwiftUI. By telling SwiftUI that you want to animate changes, the framework handles the rest and you will see a nice transition between the layout changes.

Figure 13. SwiftUI automatically animates the transition
Figure 13. SwiftUI automatically animates the transition

Building Grid Layout with Multiple Grids

You are not limited to use a single LazyVGrid or LazyHGrid in your app. By combining more than one LazyVGrid, you will be able to build some interesting layouts. Take a look at figure 14. We are going to look into the implementation of this kind of grid layout. The grid displays a list of cafe photos. Under each cafe photo, it shows a list of coffee photos. When the device is in landscape orientation, the cafe photo and the list of coffee photos will be arranged side by side.

Figure 14. Building complex grid layout with two grids
Figure 14. Building complex grid layout with two grids

Now go back to the Xcode project. Again, let's create the data model first. In the image pack you downloaded earlier, it comes a set of cafe photos. So, create a new Swift file and name it Cafe.swift. In the file, insert the following code:

struct Cafe: Identifiable {
    var id = UUID()
    var image: String
    var coffeePhotos: [Photo] = []
}

let sampleCafes: [Cafe] = {

    var cafes = (1...18).map { Cafe(image: "cafe-\($0)") }

    for index in cafes.indices {
        let randomNumber = Int.random(in: (2...12))
        cafes[index].coffeePhotos = (1...randomNumber).map { Photo(name: "coffee-\($0)") }
    }

    return cafes
}()

The Cafe struct is self explanatory that it has an image property for storing the cafe photo and the coffeePhotos property for storing the list of coffee photos. In the code above, we also create an array of Cafe for demo purpose. For each cafe, we randomly pick some coffee photos. Please feel free to modify the code if you have other images for testing.

Instead of modifying the ContentView.swift file, let's create a new file for implementing this grid view. Right click SwiftUIPhotoGrid and choose New File.... Select the SwiftUI View template and name the file MultiGridView.

Similarly to the earlier implementation, let's declare a gridLayout variable to store the current grid layout:

@State var gridLayout = [ GridItem() ]

By default, it is initialized to have one GridItem. Next, insert the following code in body to create a vertical grid with a single column:

NavigationView {
    ScrollView {
        LazyVGrid(columns: gridLayout, alignment: .center, spacing: 10) {

            ForEach(sampleCafes) { cafe in
                Image(cafe.image)
                    .resizable()
                    .scaledToFill()
                    .frame(minWidth: 0, maxWidth: .infinity)
                    .frame(maxHeight: 150)
                    .cornerRadius(10)
                    .shadow(color: Color.primary.opacity(0.3), radius: 1)
            }

        }
        .padding(.all, 10)
        .animation(.interactiveSpring())
    }
    .navigationTitle("Coffee Feed")
}

I don't think we need to go through the code again because it's almost the same as the one we wrote earlier. If your code works properly, you should see a list view that shows the collection of cafe photos.

Figure 15. A list of cafe photos
Figure 15. A list of cafe photos

Adding an Additional Grid

So, how can we display another grid under each of the cafe photo? All you need to do is to have another LazyVGrid inside the ForEach loop. Insert the following code after the Image view of the loop:

LazyVGrid(columns: [GridItem(.adaptive(minimum: 50))]) {
    ForEach(cafe.coffeePhotos) { photo in
        Image(photo.name)
            .resizable()
            .scaledToFill()
            .frame(minWidth: 0, maxWidth: .infinity)
            .frame(height: 50)
            .cornerRadius(10)
    }
}
.frame(minHeight: 0, maxHeight: .infinity, alignment: .top)
.animation(.easeIn)

Here we create another vertical grid for the coffee photos. By using the adaptive size type, this grid will fill as many photos as possible in a row. Once you made the code change, the app UI will look like that shown in figure 16.

Figure 16. Adding another grid for the coffee photos
Figure 16. Adding another grid for the coffee photos

If you prefer to arrange the cafe and coffee photos side by side, you can modify the gridLayout variable like this:

@State var gridLayout = [ GridItem(.adaptive(minimum: 100)), GridItem(.flexible()) ]

As soon as you change the gridLayout variable, your preview will be updated to display the cafe and coffee photos side by side.

Figure 17. Arrange the cafe and coffee photos side by side
Figure 17. Arrange the cafe and coffee photos side by side

Handling Landscape Orientation

To test the app in landscape orientation, you need to run it on a simulator. The preview canvas doesn't allow you to rotate the device yet.

Before you run the app, you will need to perform a simple modification in SwiftUIPhotoGridApp.swift. Since we have created a new file for implementing this multi-grid, modify the view in WindowGroup from ContentView() to MultiGridView() like below:

struct SwiftUIPhotoGridApp: App {
    var body: some Scene {
        WindowGroup {
            MultiGridView()
        }
    }
}

Now you're ready to run the app in an iPhone simulator. It works great in the portrait orientation just like you see previously in the preview canvas. However, if you rotate it sideway by pressing command-left (or right), the grid layout doesn't look as expected. What we expect is that it should look pretty much the same as that in portrait mode.

Figure 18. The app UI in landscape mode
Figure 18. The app UI in landscape mode

To fix the issue, we can adjust the minimum width of the adaptive grid item and make it a bit wider when the device is in landscape orientation. The question is how can you detect the orientation changes?

In SwiftUI, every view comes with a set of environment variables. You can find out the current device orientation by accessing both the horizontal and vertical size class variables like this:

@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
@Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass?

The @Environment property wrapper allows you to access the required environment values. In the code above, it tells SwiftUI that we want to read both the horizontal and vertical size classes, and subscribe on their changes. In other words, we will be notified whenever the device's orientation changes.

If you haven't done so, please make sure you insert the code above in MultiGridView.

The next question is how can we capture the notification and respond to the changes? In iOS 14, Apple introduced a new modifier called .onChange(). You can attach this modifier to any view to monitor any state changes. In this case, we can attach the modifier to NavigationView like this:

.onChange(of: verticalSizeClass) { value in
    self.gridLayout = [ GridItem(.adaptive(minimum:  verticalSizeClass == .compact  ? 250 : 100)), GridItem(.flexible()) ]
}

We monitor the change of both horizontalSizeClass and verticalSizeClass variables. Whenever there is a change, we will update the gridLayout variable with a new grid configuration. For iPhone, it has a compact height in landscape orientation. Therefore, if the value of verticalSizeClass equals .compact, we alter the minimum size of the grid item to 250 points.

Now run the app on an iPhone simulator again. When you turn the device sideway, it now shows the cafe photo and coffee photos side by side.

Figure 19. The app UI in landscape mode now looks better
Figure 19. The app UI in landscape mode now looks better

Understanding Navigation View Style

Earlier, I used the iPhone 11 Pro as the simulator. The app works perfectly in both portrait and landscape mode. But when you run the app on iPhone Max models, it looks a bit weird in landscape orientation. iOS automatically turns the view into a primary detail split view. You can only access the grid view by tapping a navigation button at the top-left corner of the screen.

Figure 20. Split view on iPhone 11 Max
Figure 20. Split view on iPhone 11 Max

This is a default behaviour of NavigationView on large devices like iPhone 11 Pro Max. To solve the issue and disable the split-view behaviour on iPhone Max models, you can attach the following modifier to the navigation view:

.navigationViewStyle(StackNavigationViewStyle())

By using the .navigationViewStyle modifier, we explicitly instruct NavigationView to use the stack navigation view style, regardless of the screen size. Now test the app again on iPhone 11 Max Pro. The app UI should look well even in landscape orientation just like that shown in figure 19.

Exercise

I have a couple of exercises for you. First, the app UI doesn't look good on iPad. Please modify the code and fix the issue such that the it only shows two columns: one for the cafe photo and the other for the coffee photos.

Figure 21. App UI on iPad
Figure 21. App UI on iPad

The next exercise is more complicated with a number of requirements:

  1. Different default grid layout for iPhone and iPad - When the app is first loaded up, it displays a single column grid for iPhone in portrait mode. For iPad and iPhone landscape, the app shows the cafe photos in a 2-column grid.
  2. Show/hide button for the coffee photos - Add a new button in the navigation bar for toggling the display of coffee photos. By default, the app only shows the list of cafe photos. When this button is tapped, it shows the coffee photo grid.
  3. Another button for switching grid layout - Add another bar button for toggling the grid layout between one and two columns.
Figure 22. Enhancing the app to support both iPhone and iPad
Figure 22. Enhancing the app to support both iPhone and iPad

To help you better understand the final deliverable looks like, please check out this video demo at https://link.appcoda.com/multigrid-demo.

Summary

The missing collection view in the first release of SwiftUI is now here. The introduction of LazyVGrid and LazyHGrid in SwiftUI lets developers create different types of grid layout with a few lines of code. This tutorial is just a quick overview of these two new UI components. I encourage to try out different configurations of GridItem and see what grid layout you can achieve.

For reference, you can download the complete project and find the solution to the exercise here:

results matching ""

    No results matching ""