Skip to content
The Left Bit Stories
TwitterGithubHomepage

Migrating to UIContentConfiguration

UIKit, CollectionView, UIContentConfiguration13 min read

Today we're showcasing how to migrate an existing UICollectionViewCell subclass into the new modern technique using UIContentConfiguration and UIContentView.

What the... content?

iOS 14 brought a modern techique to build and configure collection views by leveraging the new protocol UIContentConfiguration. This not only offers a performant way to represent cell's data structures, but also gives it a canonical way of dealing with state changes, all while conforming to modern standards and offering builtin swift type-safety.

This completes the puzzle first introduced in iOS 13 with the addition of new modern ways to create collection views using UIDiffableDataSource and UICollectionViewCompositionalLayout.

When the stock solution isn't enough

iOS 14 also introduced simpler and leaner UICollectionViewListCell, which takes away some of the burden of the cell definitions, by adding a built-in state, accessories and content and background configurations.

Even having UICollectionViewListCell, we sometimes need to define custom layouts for our cells. This is also true for content: the cell's UIListContentConfiguration is quite limited, offering only a handful of the most common uses, such as text properties and static images. But what if we need to define a slightly more complex layout?

When not to use UIListContentConfiguration:

  • the images come from a url, instead of from a static source
  • we use different trailing markers as the ones provided in the accessories
  • we have a complex layout, outside title/subtitle/icon
  • we use shadows, layer transformations, etc.

A Simple(ish) Cell

Consider this cell UI:

A Simple Cell

To configure it, we'll need these basic properties:

  • Title
  • Subtitle
  • Image
  • isRead indicator

The layout is simple enough, but in this case we can't use the stock UIListContentConfiguration. This is because the image is not a static image from assets, and instead comes from a url. Moreover, we need to add a trailing indicator (the purple dot), to inform the user whether the contents of this cell are read or not. Those properties are not handled in the defaultContentConfiguration(), so we'll need to define our own.

Starting point

Let's see the starting point we had, since you probably have something similar in your codebase.

1public class SimpleCollectionViewCell: UICollectionViewListCell {
2
3 private var titleLabel: UILabel!
4 private var subtitleLabel: UILabel!
5 private var photoImageView: UIImageView!
6 private var trailingIndicator: UIImageView!
7
8 override init(frame: CGRect) {
9 super.init(frame: frame)
10
11 /// Layout Code
12 }
13
14 required init?(coder aDecoder: NSCoder) {
15 fatalError("init(coder:) has not been implemented")
16 }
17
18 override public var isSelected: Bool {
19 didSet {
20 if isSelected {
21 contentView.backgroundColor = .cellSelectionBackgroundColor
22 } else {
23 contentView.backgroundColor = .defaultBackgroundColor
24 }
25 }
26 }
27
28 override public var isHighlighted: Bool {
29 didSet {
30 contentView.backgroundColor = isHighlighted ?
31 UIColor.systemGray3 :
32 UIColor.defaultBackgroundColor
33 }
34 }
35}

This cell has a couple of labels and image views. To render its contents, it defines a ViewModel (which holds the data) and a method to configure its subviews as such:

1extension SimpleCollectionViewCell {
2 public struct ViewModel: Hashable {
3 public let id: String
4 public let photo: UIImage
5 public let title: String
6 public let subtitle: String?
7 public let isRead: Bool
8 }
9
10 public func configureFor(viewModel: ViewModel) {
11 titleLabel.text = viewModel.title
12 subtitleLabel.text = viewModel.subtitle
13 photoImageView.image = viewModel.photo
14 trailingIndicator.isHidden = viewModel.isRead
15 }
16}

When we dequeue the cell from the collection view, we just need to call configureFor method to properly populate our subviews with the data.

Below you can see how we would implement a UICollectionDiffableDataSource, dequeueing this cell:

1let simpleCellRegistration = UICollectionView.CellRegistration
2 <SimpleCollectionViewCell, SimpleCollectionViewCell.ViewModel> { (cell, indexPath, vm) in
3 cell.configureFor(viewModel: vm)
4}
5
6let dataSource = UICollectionViewDiffableDataSource<Section, Item>
7 .init(collectionView: collectionView) { (cv, indexPath, item) -> UICollectionViewCell? in
8 switch item {
9 case .simpleCell(let vm):
10 return cv.dequeueConfiguredReusableCell(
11 using: simpleCellRegistration,
12 for: indexPath,
13 item: vm
14 )
15 ...
16 }
17}
18...
19enum Section {
20 case onlySection
21}
22
23enum Item: Hashable {
24 case simpleCell(SimpleCollectionViewCell.ViewModel)
25}

We ensure the type passed to the cell is appropriate by making the Item contain the SimpleCollectionViewCell.ViewModel, so that when we call the registration handler that type is the ViewModel for this particular item.

Below is a diagram depicting the process from dequeueing the cell to displaying it, up until now:

Diagram of the starting point

Notice the creation and configuration steps are done sepparately. Thus, we need to remember configuring the cell after dequeuing it.

This is easy enough, and for most cases it just works. Let's now see how we can move on to modern cell configurations and see what value this can bring us.

Moving to UIContentConfiguration

We want to migrate this ViewModel to the new technique UIContentConfiguration, wich will handle not only the data the cell needs (id, photo, title, subtitle and isRead), but also the cell state, including our familiar properties when building lists such as isSelected and isHighlighted.

The great thing about this new protocol is that we'll be able to decouple our view layout from UICollectionViewCell. This way, we'll have a composable structure that we can reuse elsewhere in our code, if need be.

First we want to take a look at the UIContentConfiguration protocol:

1public protocol UIContentConfiguration {
2
3 /// Initializes and returns a new instance of the content view using this configuration.
4 func makeContentView() -> UIView & UIContentView
5
6 /// Returns the configuration updated for the specified state,
7 /// by applying the configuration's default values for that state
8 /// to any properties that have not been customized.
9 func updated(for state: UIConfigurationState) -> Self
10}
  • makeContentView() ensures that the configuration creates a new view to serve as a contentView for the cell. This view needs to be both a UIView and a UIContentView, which enforces that the view has a UIContentConfiguration stored.

  • updated(for:) returns a new configuration applying a given state. This will allow us to, for example, change the cell background color when the user taps it.

Our custom configuration will have to implement both functions. We'll define it as follows:

1public struct Configuration: UIContentConfiguration {
2 public let id: String
3 public let photo: UIImage
4 public let title: String
5 public let subtitle: String?
6
7 private(set) var state: UICellConfigurationState?
8
9 public init(
10 id: String,
11 photo: UIImage,
12 title: String,
13 subtitle: String?,
14 state: UICellConfigurationState? = nil
15 ) {
16 self.id = id
17 self.photo = photo
18 self.title = title
19 self.subtitle = subtitle
20 self.state = state
21 }
22
23 public func makeContentView() -> UIView & UIContentView {
24 View(configuration: self) // To be defined
25 }
26
27 public func updated(for state: UIConfigurationState) -> SimpleCell.Configuration {
28 var mutableCopy = self
29 if let cellState = state as? UICellConfigurationState {
30 mutableCopy.state = cellState
31 }
32 return mutableCopy
33 }
34
35}

Here we moved all the code in the ViewModel struct into this new Configuration type. We also added the state property on it, which will hold relevant properties such as isSelected or isHighlighted. This object is created by UIKit when the user interacts with cells in lists.

Also, the updated(for: UIConfigurationState) function is now very straightforward: since we're using a struct to hold this data, we can add a new variable and just use self and let copy-on-write do its job. This allows this function to just mutate the new properties, and to return a fresh copy for the view to be reconfigured.

We also need to define our view. It must be both UIView and UIContentView. That in turn requires that it has a configuration set in. Furthermore, it will have a init(configuration:), so that it can be instantiantiated from the makeView() method.

1public class View: UIView & UIContentView {
2 public var configuration: UIContentConfiguration
3
4 ...
5
6 init(configuration: Configuration) {
7 self.configuration = configuration
8 super.init(frame: .zero)
9 ...
10 // Layout Code
11
12 configureFor(configuration: configuration)
13 }
14}

In the init method, we'll also include all the layout code the previous SimpleCollectionViewCell had. Note also that here we must configure the view, meaning populating its UILabel, UIImageView, etc. with actual content, which is passed in the init method.

This configureFor(configuration:) has basically the same code it had before, but it will also contain the code to style the state changes in the cell

1public func configureFor(configuration: Configuration) {
2 titleLabel.text = configuration.title
3 subtitleLabel.text = configuration.subtitle
4 photoImageView.image = configuration.photo
5 if let state = configuration.state {
6 backgroundColor = {
7 if state.isSelected {
8 return .cellSelectionBackgroundColor
9 } else if state.isHighlighted {
10 return .systemGray3
11 } else {
12 return .defaultBackgroundColor
13 }
14 }()
15 } else {
16 backgroundColor = .defaultBackgroundColor
17 }
18}

Note that here we merged the two properties related to state, isSelected and isHighlighted in a single state change. State now doesn't have to be defined all over the UIView subclass, and it can be set from one single method, simplifiying a lot the code.

That was quite a bit of moving around the code, but the code itself keeps being the same. Now we have a cell configured and ready to display from the get-go. But how would we use this in a collection view?

Configuring our UICollectionViewDiffableDataSource

We now have a working UIView with all it's inputs and outputs well defined. At this stage, we recommend adding snapshot tests to make sure the view looks exactly like you'd expect.

Now, to use this view as a cell in a UICollectionView, there's a secret that we haven't told you: beggining iOS 14 UICollectionViewCell defines a new property:

1@available(iOS 14.0, tvOS 14.0, *)
2extension UICollectionViewCell {
3 @available(iOS 14.0, tvOS 14.0, *)
4 public var contentConfiguration: UIContentConfiguration?
5}

The documentation states that:

Setting a content configuration replaces the existing contentView of the cell with a new content view instance from the configuration, or directly applies the configuration to the existing content view if the configuration is compatible with the existing content view type.

This means that it'll rip out it's contentView, and use the view provided by your UIContentConfiguration in it's makeContentView() method. No more subclasses of UICollectionViewCell 🎉!!!

Now that we know this, our registration and dequeue process looks much like the same as before, but applying the configuration instead:

1let simpleCellRegistration = UICollectionView.CellRegistration
2 <UICollectionViewCell, UIContentConfiguration> { (cell, _, configuration) in
3 cell.contentConfiguration = configuration
4}
5
6let dataSource = UICollectionViewDiffableDataSource<Section, Item>
7 .init(collectionView: collectionView) { (cv, indexPath, item) -> UICollectionViewCell? in
8 switch item {
9 case .simpleCell(let configuration):
10 return cv.dequeueConfiguredReusableCell(
11 using: simpleCellRegistration,
12 for: indexPath,
13 item: configuration
14 )
15 ...
16 }
17}

We do need to change how the dataSource's Item is defined:

1enum Item: Hashable {
2 case simpleCell(SimpleCell.Configuration)
3}

Now we store the actual SimpleCell.Configuration we defined previously.

Also, UIKit is now in charge for some changes on that configuration: in the case for a state change, UIKit will immediatly call SimpleCell.Configuration updated(for state:) method, which in turn will update the configuration on the SimpleCell.View and call configureFor(configuration:), so everything is kept in sync.

Moreover, if there's a data change and we need to call reconfigureItems() on the dataSource, the cell will be dequeued, which will set the configuration again, calling updated(for state:).

This here shows the diagram of the different steps involved in the dequeueing and displaying of the cells.

Diagram of the process after migrating

We really are doing the same steps as before, just in a different order. Instead of dequeueing the cell, creating the ViewModel and then assigning it to the cell, we are creating the Configuration and assigining it to the cell, which in turn creates the content view under the hood. However, notice here we're merging the creation and configuration of the view in a single step, and that may have some impact on the layout definition, since now we new can't create a cell on its own, and it needs to have a configuration ready beforehand.

So far we saw how we create a cell using the configuration. But if we tap on the cell, the background is not being changed. This is because we lack the last piece of the puzzle: Updates.

How are changes in the configuration affecting the view?

As we can see from the updated(for state:) method, we can mutate the configuration whenever a change is needed. This in turn will change the instance of the configuration defined in SimpleCellView. But that does not change any views yet. So we need to set a trigger for that to happen. So we add a didSet trigger to the SimpleCellView's configuration:

1public var configuration: UIContentConfiguration {
2 didSet {
3 guard let oldConfig = oldValue as? Configuration,
4 let config = configuration as? Configuration else { return }
5 if oldConfig != config {
6 self.configureFor(configuration: config)
7 }
8 }
9}

Here we make sure that if the configuration really changed, we call configureFor(configuration: config). To do this, we need to make Configuration conform to Equatable as well. But aside from that, we just need to call configureFor(configuration:) again to see the change take effect.

Here's a diagram of the update process

Update Diagram

And thats really it! Here you can se the complete code.

1public enum SimpleCell {
2 public struct Configuration: UIContentConfiguration, Identifiable, Equatable {
3 public let id: String
4 public let photo: UIImage
5 public let title: String
6 public let subtitle: String?
7
8 private(set) var state: UICellConfigurationState?
9
10 public init(
11 id: String,
12 photo: UIImage,
13 title: String,
14 subtitle: String?,
15 state: UICellConfigurationState? = nil
16 ) {
17 self.id = id
18 self.photo = photo
19 self.title = title
20 self.subtitle = subtitle
21 self.state = state
22 }
23
24 public func makeContentView() -> UIView & UIContentView {
25 View(configuration: self)
26 }
27
28 public func updated(for state: UIConfigurationState) -> SimpleCell.Configuration {
29 var mutableCopy = self
30 if let cellState = state as? UICellConfigurationState {
31 mutableCopy.state = cellState
32 }
33 return mutableCopy
34 }
35
36 }
37
38 public class View: UIView & UIContentView {
39 public var configuration: UIContentConfiguration
40 private var titleLabel: UILabel!
41 private var subtitleLabel: UILabel!
42 private var photoImageView: UIImageView!
43
44 init(configuration: Configuration) {
45 self.configuration = configuration
46 super.init(frame: .zero)
47 ...
48 // Layout Code
49
50 configureFor(configuration: configuration)
51 }
52
53 public func configureFor(configuration: Configuration) {
54 titleLabel.text = configuration.title
55 subtitleLabel.text = configuration.subtitle
56 photoImageView.image = configuration.photo
57
58 if let state = configuration.state {
59 backgroundColor = {
60 if state.isSelected {
61 return .cellSelectionBackgroundColor
62 } else if state.isHighlighted {
63 return .systemGray3
64 } else {
65 return .defaultBackgroundColor
66 }
67 }()
68 } else {
69 backgroundColor = .defaultBackgroundColor
70 }
71 }
72 }
73}

Bonus Track: SwiftUI

You might be wondering: "is this change worth it? What's in it for me?"

Moving to this architecture GREATLY improves the experience when bridging these views to SwiftUI. It should be as simple as:

1import SwiftUI
2
3extension SimpleCell {
4
5 struct SwiftUIView: UIViewRepresentable {
6
7 let config: Configuration
8
9 func makeUIView(context: Context) -> SimpleCell.View {
10 config.makeContentView() as! SimpleCell.View
11 }
12
13 func updateUIView(_ uiView: SimpleCell.View, context: Context) {
14 uiView.configureFor(configuration: config)
15 }
16
17 typealias UIViewType = SimpleCell.View
18 }
19}

A little more of code should be required to handle updates to this view, but should not be hard, let's leave that as homework 😅.

This data-driven approach trickling down in UIKit it's greatly appreciated and makes it easy to maintain UIKit projects.

Conclusion

With that, is as simple as changing types wherever we used SimpleCollectionViewCell to the new SimpleCell.View and SimpleCollectionViewCell.ViewModel to SimpleCell.Configuration.

And that's it! Using that structure we can now implement the same things we previously had, without the hassle of having all the state properties attached to the view, all while relying on UIKit to do the heavy lifting of deciding when to update content and state.

Moving on from here

This covers how we would implement a custom UIContentConfiguration. For many cases, though, using the stock UIListContentConfiguration and a plain UICollectionViewListCell may be enough. You can read more about it on WWDC notes and from the WWDC 2020 Session itself

There is one final caveat: we're currently using the whole Configuration struct as the Item for the UICollectionDiffableDataSource<Section, Item>, instead of asigning it the ID. This works, but according to apple's principles it may lead to bad performance or ocasional crashing.

Plus, using only the ID as a reference would allow us to use reconfigureItems, first introduced in iOS 15, to reload any changes on a cell using this same technique.

So head on to our next article, where we discuss how to implement a data source using this same structure, but relying on IDs or simple enums as the data source items instead of the whole Configuration object.

Cheers!

© 2023 by The Left Bit Stories. All rights reserved.
Theme by LekoArts