Building Dynamic Lists in SwiftUI

The Ultimate Guide to SwiftUI List Views - Part 2


Sep 6, 2021 • 17 min read

Previously, we looked at how to use List views to create static list views. Static list views are useful for creating menus or settings screens in iOS apps, but List views become a lot more useful when we connect them to a data source.

Today, we’re going to look at a couple of examples how you can use List views to display a dynamic list of data, such as a list of books. We will also learn how to use some of the new features that Apple added to the latest version of SwiftUI in iOS 15, such as pull-to-refresh, a search UI, and an easy way to use async/await to fetch data from asynchronous APIs, such as remote services.

Displaying a list of elements

There are a number of ways to create lists, and as we will see later in this series, you can create both flat lists as well as hierarchical, nested lists. Since all list rows are computed on demand, List views perform well even for collections with many items.

The easiest way to create a List view based on a collection of elements is to use its constructor that takes a RandomAccessCollection and a view builder for the row content:

List(collection) { element in
  // use SwiftUI views to render an individual row to display `element`
}

Inside the view builder, we get access to the individual elements of the collection in a type-safe way. This means we can access the properties of the collection elements and use SwiftUI views like Text to render the individual rows, like in the following example:

struct Book: Identifiable {
  var id = UUID()
  var title: String
  var author: String
  var isbn: String
  var pages: Int
  var isRead: Bool = false
}
 
extension Book {
  static let samples = [
    Book(title: "Changer", author: "Matt Gemmell", isbn: "9781916265202", pages: 476),
    Book(title: "SwiftUI for Absolute Beginners", author: "Jayant Varma", isbn: "9781484255155", pages: 200),
    Book(title: "Why we sleep", author: "Matthew Walker", isbn: "9780141983769", pages: 368),
    Book(title: "The Hitchhiker's Guide to the Galaxy", author: "Douglas Adams", isbn: "9780671461492", pages: 216)
  ]
}
 
private class BooksViewModel: ObservableObject {
  @Published var books: [Book] = Book.samples
}
 
struct BooksListView: View {
  @StateObject fileprivate var viewModel = BooksViewModel()
  var body: some View {
    List(viewModel.books) { book in
      Text("\(book.title) by \(book.author)")
    }
  }
}

As this view acts as the owner of the data we want to display, we use a @StateObject to hold the view model. The view model exposes a published property which holds the list of books. In the interest of simplicity, this is a static list, but in a real-world application you would fetch this data from a remote API or a local database.

Notice how we can access the properties of the Book elements inside the List by writing book.title or book.author. Here, we use a Text view to display the title and the author of a book using string interpolation.

Thanks to SwiftUI’s declarative syntax, we can easily build more complex custom UIs to present data - just like we saw last time.

Let’s replace the Text view in the above snippet with a more elaborate row that displays the book cover, title, author and number of pages:

// ...
List(viewModel.books) { book in
  HStack(alignment: .top) {
    Image(book.mediumCoverImageName)
      .resizable()
      .aspectRatio(contentMode: .fit)
      .frame(height: 90)
    VStack(alignment: .leading) {
      Text(book.title)
        .font(.headline)
      Text("by \(book.author)")
        .font(.subheadline)
      Text("\(book.pages) pages")
        .font(.subheadline)
    }
    Spacer()
  }
}
// ...

Using Xcode’s refactoring tools for SwiftUI, we can extract this code into a custom view, to make our code easier to read.

private struct BookRowView: View {
  var book: Book
  
  var body: some View {
    HStack(alignment: .top) {
      Image(book.mediumCoverImageName)
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(height: 90)
      VStack(alignment: .leading) {
        Text(book.title)
          .font(.headline)
        Text("by \(book.author)")
          .font(.subheadline)
        Text("\(book.pages) pages")
          .font(.subheadline)
      }
      Spacer()
    }
  }
}

Check out my video on building SwiftUI views to see this (and other) refactoring in action:

youtube https://www.youtube.com/watch?v=UhDdtdeW63k

Since we’re not planning to modify the data inside the list row (or inside a details view), we pass the list item to the row as a simple reference. If we want to modify the data inside the list row (e.g. by marking a book as a favourite, or passing it on to an child screen where the user can edit the book details), we’ll have to use a list binding.

Using List Bindings to allow modifying list items

Normally, data inside a view is unmodifiable. To modify data, it needs to be managed as a @State property or a @ObservedObject view model. To allows users to modify data in a child view (e.g. a TextField or a details screen), we need to use a binding to connect the data in the child view to the state in the parent view.

Until SwiftUI 3, there wasn’t a direct way to get a binding to the elements of the list, so people had to come up with their own solutions. I’ve written about this before in this blog post, in which I contrasted incorrect and correct ways to do this.

With SwiftUI 3, Apple has introduced a straight-forward way to access list items as bindings, using the following syntax:

List($collection) { $element in
  TextField("Name", text: $element.name)
}

To allow users of our sample app to edit the title of a book inline in the list view, all we have to do is to update the book list view as follows:

List($viewModel.books) { $book in
  TextField("Book title", 
            text: $book.title, 
            prompt: Text("Enter the book title"))
}

Of course, this also works for custom views. Here is how to update the BookRowView to make the book title editable:

struct EditableBooksListView: View {
  // ...
 
  var body: some View {
    List($viewModel.books) { $book in
      EditableBookRowView(book: $book)
    }
  }
}
 
 
private struct EditableBookRowView: View {
  @Binding var book: Book
  
  var body: some View {
    HStack(alignment: .top) {
      Image(book.mediumCoverImageName)
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(height: 90)
      VStack(alignment: .leading) {
        TextField("Book title", text: $book.title, prompt: Text("Enter the book title"))
          .font(.headline)
        Text("by \(book.author)")
          .font(.subheadline)
        Text("\(book.pages) pages")
          .font(.subheadline)
      }
      Spacer()
    }
  }
}

The key point here is to use @Binding in the child view. By doing so, the parent view retains ownership of the data that you pass in to the child view, while letting the child view modify the data. The source of truth is the @Published property on the ObservableObject in the parent view.

To read more about list bindings, and how this feature works under the hood, check out my article SwiftUI List Bindings.

Asynchronously fetching data

The next sections of this article have one thing in common - they’re all based on Apple’s new APIs for handling asynchronous code.

At WWDC 21, Apple introduced Swift’s new concurrency model as part of Swift 5.5. This next version of Swift is currently in beta, but you can already get yours hands on it and experiment with the new features by downloading the beta versions of Xcode 13 from https://developer.apple.com/download/.

In the previous examples, we used a static list of data. The advantage of this approach is that we didn’t have to fetch (and wait for) this data, as it was already in memory. This was fine for the examples, as it allowed us to focus on what’s relevant, but it doesn’t reflect reality. In a real-world application, we usually display data from remote APIs, and this usually means performing asynchronous calls: while we’re waiting for the results to come in from the remote API, the app needs to continue updating the UI. If it didn’t do so, users might get the impression the app was hanging or even crashed.

So in the next examples, I’m going to demonstrate how to make use of Swift’s new concurrency model to handle asynchronous code.

A good moment to fetch data is when the user navigates to a new screen and the screen just appears. In previous versions of SwiftUI, using the .onAppear view modifier was a good place to request data. Starting with iOS 15, SwiftUI includes a new view modifier that makes this even easier: .task. It will start an asynchronous Task when the view appears, and will cancel this task once the view disappears (if the task is still running). This is useful if your task is a long-running download that you automatically want to abort when the user leaves the screen.

Using .task is as easy as applying it to your List view:

struct AsyncFetchBooksListView: View {
  @StateObject fileprivate var viewModel = AsyncFetchBooksViewModel()
  
  var body: some View {
    List(viewModel.books) { book in
      AsyncFetchBookRowView(book: book)
    }
    .overlay {
      if viewModel.fetching {
        ProgressView("Fetching data, please wait...")
          .progressViewStyle(CircularProgressViewStyle(tint: .accentColor))
      }
    }
    .animation(.default, value: viewModel.books)
    .task {
      await viewModel.fetchData()
    }
  }
}

In the view model, you can then use asynchronous APIs to fetch data. In this example, I’ve mocked the backend to make the code a bit easier to read, and added an artificial delay:

private class AsyncFetchBooksViewModel: ObservableObject {
  @Published var books = [Book]()
  @Published var fetching = false
  
  func fetchData() async {
    fetching = true
 
    await Task.sleep(2_000_000_000)
    books = Book.samples
 
    fetching = false
  }
}

If you’d try and run the code like this, you would end up with a runtime warning, saying that “Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.”

The reason for this runtime error is that the code inside refreshable is not executed on the main thread. However, UI updates must be executed on the main thread. In the past, we would’ve had to use DispatchQueue.main.async { … } to make sure any UI updates are executed on the main thread. However, with Swift’s new concurrency model, there is an easier way: all we have to do is to any methods (or classes) that perform UI updates using the @MainActor property wrapper. This instructs the compiler to switch to the main actor when executing this code, and thus make sure any UI updates run on the main thread. Here’s the updated code:

private class AsyncFetchBooksViewModel: ObservableObject {
  @Published var books = [Book]()
  @Published var fetching = false
  
  @MainActor
  func fetchData() async {
    fetching = true
 
    await Task.sleep(2_000_000_000)
    books = Book.samples
 
    fetching = false
  }
}

To learn more about Swift’s new concurrency model, check out this video series on YouTube, as well as the following articles on my blog:

Pull to refresh

Unless you use an SDK like Cloud Firestore that allows you to listen to updates in your backend in realtime, you will want to add some UI affordances to your app that make it easy for your users to request the latest data. One of the most common ways to let users refresh data is pull to refresh, made popular in 2008 by Loren Brichter in the Tweetie app (later acquired by Twitter and relaunched as Twitter for iOS).

SwiftUI makes it easy to add this functionality to your app with just a few lines of code, thanks to its declarative nature. And - as mentioned above - this feature also makes use of Swift’s new concurrency model, to ensure that your app’s UI remains responsive even while it needs to wait for any updates to arrive.

Adding the refreshable view modifier to your List view is all it takes to add pull to refresh to your app:

struct RefreshableBooksListView: View {
  @StateObject var viewModel = RefreshableBooksViewModel()
  var body: some View {
    List(viewModel.books) { book in
      RefreshableBookRowView(book: book)
    }
    .refreshable {
      await viewModel.refresh()
    }
  }
}

As indicated by the await keyword, refreshable opens an asynchronous execution context. This requires that the code you’re calling from within refreshable can execute asynchronously (if the code you’re calling can execute non-asynchronously, because it returns immediately, that’s fine as well, but more often than not you’ll want to communicate with a remote API that requires being called asynchronously).

To give you an idea of how this might look like, I’ve created a view model that simulates an asynchronous remote API by adding some artificial wait time:

class RefreshableBooksViewModel: ObservableObject {
  @Published var books: [Book] = Book.samples
  
  private func generateNewBook() -> Book {
    let title = Lorem.sentence
    let author = Lorem.fullName
    let pageCount = Int.random(in: 42...999)
    return Book(title: title, author: author, isbn: "9781234567890", pages: pageCount)
  }
  
  func refresh() async {
    // in Xcode 13 b1 and b2, this crashes. 
    // Add SWIFT_DEBUG_CONCURRENCY_ENABLE_COOPERATIVE_QUEUES = NO 
    // to the target's environment values as a workaround
    await Task.sleep(2_000_000_000)
    let book = generateNewBook()
    books.insert(book, at: 0)
  }
}

Let’s take a look at this code to understand what’s going on.

  1. As in the previous samples, books is a published property that the view subscribes to.
  2. generateNewBook is a local function that produces a random new Book instance using the excellent LoremSwiftum library.
  3. Inside refresh, we call generateBook to produce a new book and then insert it into the published property books, but before we do so, we tell the app to sleep for 2 seconds, using the Task.sleep call. This is an asynchronous call, so we need to use await to call it.

Just the same as in the previous example, this code will produce a purple runtime warning: “Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates”, so we need to use @MainActor to ensure all updates happen on the main actor. This time, instead of marking just the refresh method, we’re going to mark the entire view model as @MainActor:

@MainActor
class RefreshableBooksViewModel: ObservableObject {
  // ...
 
  func refresh() async {
    // ...
  }
}

One final adjustment before we can wrap up this section: you will notice that, when adding new items to the list by pulling to refresh, the newly added items will appear instantly, without a smooth transitions.

Thanks to SwiftUI’s declarative syntax, adding animations to make this feel more natural is super easy: all we need to do is adding an animation view modifier to the List view:

// ...
List(viewModel.books) { book in
  RefreshableBookRowView(book: book)
}
.animation(.default, value: viewModel.books)
// ...

By providing the value parameter, we can make sure this animation is only run when the contents of the list view changes, for example when new items are inserted or removed.

To perfect the animations, we’ll also add a short pause to the end of the refresh function on the view model - this makes sure that the new rows appear with a smooth transition before the progress spinner disappears:

func refresh() async {
  // in Xcode 13 b1 and b2, this crashes. Add SWIFT_DEBUG_CONCURRENCY_ENABLE_COOPERATIVE_QUEUES = NO to the target's environment values as a workaround
  await Task.sleep(2_000_000_000)
  let book = generateNewBook()
  books.insert(book, at: 0)
  
  // the following line, in combination with the `.animation` modifier, makes sure we have a smooth animation
  await Task.sleep(500_000_000)
}

Searching

SwiftUI makes it easy to implement search in List views - all you need to do is apply the .searchable view modifier to the list view, and SwiftUI will handle all the UI aspects for you automatically: it will display a search field (and make sure it is off screen when you first display the list view, just like you’d expect from a native app). It also has all the UI affordances to trigger the search and clear the search field).

The only thing that’s left to do is to actually perform the search and provide the appropriate result set.

Generally speaking, a search screen can either act locally (i.e. filter the items being displayed in a list view), or remotely (i.e. perform a query against a remote API, and only display the results of this call).

For this section, we’re going to look at filtering the elements being displayed in the list view. To do so, we’ll be using a combination of async/await and Combine.

To get started, we’ll build a simple List view that displays a list of books from a view model. This should look very familiar to you, as we’re in fact reusing much of the code we’ve used for the previous examples:

struct SearchableBooksListView: View {
  @StateObject var viewModel = SearchableBooksViewModel()
  var body: some View {
    List(viewModel.books) { book in
      SearchableBookRowView(book: book)
    }
  }
}
 
struct SearchableBookRowView: View {
  var book: Book
  
  var body: some View {
    HStack(alignment: .top) {
      Image(book.mediumCoverImageName)
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(height: 90)
      VStack(alignment: .leading) {
        Text(book.title)
          .font(.headline)
        Text("by \(book.author)")
          .font(.subheadline)
        Text("\(book.pages) pages")
          .font(.subheadline)
      }
      Spacer()
    }
  }
}

The view model is very similar to the ones we used previously, with one important difference: the collection of books is empty initially:

class SearchableBooksViewModel: ObservableObject {
  @Published var books = [Book]()  
}

To add a search UI to SearchableBooksListView, we apply the .searchable view modifier, and bind its text parameter to a new searchTerm property on the view model:

class SearchableBooksViewModel: ObservableObject {
  @Published var books = [Book]()
  @Published var searchTerm: String = ""
}
 
struct SearchableBooksListView: View {
  @StateObject var viewModel = SearchableBooksViewModel()
  var body: some View {
    List(viewModel.books) { book in
      SearchableBookRowView(book: book)
    }
    .searchable(text: $viewModel.searchTerm)
  }
}

This will install the search UI in the List view, but if you run this code, nothing will happen. In fact, you won’t even see any books in the list view.

To change this, we will add a new private property to the view model which holds the original unfiltered list of books. And finally, we will set up a Combine pipeline that filters this list based on the search term entered by the user:

class SearchableBooksViewModel: ObservableObject {
  @Published private var originalBooks = Book.samples
  @Published var books = [Book]()
  @Published var searchTerm: String = ""
  
  init() {
    Publishers.CombineLatest($originalBooks, $searchTerm) 1
      .map { books, searchTerm in 2
        books.filter { book in 3
          searchTerm.isEmpty ? true : (book.title.matches(searchTerm) || book.author.matches(searchTerm))
        }
      }
      .assign(to: &$books)
  }
}

How does this Combine pipeline work?

  1. We use Publishers.CombineLatest to take the latest state of the two publishers, $originalBooks and $searchTerm. In a real-world application, we might receive updates to the collection of books in the background, and we’ll want these to be included in the search result as well. The CombineLatest publisher will publish a new tuple containing the latest value of originalBooks and searchTerm every time one of those publishers send a new event.
  2. We then use the .map operator to transform the (books, searchTerm) tuple into an array of books that we eventually assign to the published $books property, which is connected to the SearchableBooksListView.
  3. Inside the .map closure, we use filter to return only the books that contain the search term either in their title or in the author’s name. This part of the process actually is not Combine-specific - filter is a method on Array.

If you run this code, you will notice that everything you type into the search field will be auto-capitalised. To prevent this, we can apply the . autocapitalization view modifier - after the searchable view modifier:

struct SearchableBooksListView: View {
  @StateObject var viewModel = SearchableBooksViewModel()
  var body: some View {
    List(viewModel.books) { book in
      SearchableBookRowView(book: book)
    }
    .searchable(text: $viewModel.searchTerm)
    .autocapitalization(.none)
  }
}

Closure

List views in SwiftUI are very powerful, and with the features announced at WWDC 21, Apple is closing many of the gaps that existed between UIKit and SwiftUI’s List API.

Some of the features are a bit hidden, but thankfully, the official documentation is catching up, and it always pays off to read the source documentation in the SwiftUI header file. It’d be even better if we had access to SwiftUI’s source code, but I wouldn’t hold my breath for this one.

In the meantime, I hope this series of articles helps to shed some light into what’s probably one of the most used user interface paradigms on Apple’s platforms. Feel free to connect with me on Twitter, and if you have any questions or remarks, don’t hesitate to send a tweet or DM.

In the next episode, we will look into styling List views - Apple has added some exciting styling capabilities in the latest release, making it easier to adopt Lists to your app’s branding.

Thanks for reading, and until next time!

Newsletter
Enjoyed reading this article? Subscribe to my newsletter to receive regular updates, curated links about Swift, SwiftUI, Combine, Firebase, and - of course - some fun stuff 🎈