Customising a List in SwiftUI

Tue, 1 Dec 2020 00:00:00 +0000

If you started to play along with SwiftUI, chances are you used List quite a bit in your code. And you probably had some unexpected challenges with it. So today let's see how to fix them in iOS13 and... iOS14!

Building a List in SwiftUI is a real pleasure compared to its counterpart from UIKit. We went from having a UITableView, a UITableViewDataSource/UITableViewDelegate and a custom cell to just making a simple loop on our data to generate our List 🥳.

But if you ever tried to hide a separator or the NavigationLink arrow from your list then you know that the initial dream 😇 can quickly become a nightmare 😡.

Today let's see how to customise those two items into a List both for iOS13 and iOS14.

iOS13

Table separator

Let's start with iOS13 which you may still need to be compatible with. Hiding the separator is actually pretty easy: you can rely on UITableView appearance!

UITableView.appearance().separatorStyle = .none

Downside of this technique is it will be disabled for the all application. If you need more granular control, one solution might be to rely on SwiftUI-Introspect:

List {
  ...
}
.introspectTableView { tableView in
  tableView.separatorStyle = .none
}

NavigationLink arrow indicator

Disabling the arrow is a little bit "trickier". Basically you'll have to use a ZStack to layer an empty NavigationLink (yes). On top of which you'll actually display your content:

List {
  ForEach(data) {
    ZStack {
      NavigationLink(destination: MyDestination()) {
        EmptyView()
      }
      .opacity(0)

      MyRowContent($0)
    }
  }
}

It's more verbose and less "aesthetic" than regular NavigationLink but it efficiently hide the arrow.

Voila! You customised your app for iOS13. But unfortunately for you... those don't work on iOS14 😬

iOS14

Apple changed some implementation details and so we are back to finding solutions for iOS14. We could use same workaround just as we did on iOS13. However this is far from ideal and we might face new issues when iOS15 is out.

Fortunately for us Apple also introduced LazyVStack this year. So instead of using a List we can instead migrate to a LazyVStack:

ScrollView {
  LazyVStack {
    ForEach(data) {
      NavigationLink(destination: MyDestination()) {
        MyRowContent($0)
      }
    }
  }
}

With this code you will have neither separator nor navigation accessory arrow issues. But what if you still need to support iOS13? You can build a custom view to use use List or LazyVStack.

struct CompatibleList<Row: Identifiable, RowContent: View>: View {
  private let rows: [Row]
  private let rowContent: (Row) -> RowContent

  init(rows: [Row], @ViewBuilder rowContent: @escaping (Row) -> RowContent) {
    self.rows = rows
    self.rowContent = rowContent
  }

  var body: some View {
    if #available(iOS 14.0, *) {
      ScrollView {
        LazyVStack(spacing: 0) {
          rowsBody
        }
      }
    } else {
      List {
        rowsBody
      }
      .listStyle(PlainListStyle())
      .introspectTableView { tableView in
        tableView.separatorStyle = .none
      }
    }
  }

  var rowsBody: some View {
    ForEach(rows) { row in
      rowContent(row)
    }
  }
}

While nice note that we are now much more limited in our API. We can't use Section for instance. For that we would need to go one level deeper and make our own custom Result Builder (formerly known as Function Builder).

You may also have noticed that this custom view only handle half of our issues: we don't handle NavigationLink for iOS13 and iOS14! Easiest solution I found is to build an extension to use in place of NavigationLink itself.

extension View {
  @ViewBuilder
  func listItemLink<Destination: View>(_ destination: Destination) -> some View {
    if #available(iOS 14, *) {
      NavigationLink(destination: destination) {
        self
      }
      .frame(maxWidth: .infinity, maxHeight: .infinity)
    } else {
      ZStack {
        NavigationLink(destination: destination) {
          EmptyView()
        }
        .opacity(0)

        self
      }
    }
  }
}

You may not need frame(maxWidth:,maxHeight) but I noticed some scroll performance issues when using NavigationLink inside LazyVStack without it.

Customing a list in SwiftUI on iOS13 is far beyond from simple. On the other hand starting the use of LazyVStack on iOS14 will make it painless. But it will come at the cost of some tradeoffs to support both platforms.