Among the SwiftUI enhancements from this year, List has learned to traverse hierarchical data.

This is useful when we’d like to display a tree-like structure, for example a Swift package folder:

The best part? Its implementation is one line of code.

Given our recursive model:

struct FileItem: Identifiable {
  let name: String
  var children: [FileItem]? // an instance can have multiple sub-FileItems.

  var id: String { name }
}

This is our complete view:

struct ContentView: View {
  let data: [FileItem]

  var body: some View {
    List(data, children: \.children, rowContent: { Text($0.name) })
  }
}

This is all List needs:

  • an array of elements
  • a keypath to the optional children
  • a view builder that, given one element, creates a row

List will take care of everything else.

This is yet another example of elegant SwiftUI API, which hides complexity from the developer:
in this article, let’s see how we can implement our own Hierarchy List.

Implementing a Hierarchy List

We will mimic the same signature of the List initializer used above.

Our new structure will be composed of two pieces:

  1. a simple List, to preserve the lazy loading of our views
  2. a RecursiveView, that will display a given “hierarchy level” (e.g. the content of a folder)

Generic Constraints

Our Hierarchy List will support any kind of inputs and views, therefore we will define two generics:

  • the type of collection data that comes in, Data
  • the output of the row view builder, RowContent

To make it work, we need to set some limitations on these generics:

  • Data will need to be a RandomAccessCollection, as our List can access to any of its elements at will.
  • Data collection’s Element, Data.Element, will need to conform to Identifiable, as this will be used to identify each row in the List.
  • lastly, the output of the row view builder, RowContent, must be a View.

1. HierarchyList

HierarchyList is the first of the two components just mentioned above:
the name HierarchyList was chosen as this is what developers will see when using our API.

struct HierarchyList<Data, RowContent>: View where Data: RandomAccessCollection, Data.Element: Identifiable, RowContent: View {
  let recursiveView: RecursiveView<Data, RowContent>

  init(data: Data, children: KeyPath<Data.Element, Data?>, rowContent: @escaping (Data.Element) -> RowContent) {
    self.recursiveView = RecursiveView(data: data, children: children, rowContent: rowContent)
  }

  var body: some View {
    List {
      recursiveView
    }
  }
}

As promised, this is really just a List that invokes the second component of our structure: RecursiveView.

2. RecursiveView

This view will display one level of elements and, if one or more of these elements have any “sub-elements”, will invoke itself via a DisclosureGroup (that we met previously).

DisclosureGroup’s uses a lazy approach: it computes its content on demand, based on what it needs to display.

In either case, the given rowContent view builder will be used to draw each row in the screen:

struct RecursiveView<Data, RowContent>: View where Data: RandomAccessCollection, Data.Element: Identifiable, RowContent: View {
  let data: Data
  let children: KeyPath<Data.Element, Data?>
  let rowContent: (Data.Element) -> RowContent

  var body: some View {
    ForEach(data) { child in
      if let subChildren = child[keyPath: children] {
        DisclosureGroup {
          RecursiveView(data: subChildren, children: children, rowContent: rowContent)
        } label: {
          rowContent(child)
        }
      } else {
        rowContent(child)
      }
    }
  }
}

Wrap up

That’s it! With just 33 lines of code we have perfectly cloned the new List hierarchical behavior:

Lastly, let’s add some access control modifiers in order to complete our API:

public struct HierarchyList<Data, RowContent>: View where Data: RandomAccessCollection, Data.Element: Identifiable, RowContent: View {
  private let recursiveView: RecursiveView<Data, RowContent>

  public init(data: Data, children: KeyPath<Data.Element, Data?>, rowContent: @escaping (Data.Element) -> RowContent) {
    self.recursiveView = RecursiveView(data: data, children: children, rowContent: rowContent)
  }

  public var body: some View {
    List {
      recursiveView
    }
  }
}

private struct RecursiveView<Data, RowContent>: View where Data: RandomAccessCollection, Data.Element: Identifiable, RowContent: View {
  let data: Data
  let children: KeyPath<Data.Element, Data?>
  let rowContent: (Data.Element) -> RowContent

  var body: some View {
    ForEach(data) { child in
      if let subChildren = child[keyPath: children] {
        DisclosureGroup {
          RecursiveView(data: subChildren, children: children, rowContent: rowContent)
        } label: {
          rowContent(child)
        }
      } else {
        rowContent(child)
      }
    }
  }
}

The final gist can be found here.

A Custom Hierarchy List Behavior

Since we have our own implementation of the Hierarchy List, we can now add behaviors that SwiftUI doesn’t offer.

For example, SwiftUI’s List initially only displays the first level of the hierarchy, with all disclosure groups collapsed:
how can we have the opposite behavior, where the full hierarchy is displayed from the start?

As we’ve covered in a previous article, DisclosureGroup comes with multiple initializers, some of which accept a isExpanded binding, letting us control the group expanded/collapsed state.

We can use one of those initializers to wrap the default DisclosureGroup in a new view, FSDisclosureGroup, that will set the initial isExpanded state to true instead of false:

struct FSDisclosureGroup<Label, Content>: View where Label: View, Content: View {
  @State var isExpanded: Bool = true
  var content: () -> Content
  var label: () -> Label

  var body: some View {
    DisclosureGroup(
      isExpanded: $isExpanded,
      content: content,
      label: label
    )
  }
}

We can now replace DisclosureGroup in our RecursiveView with FSDisclosureGroup and voila’, the full hierarchy is displayed by default, and users can still collapse/expand groups later on:

The final gist can be found here.

Hierarchy Lists in iOS 13

Both the new hierarchical List API and DisclosureGroup are iOS 14+, however in a previous article we’ve built our own DisclosureGroup:
without bringing the whole API here, let’s just replace our FSDisclosureGroup with the following:

struct FSDisclosureGroup<Label, Content>: View where Label: View, Content: View {
  @State var isExpanded: Bool = false
  var content: () -> Content
  var label: () -> Label

  var body: some View {
    Button(action: { isExpanded.toggle() }, label: { label().foregroundColor(.blue) })
    if isExpanded {
      content()
    }
  }
}

This iOS-13 compatible View makes the whole row tappable, making it simple to see which elements have sub elements or not:

Making the UI more similar to the iOS 14 DisclosureGroup is left as an exercise to the reader

As we can see from the image above, our list is missing something that DisclosureGroup was taking care of: the padding!
When a group is expanded, its children are displayed with a leading padding to clearly show the data structure, however we do not get that in our own implementation.

Luckily for us, this is simple to address: all we need to do is add a .padding(.leading) modifier in our recursive call of RecursiveView.

private struct RecursiveView<Data, RowContent>: View where Data: RandomAccessCollection, Data.Element: Identifiable, RowContent: View {
  ...

  var body: some View {
    ForEach(data) { child in
      if let subChildren = child[keyPath: children] {
        DisclosureGroup {
          RecursiveView(data: subChildren, children: children, rowContent: rowContent)
          .padding(.leading) // <-- new padding
        } label: {
          ...
        }
      } else {
        ...
      }
    }
  }
}

And that’s it! Now we have a complete port of the Hierarchy List to iOS 13 😃

The complete gist, compatible with Xcode 11 and Swift 5.2, can be found here.

Conclusions

This week we’ve covered and re-implemented another great SwiftUI addition: are you going to use it in your projects? What other new SwiftUI feature would you like me to write about? Please let me know!

Thank you for reading and stay tuned for more SwiftUI articles!

⭑⭑⭑⭑⭑