Building a Collection For SwiftUI (Part 2) - SwiftUI Collection Implementation
Faced with the inability to reach an acceptable level of performance with grid layouts made of simple SwiftUI building blocks, I decided to roll my own solution.
This article is part 2 in the Building a Collection For SwiftUI series.
Implementation Strategies and Requirements
Two possible implementation strategies can be considered when implementing a SwiftUI collection:
UICollectionViewentirely in SwiftUI, including cell reuse and decoration views, not to mention flexible layout support.
UICollectionViewinto a SwifUI
UICollectionView is already mature and has been greatly enhanced lately, the second option is obviously more manageable.
I added a few implementation constraints to ensure the custom collection view feels like a native SwiftUI component:
- It must nicely integrate with SwiftUI declarative formalism.
- Cells and supplementary views must be built in SwiftUI, not in UIKit, and certainly not with xibs or constraint-based layouts.
- Cells and supplementary views must be lazily instantiated and reused. Put another way, scrolling performance must be similar to what is usually expected from a
- The collection view must support arbitrary layouts and nicely update when SwiftUI detects a change to a source of truth.
- Focus must be correctly managed on tvOS, in particular it should be possible to implement the standard user experience expected on Apple TV according to the Human Interface Guidelines.
Let us start by wrapping a UIKit collection into a SwiftUI view.
Wrapping UIKit Collection View Into SwiftUI
A great feature of SwiftUI is the ease with which you can embed UIKit views in SwiftUI and conversely. This makes it easy to adopt SwiftUI where you can in your app, while still using good old UIKit where SwiftUI is lagging behind in functionality or performance.
SwiftUI provides the
UIViewRepresentable protocol to wrap a UIKit view for use in SwiftUI. We can also wrap a view controller into a
UIViewControllerRepresentable, but I here prefer the former approach as there is here no real competitive advantage in wrapping
UICollectionViewController instead of
If you are not familiar with SwiftUI, just recall that a
View in SwiftUI can be seen as a short-lived layout snapshot, unlike a
UIView in UIKit which is actually what is drawn on screen. To create and update content on screen in SwiftUI you therefore provide an up-to-date snapshot of your layout which then gets applied by the SwiftUI layout engine depending on what has changed. Snapshots are then discarded until new ones are required by further updates.
When interfacing UIKit with SwiftUI this important distiction translates into two protocol methods required by
UIViewRepresentable, so that
View snapshots can create or update the matching
UIView on screen:
makeUIView(context:)is called when the
UIViewdescribed by a
Viewneeds to be created.
updateUIView(_:context:)is called when the available
UIViewdescribed by a
Viewneeds to be updated.
We therefore start our implementation by conforming our new
CollectionView type to
UIViewRepresentable and implementing its two required methods:
In our case the actual
UICollectionView instance must be created in
makeUIView(context:), but this requires a collection view layout to be provided at initialization time first.
Providing a Collection View Layout
With iOS 13 and tvOS 13 collection view layouts can be provided in a general declarative way through compositional layouts. The obtained layout code is expressive and supports advanced features like independently scrollable sections.
Because SwiftUI was introduced with iOS and tvOS 13, compositional layouts are the obvious choice for our implementation:
Note that we provide
.zero as frame since SwiftUI is responsible of applying a suitable frame when the
UICollectionView is actually drawn on screen.
Compositional layouts are defined using sections, each section containing groups of items with supplementary or decoration views, and possibly independent orthogonal scrolling behavior. How and where this layout definition should be provided is yet still unclear, we just know it must ultimately be delivered by a
Before we proceed with finding how to actually provide section layout definitions, let us first discuss how data will be loaded into the collection.
Loading Data Into the Collection
Since iOS 13 and tvOS 13, and in addition to compositional layouts, UIKit provides diffable data sources to incrementally update data associated with a collection and animate changes. Such data sources ensure that cells and underlying data stay consistent, avoiding crashes ususally associated with mismatches between data and layout.
When a data change needs to be made, a corresponding snapshot must be created and applied to the data source, which then takes care of the updating the associated collection view. This approach is quite similar to how SwiftUI reacts to data changes in general: When a change is detected a new layout description is provided to represent the new state and SwiftUI takes care of applying it to update the content visible on screen. Diffable data sources therefore seem quite appropriate in achieving what we need.
Let us first briefly consider from a client perspective. A parent content view which displays some
Model conforming to
ObservableObject into our
CollectionView would roughly be implemented as follows:
When a model update is published by the observed object the view body is recalculated. An internal method takes care of creating a new data snapshot in some format understood by the
CollectionView, yet to be determined. The SwiftUI layout engine then either creates or updates the underlying
UICollectionView using this new view snapshot.
What kind of data format should we use, then? Using
UICollectionViewDiffableDataSource internally is appropriate for the reasons outlined above. Since this type is generic and parametrized with two types,
ItemIdentifierType (both required to be
CollectionView type needs to be generic as well.
For compatibility with compositional layouts which display rows of items, it is natural to model the data displayed by a
CollectionView as an array of rows, each row being described by the section it corresponds to and the items it contains:
Item are both
CollectionViewRow itself hashable only requires an explicit protocol conformance. Having a row itself hashable is useful to quickly check whether it changed, but more on that later.
CollectionRow is a generic type, the collection view itself must at least be parametrized with the same
Types for the generic parameters will be automatically inferred by the Swift compiler when assigning rows to the collection. Still these rows are received by
CollectionView and must be represented by a
UICollectionView. Now is therefore the time to actually implement the diffable data source we need.
Data Source and Coordinator
UICollectionView requires a data source to provide the data it displays. Instantiating a diffable data source containing
Sections and displaying them in a
collectionView is simple:
This data source must be retained, as the collection view only keeps a weak reference to it. But where should we store a strong reference to the data source to keep it alive, since
Views in SwiftUI are merely short-lived snapshots which get destroyed once they have been applied?
Fortunately SwiftUI provides an answer in the form of coordinators. The
UIViewRepresentable protocol namely lets you optionally implement a
makeCoordinator() method, from which you can return an instance of a custom type. SwiftUI calls this method before creating the UIKit view from the first time, associates the coordinator with it, and keeps the coordinator alive for as long as the
UIView is in use.
We don’t know much about the coordinator type we need yet, except that it must store our data source. After initial creation, this coordinator is provided to the
makeUIView(context:) method through the context parameter as a constant. We must be able to mutate the contained data source reference after the
UICollectionView has been instantiated (the diffable data source namely requires the collection view at initialization time), so our
Coordinator needs to be a
Now that we have a data source properly created and retained for the lifetime of the collection view, we can discuss how to fill it with data.
Data Source Snapshots
Updating a diffable data source is made in increments. When data changes it suffices to build a new data snapshot and apply it:
There are two performance issues associated with the above code, though:
- The first animated applied snapshot can be slow for a large amount of data.
- If you run this code on tvOS for a large number of row items and try to navigate the collection, you will discover that performance is really poor. If you try the same code on iOS performance is much better, though.
Since performance issues are what motivated the creation of a custom
CollectionView in the first place, we must fix these identified problems before going any further.
Solving Performance Issues
To fix performance issues associated with the first snapshot, it suffices to factor out the corresponding code into a
reloadData(context:animated:) method, called with or without animation depending on whether we are creating or updating the view:
The second performance problem we are facing on tvOS is due to snapshots being applied too many times. Recall that SwiftUI recalculates view bodies when a source of truth changes. Many kinds of changes can therefore trigger
updateUIView(_:context:), which is why you should attempt to keep its implementation as lightweight as possible in general.
@Environment changes trigger a view update. A specificity of tvOS is that focus updates are provided through the environment. Moving the focus around is therefore sufficient to trigger view updates with each and every move. This is the reason why navigating the collection is a lot heavier on tvOS than on iOS with the implementation above, as snaphots are applied every time the focus is moved.
Sadly there is no way to distinguish updates due to data or enviroment changes in
updateUIView(_:context:). The context does not provide this kind of information, the implementation therefore has no way to know whether an update requires a snapshot to be applied (data change) or not (simple environment change). Is there a way to avoid applying snaphots when the data has not changed?
Fortunately there is. As you may remember we made
CollectionViewRow conform to
Hashable. Calculating hashes is much cheaper than creating and applying a snapshot. Each time we apply a snapshot we can therefore store a hash of its
rows, persist this value into our coordinator so that it stays available between updates, and only apply a new snapshot when the hash actually changed:
With this simple trick performance problems on tvOS are eliminated, even with large data sets containing thousands of items. Note that though this
CollectionView implementation inhibits reloads due to environment changes, subviews (e.g. cells we will discuss below) can still individually respond to environment changes if they need to.
We now almost have a first working
CollectionView implementation. We only need to be able to configure its layout and cells when creating it.
Collection Layout Support
At the beginning of this article we instantiated a
UICollectionViewCompositionalLayout but did not provide any meaningful implementation for it:
How should clients of our
CollectionView provide a layout? We want our collection to behave as a native SwiftUI component, it would therefore be tempting to use function builders to have a SwiftUI-inspired syntax for layout construction too. But since the current compositional layout API is already declarative and simple, I think it is simpler to just use it as is, rather than introducing a new syntax and the code to support it.
Our compositional layout internally requires a section layout provider trailing closure, so this is what our public type interface will let the user customize:
Clients provide a section layout when instantiating a
CollectionView, for example a shelf-like layout similar to the one we previously implemented with SwiftUI stacks and scroll views (see part 1):
Refer to the official documentation for more information about the compositional layout API. Note that how
rows() are actually provided is here omitted for simplicity, as it does not affect the layout.
The layout example above is quite simple. In general each section layout might depend on the data model, though, for example if the layout is received from a web service. In such cases the
sectionLayoutProvider block will capture the initial context and keep it for subsequent layout updates, which is not what we want if the layout returned by the web service later changes. To solve this issue our friend the coordinator again comes to the rescue:
This way we ensure the section layout provider is kept up-to-date between view updates so that the layout is always the most recent one, especially if it depends on the data model. Note that the optional
sectionLayoutProvider stored by the
Coordinator can here be safely force unwrapped, as it will always be available by construction. Note we also updated the
layout(context:) method with a
Context parameter to retrieve the coordinator from.
With layout definition needs covered let us finish by discussing cell layout and display.
CollectionView first implementation is almost finished, but the cell provider closure of our
UICollectionViewDiffableDataSource is still missing. Recall that we don’t want to layout cells with UIKit code but with SwiftUI declarative syntax. How should we now proceed?
SwiftUI syntax is built on top of view builders, actually a special kind of function builder. To be able to associate SwiftUI cells with their
UICollectionViewCell counterparts (which we still need internally for our
UICollectionView implementation), we simply introduce a view builder taking an index path and item as parameters, and returning some SwiftUI view as cell:
@ViewBuilder syntax requires a dedicated initializer, as
@ViewBuilder can only appear as a parameter-attribute. Note that Swift infers the type of the cell body based on the view builder block, forcing us to add
Cell as additional parameter of the
CollectionView generic type list.
Cells whose layout is defined with SwiftUI code are ultimately displayed by a
UICollectionView and thus must be wrapped for display by UIKit. This is achieved by using
UIHostingController and a simple host
The implementation of this cell is straightforward:
- We prepare a
UIHostingControllerwhen setting a SwiftUI cell. The hosting controller view is installed to fill the entire cell content view. Such embedding is actually made possible by the fact that creating a
UIHostingControlleris actually quite cheap.
- When a cell is reused, the hosting controller view is removed and the controller itself is released.
Now that we have a cell, it suffices to register it and return dequeued instances from our data source, assigning it the corresponding SwiftUI cell:
Note that, unlike cells defined in UIKit, there is no need for clients of our
CollectionView to mess with cell class registrations and reuse identifiers. Only one cell identifier is required and managed internally. All cells provided in SwiftUI namely share the same
Cell generic type parameter, whose actual value is filled in by the Swift compiler based on what is inside the view builder closure.
iOS and tvOS 14 provide a new
CellRegistration API but, to keep things simple, we still use the old cell registration and dequeuing API, so that the implementation is compatible with iOS and tvOS 13 without the use of availability macros. The iOS and tvOS 14 modern implementation is left as an exercise for the reader.
Example of a Grid Layout
Bringing everything together, we now have a first working implementation of a
CollectionView in SwiftUI, powered by
UICollectionView. The complete source code will be provided in the third and last article in this series, but let us have a look at how a shelf-like layout like the one we discussed in part 1 is implemented with the API we have defined throughout this article, on tvOS:
The formalism is quite elegant and compact but, when actually running the code above, we find a few more issues:
- When scrolling a shelf horizontally, cells which appear from the edges are not properly sized.
- Buttons do not exhibit the
CardButtonStyleappearance when focused.
In this lengthy article we have implemented a working collection view for SwiftUI, based on
UICollectionView. We have designed an API to layout the collection itself as well as its individual cells. We also have ensured performance is on par with what we expect from a usual
UIKit collection view, thus addressing our initial problem.
The end result, while certainly promising, still suffers from a few issues we will solve in the next and final article in this series.
Read next: Part 3: Fixes and Focus Management