Build Complex Lists in SwiftUI
Building complex lists in iOS with
UICollectionView has been painful. Before iOS 13, creating a layout with multiple sections, each with different behavior and different axes, usually ended up creating a custom
UICollectionViewLayout. Fortunately, along with iOS 13, developers got
UICollectionViewCompositionalLayout. They made it possible to create section layouts in a straightforward way. Linking data to a given section did not require writing a lot of additional code, thanks to the data based on the
SwiftUI is a great framework that will make it easier and faster for developers to create views in the future. Unfortunately, it is currently at an early stage of development, and in my opinion, it lacks many key components. One of them is the equivalent of the well-known
UICollectionView. So I decided to do it on my own.
To better illustrate the mechanism, I will show the use of the collection we’re going to create, with the example of a simple mountain booklet app.
To know which way to go from the beginning, let’s define the goals:
- API follows the conventions of already existing SwiftUI components.
- The mechanism of data binding with the section layout is based on
- The element responsible for defining the layout of the section works based on the section type, not the data itself.
Since we want to follow the SwiftUI convention, the
List's initialization will use the result builder feature. The code below provides an overview. Thanks to what
@ObservedObject gives us, we don’t have to worry about reloading the view because SwiftUI will do it for us — exactly as in the case of using
Knowing just as much as we do at this point, I feel like we’ve designed the API quite well. So we can move on to implementation.
What we defined previously stipulates that the
Collection will base on data separated into sections.
All we know is that the sections need to be
Hashable to use the SwiftUI’s
ForEach mechanism. So section protocol looks like this:
As I mentioned earlier, the application will be a booklet of mountains and mountain ranges. Let’s create the section’s structs, to blueprint an overall picture of how the mechanism works. We will use them later to declare the data that will be displayed.
To not overwhelm you with the code, I will not show you the structs used as associated values’ type. I will link the GitHub repo with all sources at the end.
Transformation of data into a view
All right, we set up our models. We force the section models to be
Hashable. We have achieved goal 2!
But how will the data be displayed? The assumption is to collect the components that are responsible for displaying a given section in the closure. Let’s put some work into building the collection with the use of the result builder.
A prerequisite for its creation will be what it is supposed to collect. These components, which we will call
CollectionSectionControllers, have to be linked to the section types somehow. As usual, let’s use the protocol to achieve that.
At this point, you’re probably asking yourself what the purpose of
ViewType is. Can’t
view(for:) return a
View is a protocol with the associated type of
body. The compiler wouldn’t know what type of
body this view is. In a definition of a section controller, we can erase this type using
some. If you haven’t already, I encourage you to read about opaque result types.
Some of you may see the limitations of this solution — the first results from using result builders. We can’t just define a constructor to take any number of protocols with associated types. This is the same problem that the developers of SwiftUI encountered. When we look at the implementation of
ViewBuilder, we see the following:
Again, the compiler would not be able to determine the types of arguments. Therefore, the collection builder API has to look like this:
We’ve come this far and still haven’t even prototyped the primary component we’ve been working on for so long. Let’s deal with it finally.
The first thing missing is the
sections elements’ type. It cannot be a
CollectionSection because this protocol inherits from
Hashable. By erasing sections to
AnyHashable we will lose the section type. Thereupon, determining which section controller to query for the view would be impossible.
Thus we need to create a class that will allow us to erase the section type. Simultaneously, it has to store its type to know from which controller to request the view.
The second thing the
Collection is still missing is section controllers that will be prompted for the view. With goal 1 in mind, we designed the
CollectionBuilder to collect section controllers but yet haven’t figured out how to build a component to return. The SwiftUI’s
TupleView to return a result. We also need to group section controllers somehow. For this purpose, let’s create a
Unfortunately, this is the second time that an associated value limits us in the protocol. We will handle it exactly as before. Let’s create a class responsible for erasing the type, and then let’s go back to the
Registry. To be able to match the controller to the data, we keep the section type in
AnySection. In this case, we will do the same to then match the controller to a given section.
Since we no longer need to worry about the associated value in the
SectionController, let’s create a
Regsitry. For now, let it be responsible only for gathering the controllers. We will use it as an object that we return in
At this point, it’s fair to say that we took care of an API of our
Collection. Goal 1 achieved!
Well, so what else is missing?
Collection still does not query section controllers for a view for a given section. According to how we designed the API initially, let’s create a collection initializer using a builder. Thanks to this, the registry will be included in the collection.
As above, we use
view(for:) to query views from
Registry. This method’s still not implemented, so let’s add it.
Earlier, we implemented the
restore() method in
AnySection. Now let’s use it to cast the passed section to the required type, based on the type of section controller. This way, we will query the section controller for a view, knowing the data type. Casting is necessary because a given controller only works for a kind of section.
To not lose information about the section controller type, we will create a closure in the constructor of
AnyCollectionSectionController, returning a view with the erased type.
And now we can use this closure in the
Registry to gather a view from a section controller.
OK, but what about goal 3? We have just achieved it! Our section controllers base on
SectionType. Thanks to this, we can create many sections, and if they are of the same type of data, they will require only one controller.
The last thing we should add is a contract that will force us to pass the collection’s necessary data. So let’s create a
CollectionDataSource. It will only contain the get-only
sections property. However, if you want to extend what we have made somehow, you can extend this protocol.
Let’s move back to the data models we created earlier. It’s a time to use them and define the mountains and mountain ranges that will be displayed. With this, we will check if our API works as expected.
But wait! What is the
eraseToAnySection method? It’s a simple function that extends
CollectionSection protocol. It lets us wrap sections into
AnySection in a much more convenient way.
Laying out the collection
With the configured
ViewModel, which satisfies
CollectionDataSource, we are finally ready to create our
Collection. To build a collection, we need sections. What I like the most is that we declare the view of each section from the controller level. Thanks to this, we can quickly create headers, footers, and other components of each section.
For example, let’s make a controller responsible for generating the view of the mountain ranges section.
With sections created, let’s create our collection. Thanks to
@ObserverObject, whenever the view model is updated, the collection adjusts to the content change.
The configured collection, when run on an iPhone, looks like this:
The whole mechanism we have created today meets three fundamental assumptions. It matches well with other components that come with SwiftUI by default, so applications built with it will be consistent.
Thanks to the fact that we used protocols to define components, we can easily extend the
Collection with new functionalities, such as declaring section order from the model level.
The whole thing is just a core that you should adjust to your own more complex needs. At this point, it is not able to replace the
UICollectionView, but I think it is a big step forward. Nevertheless, I look forward to the SwiftUI team’s ideas and the new functionalities of this robust framework.
Source code as a Swift package with demo app included: https://github.com/kkiermasz/Collection.