Chapter 2: Going Beyond the Single Component with Lists and Scroll Views
In this chapter, we'll learn how to display lists in SwiftUI. List
views are similar to UITableViews
in UIKit but are significantly simpler to use. No storyboards or prototype cells are required, and we do not need to know how many rows there are. SwiftUI's lists are designed to be modular so that you can build bigger things from smaller components.
We'll also look at lazy stacks and lazy grids, which are used to optimize the display of large amounts of data by loading only the subset of the content that is currently being displayed or is about to be displayed. Lastly, we'll take a look at how to present hierarchical data in an expanding list with sections that can be expanded or collapsed.
By the end of this chapter, you will understand how to display lists of static or dynamic items, add or remove rows from lists, edit lists, add sections to list views, and much more.
In this chapter, we will cover the following recipes:
- Using scroll views
- Creating a list of static items
- Using custom rows in a list
- Adding rows to a list
- Deleting rows from a list
- Editing a list
- Moving rows in a list
- Adding sections to a list
- Using
LazyHStack
andLazyVStack
(iOS 14+) - Using
LazyHGrid
andLazyVGrid
(iOS 14+) - Using
ScrollViewReader
(iOS 14+) - Using expanding lists (iOS 14+)
- Using
DisclosureGroup
to hide and show content (iOS 14+)
Technical requirements
The code in this chapter is based on Xcode 12 and iOS 13. However, some recipes are only compatible with iOS 14 and higher.
You can find the code in the book's GitHub repository at https://github.com/PacktPublishing/SwiftUI-Cookbook/tree/master/Chapter02%20-%20Lists%20and%20ScrollViews.
Using scroll views
SwiftUI scroll views are used to easily create scrolling containers. They automatically size themselves to the area where they are placed. Scroll views are vertical by default and can be made to scroll horizontally or vertically by passing in the .horizontal()
or .vertical()
modifiers as the first parameter to the scroll view.
Getting ready
Let's start by creating a SwiftUI project called ScrollViewApp
.
Optional: Download the San Francisco (SF) Symbols app here: https://developer.apple.com/sf-symbols/.
SF Symbols is a set of over 2,400 symbols provided by Apple. They follow Apple's San Francisco system font and automatically ensure optical vertical alignment for different sizes and weights.
How to do it…
We will add two scroll views to a VStack
component: one horizontal and one vertical. Each scroll view will contain SF symbols for the letters A–L:
- Between the
ContentView
struct declaration and its body, create an array of SF symbol names calledimageNames
. Add the strings for SF symbols A–L:let imageNames = [ "a.circle.fill", "b.circle.fill", "c.circle.fill", "d.circle.fill", "e.circle.fill", "f.circle.fill", "g.circle.fill", "h.circle.fill", "i.circle.fill", "j.circle.fill", "k.circle.fill", "l.circle.fill", ]
- Add a
VStack
component and scroll views to the app:var body: some View { VStack{ ScrollView { ForEach(self.imageNames, id: \.self) { name in Image(systemName: name) .font(.largeTitle) .foregroundColor(Color. yellow) .frame(width: 50, height: 50) .background(Color.blue) } } .frame(width:50, height:200) ScrollView(.horizontal, showsIndicators: false) { HStack{ ForEach(self.imageNames, id: \.self) { name in Image(systemName: name) .font(.largeTitle) .foregroundColor(Color. yellow) .frame(width: 50, height: 50) .background(Color.blue) } } } } }
The result is a view with a vertical and horizontal scroll view:

Figure 2.1 – App with horizontal and vertical scroll views
How it works…
VStack
allows us to display multiple scroll views within the ContentView
struct's body.
By default, scroll views display items vertically. The first ScrollView
component in VStack
displays items in a vertical way, even though no axis was specified.
Within the first ScrollView
component, a ForEach
loop is used to iterate over a static array and display the contents. In this case, the ForEach
loop takes two parameters: the array we are iterating over and an identifier, id: \.self
, used to distinguish between the items being displayed. The id
parameter would not be required if the collection used conformed to the Identifiable
protocol.
Two parameters are passed to the second ScrollView
component: the axis and showIndicators
(ScrollView(.horizontal, showsIndicators: false)
. The .horizontal
axis parameter causes content to be horizontal, and showIndictors:false
prevents the scrollbar indicator from appearing in the view.
See also
Refer to the Apple ScrollView
documentation at https://developer.apple.com/documentation/swiftui/scrollview.
Creating a list of static items
Lists are similar to scroll views in that they are used to view a collection of items. Lists are used for larger datasets, whereas scroll views are used for smaller datasets; the reason being that list views do not load the whole dataset in memory at once and thus are more efficient at handling large data.
Getting ready
Let's start by creating a new SwiftUI app called StaticList
.
How to do it…
We'll create a struct to hold weather information and an array of weather data. The data will then be used to create a view that provides weather information from several cities. Proceed as follows:
- Before the
ContentView
struct, create a struct calledWeatherInfo
with four properties:id
,image
,temp
, andcity
:struct WeatherInfo: Identifiable { var id = UUID() var image: String var temp: Int var city: String }
- Within the
ContentView
struct, create aweatherData
property as an array ofWeatherInfo
:let weatherData: [WeatherInfo] = [ WeatherInfo(image: "snow", temp: 5, city:"New York"), WeatherInfo(image: "cloud", temp:5, city:"Kansas City"), WeatherInfo(image: "sun.max", temp: 80, city:"San Francisco"), WeatherInfo(image: "snow", temp: 5, city:"Chicago"), WeatherInfo(image: "cloud.rain", temp: 49, city:"Washington DC"), WeatherInfo(image: "cloud.heavyrain", temp: 60, city:"Seattle"), WeatherInfo(image: "sun.min", temp: 75, city:"Baltimore"), WeatherInfo(image: "sun.dust", temp: 65, city:"Austin"), WeatherInfo(image: "sunset", temp: 78, city:"Houston"), WeatherInfo(image: "moon", temp: 80, city:"Boston"), WeatherInfo(image: "moon.circle", temp: 45, city:"denver"), WeatherInfo(image: "cloud.snow", temp: 8, city:"Philadelphia"), WeatherInfo(image: "cloud.hail", temp: 5, city:"Memphis"), WeatherInfo(image: "cloud.sleet", temp:5, city:"Nashville"), WeatherInfo(image: "sun.max", temp: 80, city:"San Francisco"), WeatherInfo(image: "cloud.sun", temp: 5, city:"Atlanta"), WeatherInfo(image: "wind", temp: 88, city:"Las Vegas"), WeatherInfo(image: "cloud.rain", temp: 60, city:"Phoenix"), ]
- Add the
List
view to theContentView
body. The list should display the information contained in ourweatherData
array. Also add thefont(size: 25)
andpadding()
modifiers:List { ForEach(self.weatherData){ weather in HStack { Image(systemName: weather.image) .frame(width: 50, alignment: .leading) Text("\(weather.temp)°F") .frame(width: 80, alignment: .leading) Text(weather.city) } .font(.system(size: 25)) .padding() } }
The resulting preview should be as follows:

Figure 2.2 – List in the weather app
How it works…
We started the recipe by creating a WeatherInfo
struct to hold and model our weather data. The id = UUID()
property creates a unique identifier for each variable we create from the struct. Adding the id
property to our struct allows us to later use it within a ForEach
loop without providing id: .\self
parameter as seen in the previous recipe.
The ForEach
loop within the List
view iterates through the items in our model data and copies one item at a time to the weather variable declared on the same line.
The ForEach
loop contains HStack
because we would like to display multiple items on the same line – in this case, all the information contained in the weather
variable.
The font(.system(size: 25))
and padding()
modifiers add some padding to the list rows to improve the design and readability of the information.
The images displayed are based on SF Symbols, explained in Chapter 1, Using the Basic SwiftUI Views and Controls.
Using custom rows in a list
The number of lines of code required to display items in a list view row could vary from one to several lines of code. A custom list row is used when working with several lines of code within a list view row. Implementing custom lists improves modularity and readability, and allows code reuse.
Getting ready
Let's start by creating a new SwiftUI app called CustomRows
.
How to do it…
We will reuse part of the code in the static list and clean it up to make it more modular. We create a separate file to hold the WeatherInfo
struct, a separate SwiftUI file for the custom WeatherRow
, and finally, we implement the components in the ContentView.swift
file. The steps are as follows:
- Create a new Swift file called
WeatherInfo
by selecting File | New | File | Swift File or by pressing the ⌘ + N keys. - Define the
WeatherInfo
struct within theWeatherInfo.swift
file:struct WeatherInfo: Identifiable { var id = UUID() var image: String var temp: Int var city: String }
- Create a
weatherData
variable that holds an array ofWeatherInfo
:let weatherData: [WeatherInfo] = [ WeatherInfo(image: "snow", temp: 5, city:"New York"), WeatherInfo(image: "cloud", temp:5, city:"Kansas City"), WeatherInfo(image: "sun.max", temp: 80, city:"San Francisco"), WeatherInfo(image: "snow", temp: 5, city:"Chicago"), WeatherInfo(image: "cloud.rain", temp: 49, city:"Washington DC"), WeatherInfo(image: "cloud.heavyrain", temp: 60, city:"Seattle"), WeatherInfo(image: "sun.min", temp: 75, city:"Baltimore"), WeatherInfo(image: "sun.dust", temp: 65, city:"Austin"), WeatherInfo(image: "sunset", temp: 78, city:"Houston"), WeatherInfo(image: "moon", temp: 80, city:"Boston"), WeatherInfo(image: "moon.circle", temp: 45, city:"denver"), WeatherInfo(image: "cloud.snow", temp: 8, city:"Philadelphia"), WeatherInfo(image: "cloud.hail", temp: 5, city:"Memphis"), WeatherInfo(image: "cloud.sleet", temp:5, city:"Nashville"), WeatherInfo(image: "sun.max", temp: 80, city:"San Francisco"), WeatherInfo(image: "cloud.sun", temp: 5, city:"Atlanta"), WeatherInfo(image: "wind", temp: 88, city:"Las Vegas"), WeatherInfo(image: "cloud.rain", temp: 60, city:"Phoenix"), ]
- Create a new SwiftUI file by selecting File | New | File | SwiftUI View or by pressing the ⌘ + N keys.
- Name the file
WeatherRow
. - Add the following code to design the look and functionality of the weather row:
struct WeatherRow: View { var weather: WeatherInfo var body: some View { HStack { Image(systemName: weather.image) .frame(width: 50, alignment: .leading) Text("\(weather.temp)°F") .frame(width: 80, alignment: .leading) Text(weather.city) } .font(.system(size: 25)) .padding() } }
- Add the following code to the
WeatherRow_Previews
struct to display information from a sampleWeatherInfo
struct instance:static var previews: some View { WeatherRow(weather: WeatherInfo(image: "snow", temp: 5, city:"New York")) }
- The resulting
WeatherRow.swift
canvas preview should look as follows:Figure 2.3 – WeatherRow preview
- Open the
ContentView.swift
file and create a list to display data using the customWeatherRow
component:var body: some View { List { ForEach(weatherData){ weather in WeatherRow(weather: weather) } } }
The resulting canvas preview should look as follows:

Figure 2.4 – CustomRowApp preview
Run the app live preview and admire the work of your own hands.
How it works…
The WeatherInfo
Swift file contains the description of the WeatherInfo
struct. Our dataset, an array of WeatherInfo
variables, was also declared in the file to make it available to other sections of the project.
The WeatherRow
SwiftUI file contains the design we will use for each weather row. The weather property within WeatherRow
will hold the WeatherInfo
arguments passed to the view. HStack
in the body of WeatherRow
is used to display data from weather variables since a SwiftUI view can only return one view at a time. When displaying multiple views – an image view and two text views, in this case – the views are all encased in an HStack
component. The .frame(width: 50, alignment: .leading)
modifier added to the image and first text view sets the width used by the element it modifies to 50 units and the alignment to the .leading
parameter.
Finally, the .font(.system(size: 25))
and .padding()
modifiers are added to HStack
to increase the text and image font sizes and add padding to all sides of WeatherRow
.
Adding rows to a list
Lists are usually used to add, edit, remove, or display content from an existing dataset. In this section, we will go over the process of adding items to an already existing list.
Getting ready
Let's start by creating a new SwiftUI app called AddRowsToList
.
How to do it…
To implement the add functionality, we will enclose the List
view in NavigationView
, and add a button to navigationBarItems
that triggers the add function we will create. The steps are as follows:
- Create a state variable in the
ContentView
struct that holds an array of integers:@State var numbers = [1,2,3,4]
- Add a
NavigationView
component containing aList
view to theContentView
body:NavigationView{ List{ ForEach(self.numbers, id:\.self){ number in Text("\(number)") } } }
- Add a
navigationBarItems
modifier to the list closing brace that contains a button that triggers theaddItemToRow()
function:.navigationBarItems(trailing: Button(action: { self.addItemToRow() }){ Text("Add") })
- Implement the
addItemToRow()
function, which appends a random integer to the numbers array. Place the function within theContentView
struct, immediately after the body variable's closing brace:private func addItemToRow() { self.numbers.append(Int.random(in: 0 ..< 100)) }
- For the beauty and aesthetics, add a
navigationBarTitle
modifier to the end of the list so as to make it display a title at the top of the list:.navigationBarTitle("Number List", displayMode: .inline)
- The resulting code should be as follows:
struct ContentView: View { @State var numbers = [1,2,3,4] var body: some View { NavigationView{ List{ ForEach(self.numbers, id:\.self){ number in Text("\(number)") } }.navigationBarTitle("Number List", displayMode: .inline) .navigationBarItems(trailing: Button("Add", action: addItemToRow)) } } private func addItemToRow() { self.numbers.append(Int.random(in: 0 ..< 100)) } }
The resulting preview should be as follows:

Figure 2.5 – AddRowToList preview
Run the app live preview and admire the work of your own hands!
How it works…
The array of numbers is declared as a @State
variable because we want the view to be refreshed each time the value of the items in the array changes – in this case, each time we add an item to the numbers array.
The .navigationBarTitle("Number List", displayMode: .inline)
modifier adds a title to the list using the .inline display mode
parameter.
The .navigationBarItems(trailing: Button(…)…)
modifier adds a button to the trailing end of the display, which triggers the addItemToRow
function when clicked.
The addItemToRow
function generates a random number in the range 0–99 and appends it to the numbers array.
Deleting rows from a list
In this section, we will display a list of countries and use a swipe motion to delete items from the list one at a time.
Getting ready
Let's start by creating a SwiftUI app called DeleteRowFromList
.
How to do it…
We use the List
view's .onDelete(perform: …)
modifier to implement list deletion. The process is as follows:
- Add a state variable to the
ContentView
struct calledcountries
. The variable should contain an array of countries:@State var countries = ["USA", "Canada", "England","Cameroon", "South Africa", "Mexico" , "Japan", "South Korea"]
- Create a
List
view in theContentView
body that uses aForEach
loop to display the contents of thecountries
array:List { ForEach(countries, id: \.self) { country in Text(country) } }
- Add a
.onDelete(perform: self.deleteItem)
modifier to theForEach
loop. - Implement the
deleteItem()
function. The function should be placed below the body variable's closing brace:private func deleteItem(at indexSet: IndexSet){ self.countries.remove(atOffsets: indexSet) }
- Optionally, enclose the
List
view in a navigation view and add a.navigationBarTitle("Countries", displayMode: .inline)
modifier to the list. The resultingContentView
struct should be as follows:struct ContentView: View { @State var countries = ["USA", "Canada", "England","Cameroon", "South Africa", "Mexico" , "Japan", "South Korea"] var body: some View { NavigationView{ List { ForEach(countries, id: \.self) { country in Text(country) } .onDelete(perform: self.deleteItem) } .navigationBarTitle("Countries", displayMode: .inline) } } private func deleteItem(at indexSet: IndexSet){ self.countries.remove(atOffsets: indexSet) }
Run the canvas review by clicking the play button next to the canvas preview. A swipe from right to left on a row causes a Delete button to appear. Click on the button to delete the row:

Figure 2.6 – DeleteRowFromList preview execution
Run the app live preview and admire the work of your own hands!
How it works…
Navigation views and list views were discussed earlier. Only the .onDelete(…)
modifier is new. The .onDelete(perform: self.deleteItem)
modifier triggers the deleteItem()
function when the user swipes from right to left.
The deleteItem(at indexSet: IndexSet)
function takes a single parameter, IndexSet
, which represents the index of the row to be removed/deleted. The .onDelete()
modifier automatically knows to pass the IndexSet
parameter to deleteItem(…)
.
There's more…
Deleting an item from a list view can also be performed by embedding the list in a navigation view and adding an EditButton
component.
Editing a list
Implementing the EditButton
component in a list is very similar to implementing the delete button in the previous recipe. An edit button offers the user the option to quickly delete items by clicking a minus sign to the left of each list row.
Getting ready
Let's start by creating a SwiftUI app called EditListApp
.
How to do it…
We will reuse some of the code from the previous recipe to complete this project. Take the following steps:
- Replace the
EditListApp
app'sContentView
struct with the following content from theDeleteRowFromList
app:struct ContentView: View { @State var countries = ["USA", "Canada", "England","Cameroon", "South Africa", "Mexico" , "Japan", "South Korea"] var body: some View { NavigationView{ List { ForEach(countries, id: \.self) { country in Text(country) } .onDelete(perform: self.deleteItem) } .navigationBarTitle("Countries", displayMode: .inline) } } private func deleteItem(at indexSet: IndexSet){ self.countries.remove(atOffsets: indexSet) } }
- Add a
.navigationBarItems(trailing: EditButton())
modifier to theList
view, just below the.navigationBarTitle("Countries", displayMode:.inline)
modifier.Run the preview and click the Edit button at the top-right corner of the screen. Minus (-) signs enclosed in red circles appear to the left of each row:

Figure 2.7 – EditListApp preview execution
Run the app live preview and admire the work of your own hands!
How it works…
The .navigationBarItems(trailing: EditButton())
modifier adds an edit button to the right corner of the display that, once clicked, creates the view shown in the preceding screenshot. A click on the delete button executes the .onDelete(perform: self.deleteItem)
modifier. The modifier then executes the deleteItem
function, which removes/deletes the selected row.
There's more…
To move the edit button to the right of the navigation bar, change the modifier to .navigationBarItems(leading: EditButton())
.
Moving rows in a list
In this section, we will create an app that implements a list view and allows the user to move/reorganize rows.
Getting ready
Let's start by creating a new SwiftUI app in Xcode called MovingListRows
.
How to do it…
To allow users to move rows, we need to add a .onMove(..)
modifier to the end of the list view's ForEach
loop. We also need to embed the list in a navigation view and add a navigationBarItems
modifier that implements an EditButton
component. The steps are as follows:
- Open
ContentView.swift
. Within theContentView
struct, add a@State
variable inContent
calledcountries
that contains an array of countries:@State var countries = ["USA", "Canada", "England","Cameroon", "South Africa", "Mexico" , "Japan", "South Korea"]
- Replace the
Text
view in the body with a navigation view:NavigationView{ }
- Add a list and a
ForEach
loop withinNavigationView
that displays the content of thecountries
variable:List { ForEach(countries, id: \.self) { country in Text(country) }.onMove(perform: moveRow) }
- Add a
.onMove(…)
modifier to theForEach
loop that calls themoveRow
function:ForEach(countries, id: \.self) { country in Text(country) }.onMove(perform: moveRow)
- Add the
.navigationBarTitle("Countries", displayMode: .inline)
and.navigationBarItems(trailing: EditButton())
modifiers to the end of theList
view:List { . . . } .navigationBarTitle("Countries", isplayMode: .inline) .navigationBarItems(trailing: EditButton())
- Implement the
moveRow
function at the end of the body variable's closing brace:private func moveRow(source: IndexSet, destination: Int){ countries.move(fromOffsets: source, toOffset: destination) }
- The completed
ContentView
struct should be as follows:struct ContentView: View { @State var countries = ["USA", "Canada", "England","Cameroon", "South Africa", "Mexico" , "Japan", "South Korea"] var body: some View { NavigationView{ List { ForEach(countries, id: \.self) { country in Text(country) } .onMove(perform: moveRow) } .navigationBarTitle("Countries", displayMode: .inline) .navigationBarItems(trailing: EditButton()) } } private func moveRow(source: IndexSet, destination: Int){ countries.move(fromOffsets: source, toOffset: destination) } }
Run the application in the canvas, on a simulator, or on a physical device. A click on the Edit button at the top-right corner of the screen displays a hamburger symbol to the right of each row. Click and drag the symbol to move the row on which the country is displayed:

Figure 2.8 – MovingListRows preview when running
Run the app live preview and move the rows up or down. Nice work!
How it works…
To move list rows, you need to add the list to a navigation view, add the .onMove(perform:)
modifier to the ForEach
loop, and add a .navigationBarItems(trailing: EditButton())
modifier to the list.
The moveRow(source: IndexSet, destination: Int)
function takes two parameters: the source, an IndexSet
argument representing the current index of the item to be moved, and the destination, an integer representing the destination of the row. The .onMove(perform:)
modifier automatically passes those arguments to the function.
Adding sections to a list
In this section, we will create an app that implements a static list with sections. The app will display a partial list of countries grouped by continent.
Getting ready
Let's start by creating a new SwiftUI app in Xcode and name it ListWithSections
.
How to do it…
We will add section views to our list view. We will then add a few countries to each of the sections. Proceed as follows:
- (Optional) open the
ContentView.swift
file and replace theText
view in the body with a navigation view. Wrapping the list in a navigation view allows you to add a title and navigation items to the view:NavigationView { }
- Add a list and section to the navigation view:
List { Section(header: Text("North America")){ Text("USA") Text("Canada") Text("Mexico") Text("Panama") Text("Anguilla") } }
- Add a
listStyle(..)
modifier to the end of the list to change its style from the default plain style toGroupedListStyle()
:List { . . . } .listStyle(GroupedListStyle()) .navigationBarTitle("Continents and Countries", displayMode: .inline)
- (Optional) add a
navigationBarTitle(..)
modifier to the list, just below the list style. Add this section if you included the navigation view in step 1. - Add more sections representing various continents to the navigation view. The resulting
ContentView
struct should look as follows:struct ContentView: View { var body: some View { NavigationView{ List { Section(header: Text("North America")){ Text("USA") Text("Canada") Text("Mexico") Text("Panama") Text("Anguilla") } Section(header: Text("Africa")){ Text("Nigeria") Text("Ghana") Text("Kenya") Text("Senegal") } Section(header: Text("Europe")){ Text("Spain") Text("France") Text("Sweden") Text("Finland") Text("UK") } } .listStyle(GroupedListStyle()) .navigationBarTitle("Continents and Countries", displayMode: .inline) } } }
The canvas preview should now show a list with sections, as follows:

Figure 2.9 – ListWithSections preview
Good work! Now, run the app live preview and admire the work of your own hands.
How it works…
SwiftUI's sections are used to separate items into groups. In this recipe, we used sections to visually group countries into different continents. Sections can be used with a header, as follows:
Section(header: Text("Europe")){ Text("Spain") Text("France") Text("Sweden") Text("Finland") Text("UK") }
They can also be used without a header:
Section { Text("USA") Text("Canada") Text("Mexico") Text("Panama") Text("Anguilla") }
When using a section embedded in a list, we can add a .listStyle()
modifier to change the styling of the whole list.
Using LazyHStack and LazyVStack (iOS 14+)
SwiftUI 2.0 introduced the LazyHStack
and LazyVStack
components. These components are used in a similar way to regular HStack
and VStack
components but offer the advantage of lazy loading. Lazy components are loaded just before the item becomes visible on the device's screen during a device scroll, therefore reducing latency.
We will create an app that uses LazyHStack
and LazyVStack
and observe how it works.
Getting ready
Let's create a new SwiftUI app called LazyStacks
:
- Open Xcode and click Create New Project.
- In the Choose template window, select iOS and then App.
- Click Next.
- Enter
LazyStacks
in the Product Name field and select SwiftUI App from the Life Cycle field. - Click Next and select a location on your computer where the project should be stored.
How to do it…
We will implement a LazyHStack
and LazyVStack
view within a single SwiftUI view file by embedding both in a VStack
component. The steps are as follows:
- Click on the
ContentView.swift
file to view its content in Xcode's editor pane. - Let's create a
ListRow
SwiftUI view that will have two properties: an ID and a type.ListRow
should also print a statement showing what item is currently being initialized:struct ListRow: View { let id: Int let type: String init(id: Int, type: String){ print("Loading \(type) item \(id)") self.id = id self.type = type } var body: some View { Text("\(type) \(id)").padding() } }
- Replace the initial
Text
view withVStack
:VStack { }
- Add a horizontal scroll view inside the
VStack
component and use a.frame()
modifier to limit the view's height:ScrollView(.horizontal){ }.frame(height: 100, alignment: .center)
- Add
LazyHStack
inside the scroll view with aForEach
view that iterates through the numbers1
–10000
and displays them using ourListRow
view:LazyHStack { ForEach(1...10000, id:\.self){ item in ListRow(id: item, type: "Horizontal") } }
- Add a second vertical scroll view to
VStack
with aLazyVStack
struct that loops through numbers1
–10000
:ScrollView { LazyVStack { ForEach(1...10000, id:\.self){ item in ListRow(id: item, type: "Vertical") } } }
- Now, let's observe lazy loading in action. If the Xcode debug area is not visible, click on View | Debug Area | Show Debug Area:
Figure 2.10 – Show Debug Area
- Select a simulator to use for running the app. The app should look as follows:
Figure 2.11 – Selecting the simulator from Xcode
- Click the play button to run the code in the simulator:
Figure 2.12 – The LazyStacks app running on the simulator
- Scroll through the items in
LazyHStack
(located at the top). Observe how theprint
statements appear in the debug area just before an item is displayed on the screen. Each item is initialized just before it is displayed. - Scroll through the items in
LazyVStack
. Observe how theprint
statements appear in the debug area just before an item is displayed.
How it works…
We started this recipe by creating the ListRow
view because we wanted to clearly demonstrate the advantage of lazy loading over the regular method where all items get loaded at once. The ListRow
view has two properties: an ID and a string. We add a print
statement to the init()
function so that we can observe when each item gets initialized:
init(id: Int, type: String){ print("Loading \(type) item \(id)") self.id = id self.type = type }
The ListRow
view body presents a Text
view with the ID
and type
parameters passed to it.
Moving up to the ContentView
struct, we replace the initial Text
view in the body variable with a VStack
component. This allows us to implement both LazyHStack
and LazyVStack
within the same SwiftUI view.
We implement LazyHStack
by first wrapping it in a scroll view, then using a ForEach
struct to iterate over the range of values we want to display. For each of those values, a new ListRow
view is initialized just before it becomes visible when the user scrolls down:
ScrollView { LazyVStack { ForEach(1...10000, id:\.self){ item in ListRow(id: item, type: "Vertical") } } }
Run the app using a device emulator to view the print
statements before each item is initialized. Nothing will be printed if the app is run in live preview mode on Xcode.
There's more…
Try implementing the preceding app using a regular HStack
or VStack
component and observe the performance difference. The app will be significantly slower since all the rows are initialized at once.
Using LazyHGrid and LazyVGrid (iOS 14+)
Just like lazy stacks, lazy grids use lazy loading to display content on the screen. They initialize only the subset of the items that would soon be displayed on the screen as the user scrolls. SwiftUI 2 introduced the LazyVGrid
and LazyHGrid
views. Let's implement a lazy grid that displays some text in this recipe.
Getting ready
Create a new SwiftUI iOS project and name it UsingLazyGrids
.
How to do it…
We'll implement a LazyVGrid
and LazyHGrid
view inside a Stack
view so as to observe both views in action on a single page. The steps are as follows:
- Above the
ContentView
struct's body variable, let's create an array ofGridItem
columns. TheGridItem
struct is used to configure the layout ofLazyVGrid
:let columns = [ GridItem(.adaptive(minimum: 100)) ]
- Create an array of
GridItem
rows that define the layout that would be used inLazyHGrid
:let rows = [ GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()) ]
- Create an array of colors. This will be used for styling some items in our view:
let colors: [Color] = [.green,.red, .yellow,.blue]
- Replace the initial
Text
view in the body view with aVStack
component and a scroll view. - Within the scroll view, add a
LazyVGrid
view and aForEach
struct that iterates over the numbers1
–999
and displays the number in aText
view:VStack { ScrollView { LazyVGrid(columns: columns, spacing:20) { ForEach(1...999, id:\.self){ index in Text("Item \(index)") } } } }
- The resulting view displays the numbers in a grid, but the content looks very bland. Let's add some zest by styling the text.
- Add a
padding()
modifier, a background modifier that uses the value of the current index to pick a color from our color array, and theclipshape()
modifier to give eachText
view a capsule shape:… ForEach(1...999, id:\.self){ index in Text("Item \(index)") .padding(EdgeInsets(top: 30, leading: 15, bottom: 30, trailing: 15)) .background(colors[index % colors.count]) .clipShape(Capsule()) } …
- Now, let's add a scroll view and
LazyHStack
. You will notice that everything else is the same as theLazyVGrid
view except for thecolumn
parameter that's changed to arow
parameter:VStack { … ScrollView(.horizontal) { LazyHGrid(rows: rows, spacing:20) { ForEach(1...999, id:\.self){ index in Text("Item \(index)") .foregroundColor(.white) .padding(EdgeInsets(top: 30, leading: 15, bottom: 30, trailing: 15)) .background(colors[index % colors. count]) .clipShape(Capsule()) } } } }
The resulting preview should look as follows:

Figure 2.13 – The UsingLazyStacks app
Run the app in the preview and vertically scroll through LazyVGrid
and horizontally scroll through LazyHGrid
. Observe how smoothly the scrolling occurs despite the fact that our data source contains close to 2,000 elements. Scrolling stays responsive because all our content is lazily loaded.
How it works…
One of the fundamental concepts of using lazy grids involves understanding how to define the LazyVGrid
columns or the LazyHGrid
rows. The LazyVGrid
column variable was defined as an array containing a single GridItem
component but causes three rows to be displayed. GridItem(.adaptive(minimum: 80))
tells SwiftUI to use at least 80 units of width for each item and place as many items as it can along the same row. Thus, the number of items displayed on a row may increase when the device is changed from portrait to landscape orientation, and vice versa.
GridItem(.flexible())
, on the other hand, fills up each row as much as possible:
let rows = [ GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()) ]
Adding three GridItem(.flexible())
components divides the available space into three equal rows for data display. Remove or add more GridItem(.flexible())
components to the row array and observe how the number of rows decreases or increases.
We provide two parameters to our lazy grids: a columns
/rows
parameter that specifies the layout of the grid and a spacing
parameter that defines the spacing between the grid and the next item in the parent view.
Using ScrollViewReader (iOS 14+)
ScrollViewReader
can be used to programmatically scroll to a different section of a list that might not be currently visible. In this recipe, we will create an app that displays a list of characters from A to L. The app will also have a button at the top for programmatically scrolling to the last element in the list and a button at the bottom for programmatically scrolling to an element in the middle of the list.
Getting ready
Create a new SwiftUI app using the UIKit App Delegate life cycle:

Figure 2.14 – UIKit App Delegate in Xcode
Name the app UsingScrollViewReader
.
How to do it…
We will start by creating an array of structs with a name and an ID. The array will be used to display SF symbols for the characters A–L. We will then proceed to implement ScrollViewReader
and programmatically move to the top or the bottom of the list.
The steps are as follows:
- Create a struct called
ImageStore
, just above theContentView_Previews
struct. The struct should implement theIdentifiable
protocol:struct ImageStore: Identifiable { var name: String var id: Int }
- Within the
ContentView
struct, just before the body variable, declare an array calledimageNames
. Initialize the array withImageStore
structs whose name parameters represent the letters A–Q from SF Symbols:let imageNames = [ ImageStore(name:"a.circle.fill",id:0), ImageStore(name:"b.circle.fill",id:1), ImageStore(name:"c.circle.fill",id:2), ImageStore(name:"d.circle.fill",id:3), ImageStore(name:"e.circle.fill",id:4), ImageStore(name:"f.circle.fill",id:5), ImageStore(name:"g.circle.fill",id:6), ImageStore(name:"h.circle.fill",id:7), ImageStore(name:"i.circle.fill",id:8), ImageStore(name:"j.circle.fill",id:9), ImageStore(name:"k.circle.fill",id:10), ImageStore(name:"l.circle.fill",id:11), ImageStore(name:"m.circle.fill",id:12), ImageStore(name:"n.circle.fill",id:13), ImageStore(name:"o.circle.fill",id:14), ImageStore(name:"p.circle.fill",id:15), ImageStore(name:"q.circle.fill",id:16), ]
- Replace the
TextView
component in the body variable with aScrollView
component,ScrollViewReader
, and aButton
component to navigate to the letterQ
in the list:ScrollView { ScrollViewReader { value in Button("Go to letter Q") { value.scrollTo(16) } } }
- Now use a
ForEach
struct to iterate over theimageNames
array and display its content using the SwiftUI'sImage
struct:ForEach(imageNames){ image in Image(systemName: image.name) .id(image.id) .font(.largeTitle) .foregroundColor(Color.yellow) .frame(width: 90, height: 90) .background(Color.blue) .padding() }
- Now, add another button at the bottom that causes the list to scroll back to the top:
Button("Go back to A") { value.scrollTo(0) }
- The app should be able to run now, but let's add some padding and background to the bottom and top buttons to improve their appearance:
Button("Go to letter Q") { value.scrollTo(0) } .padding() .background(Color.yellow) Button("Go to G") { value.scrollTo(6, anchor: .bottom) } .padding() .background(Color.yellow)

Figure 2.15 – The UsingScrollViewReader app
Run the app in Xcode live preview and tap the button at the top to programmatically scroll down to the letter Q.
Scroll to the bottom of the view and tap the button to scroll up to the view where the letter G is at the bottom of the visible area.
How it works…
We start this recipe by creating the ImageStore
struct that defines the properties of each image we want to display:
struct ImageStore: Identifiable { var name: String var id: Int }
The id
parameter is required for ScrollViewReader
as a reference for the location to scroll to, just like a house address provides the final destination for driving. By making the struct extend the Identifiable
protocol, we are able to iterate over the imageNames
array without specifying an id
parameter to the ForEach
struct:
ForEach(imageNames){ image in Image(systemName: image.name) … }
ScrollViewReader
should be embedded inside a scroll view. This provides a scrollTo()
method that can be used to programmatically scroll to the item whose index is specified in the method:
ScrollViewReader { value in Button("Go to letter Q") { value.scrollTo(16) } … }
The scrollTo()
method also has an anchor
parameter that is used to specify the position of the item we are scrolling to; for example, scrollTo(6, anchor: .top)
, in this case, causes the app to scroll until the ImageStore
item with ID 6 at the bottom of the view.
Using expanding lists (iOS 14+)
Expanding lists can be used to display hierarchical structures within a list. Each list item can be expanded to view its contents. The expanding ability is achieved by creating a struct that holds some information and an optional array of items of the same type as the struct itself. Let's examine how expanding lists work by creating an app that displays the contents of a backpack.
Getting ready
Create a new SwiftUI app and name it UsingExpandingLists
.
How to do it…
We start by creating the Backpack
struct that describes the properties of the data we want to display. We'll then create a number of Backpack
constants and display the hierarchical information using a List
view containing a children
property.
The steps are as follows:
- At the bottom of the
ContentView.swift
file, define aBackpack
struct that has anid
,name
,icon
, andcontent
property. Thecontent
property's type should be an optional array ofBackpack
structs:struct Backpack: Identifiable { let id = UUID() let name: String let icon: String var content: [Backpack]? }
- Below the
Backpack
struct declaration, create three variables: two currency types and an array of currencies:let dollar = Backpack(name: "Dollar", icon: "dollarsign. circle") let yen = Backpack(name: "Yen",icon: "yensign.circle") let currencies = Backpack(name: "Currencies", icon: "coloncurrencysign.circle", content: [dollar, yen])
- Create a
pencil
,hammer
,paperclip
, andglass
constant:let pencil = Backpack(name: "Pencil",icon: "pencil. circle") let hammer = Backpack(name: "Hammer",icon: "hammer") let paperClip = Backpack(name: "Paperclip",icon: "paperclip") let glass = Backpack(name: "Magnifying glass", icon: "magnifyingglass")
- Create a
bin Backpack
constant that containspaperclip
andglass
, as well as atool
constant that holdspencil
,hammer
, andbin
:let bin = Backpack(name: "Bin", icon: "arrow.up.bin", content: [paperClip, glass]) let tools = Backpack(name: "Tools", icon: "folder", content: [pencil, hammer,bin])
- Going back to the
ContentView
struct, add an instance property;items
, which contains an array of two items, thecurrencies
andtools
constants defined earlier:struct ContentView: View { let items = [currencies,tools] … }
- Within the
body
variable, replace theText
view with aList
view that displays the content of theitems
array:var body: some View { List(items, children: \.content){ row in Image(systemName: row.icon) Text(row.name) } }
The resulting Xcode live preview should look as follows when expanded:

Figure 2.16 – Using expanding lists
Run the Xcode live preview and click on the arrows to expand and collapse the views.
How it works…
We start by creating a struct of the proper format for expanding lists. Expanding lists require one of the struct properties to be an optional array of the same type as the struct itself. Our Backpack
content property holds an optional array of Backpack
items:
struct Backpack: Identifiable { let id = UUID() let name: String let icon: String var content: [Backpack]? }
The UUID()
function generates a random identifier and stores it in the id
property. We can manually provide the name
, icon
, and content
variables for the items we add to the backpack.
We create our first hierarchical structure by creating the Dollar
and Yen
variables:
let dollar = Backpack(name: "Dollar", icon: "dollarsign. circle") let yen = Backpack(name: "Yen",icon: "yensign.circle")
Observe that no content
property has been provided for both variables. This is possible because the content is an optional parameter.
We then create a currencies
constant that that has name
and icon
. The Dollar
and Yen
variables created earlier are added to its content
array.
We use a similar composition to add elements to our tool
constant.
After setting up the data we want to display, we create an item
property in our ContentView
struct that holds an array of Backpack
constants created earlier:
let items = [currencies,tools]
Finally, we replace the Text
view in the ContentView
body with a List
view that iterates over the items. The list is made expandable by adding the children
parameter, which expects an array of the same time as the struct passed to the List
view.
There's more…
The tree structure used for expandable lists can also be generated on a single line, as follows:
let tools = Backpack(name: "Tools", icon: "folder", content: [Backpack(name: "Pencil",icon: "pencil.circle"), Backpack(name: "Hammer",icon: "hammer"), Backpack(name: "Bin", icon: "arrow.up.bin", content: [Backpack(name: "Paperclip",icon: "paperclip"), Backpack(name: "Magnifying glass", icon: "magnifyingglass") ]) ])
The SwiftUI List
view provides out-of-the-box support for OutlineGroup
in iOS 14. OutlineGroup
computes views and disclosure groups on demand from an underlying collection.
See also
Refer to the WWDC20 video on stacks, grids, and outlines at https://developer.apple.com/videos/play/wwdc2020/10031/.
Refer to the Apple documentation on OutlineGroup
at https://developer.apple.com/documentation/swiftui/outlinegroup.
Using disclosure groups to hide and show content (iOS 14+)
DisclosureGroup
is a view used to show or hide content based on the state of a disclosure control. It takes two parameters: a label to identify its content and a binding to control whether its content is shown or hidden. Let's take a closer look at how it works by creating an app that shows and hides content in a disclosure group.
Getting ready
Create a new SwiftUI project and name it UsingDisclosureGroup
.
How to do it…
We will create an app the uses DisclosureGroup
views to reveal some planets in our solar system, continents on the Earth, and some surprise text. The steps are as follows:
- Below the
ContentView
struct, add a state property calledshowplanets
:@State private var showplanets = true
- Replace the
Text
view in thebody
variable with aDisclosureGroup
view that displays some planets:DisclosureGroup("Planets", isExpanded: $showplanets){ Text("Mercury") Text("Venus") }
- Review the result in Xcode's live preview, then nest another
DisclosureGroup
view for planet Earth. The group should contain the list of Earth's continents:DisclosureGroup("Planets", isExpanded: $showplanets){ … DisclosureGroup("Earth"){ Text("North America") Text("South America") Text("Europe") Text("Africa") Text("Asia") Text("Antarctica") Text("Oceania") } }
- Let's add another
DisclosureGroup
view using a different way of defining them. Since the body variable can't display two views, let's embed our parentDisclosureGroup
view in aVStack
component. Hold down the ⌘ key and click on the parentDisclosureGroup
view and select Embed in VStack from the list of actions. - Below our parent
DisclosureGroup
view, add another one that reveals surprise text when clicked:VStack { DisclosureGroup("Planets", isExpanded: $showplanets){ Text("Mercury") Text("Venus") DisclosureGroup("Earth"){ Text("North America") Text("South America") Text("Europe") Text("Africa") Text("Asia") Text("Antarctica") Text("Oceania") } } DisclosureGroup{ Text("Surprise! This is an alternative way of using DisclosureGroup") } label : { Label("Tap to reveal", systemImage: "cube.box") .font(.system(size:25, design: .rounded)) .foregroundColor(.blue) } }

Figure 2.17 – The UsingDisclosureGroup app
Run the Xcode live preview and click on the arrows to expand and collapse the views.
How it works…
Our initial DisclosureGroup
view presents a list of planets. By passing in a binding, we are able to read the state change and know whether the DisclosureGroup
view is in an open or closed state:
DisclosureGroup("Planets", isExpanded: $showplanets){ Text("Mercury") Text("Venus") }
We can also use DisclosureGroup
views without bindings, depending solely on the default UI:
DisclosureGroup("Earth"){ Text("North America") Text("South America") … }
Lastly, we used the new Swift 5.3 closure syntax to separate the Text
and Label
portions into two separate views. This allows more customization of the Label
portion:
DisclosureGroup{ Text("Surprise! This is an alternative way of using DisclosureGroup") } label : { Label("Tap to reveal", systemImage: "cube.box") .font(.system(size:25, design: .rounded)) .foregroundColor(.blue) }
DisclosureGroup
views are very versatile as they can be nested and used to display content in a hierarchical way.