EXPEDIA GROUP TECHNOLOGY — SOFTWARE
SwiftUI with UICollectionView
How to bring the two together
In my Lessons in SwiftUI blog post, I covered some of the differences between SwiftUI® in iOS 13 vs iOS 14. In iOS 14, we get LazyVStack
and LazyHStack
, which give us the performance benefits and functionality of UICollectionView
in UIKit. In the Vrbo™️ (part of Expedia Group™️) iOS app, we use UICollectionView
all over the place including the discovery feeds on the home screen, search results, trip boards, etc. Until we deprecate support for iOS 13, these UICollectionViews will be sticking around. There is also a strong possibility that we will be in a hybrid state of UIKit/SwiftUI for a while, so deprecating our use of UICollectionView
is not likely to happen all at once even after we drop support for iOS 13.
Can we use SwiftUI inside UICollectionView?
Yes…technically. :)
If you’re looking to start leveraging SwiftUI immediately while still using UICollectionView
, it is possible. One approach is to use SwiftUI Views as the content for your UICollectionViewCell
, which is what I'm going to cover in this post. I will warn you though, it might not always make sense to do this.
SwiftUI View inside UICollectionViewCell
Let’s first take a look at how to add a SwiftUI View to your UICollectionViewCell
.
This is the SwiftUI View we will be working with for our example:
Adding the View
Next, we embed the SwiftUI Card
inside a UICollectionViewCell
.
Integrating with UICollectionView
Configure UICollectionView
Here’s a basic UICollectionView
setup to render the cell above. To start, we’ll hard-code the number of cells (we will update this later).
So far, so good.
UIHostingController Setup/Cleanup
I covered UIHostingController
in Lessons in SwiftUI, but I didn't cover it's usage outside of a UIViewController
, which is what we're doing here. UIHostingController
is a subclass of UIViewController
and is how we use SwiftUI inside UIKit. Typically, when we add UIViewController
s to the hierarchy, we don't just add its UIView
as a subview, but also add the actual controller as a child of the UIViewController
. If we don't do this, we may run the risk of some unwanted side effects...memory leaks, views mysteriously disappearing, etc. So while the code above technically works and has been verified to not have any memory leaks, this may or may not cause other unintended issues. To err on the side of caution, let's go ahead and add it to the stack.
Here, we’re adding some helper methods to properly add and remove the UIHostingController
in our UICollectionViewCell
.
Now, our parent UIViewController
can add the UIHostingController
as a child.
Lets verify clean up happens when we navigate away from the parent UIViewController
.
Sweet. 😄
Passing Data
Up to this point, we’re rendering a SwiftUI View
for our UICollectionViewCell
content and we're properly setting this up. Now we need to start passing our SwiftUI View
some data, so that each cell's content is different.
First, let’s update our SwiftUI View
to accept some data.
I have gone ahead and made CardContent
a protocol here instead of a struct. In the Vrbo iOS app, the data providing the content for these cards is coming from a variety of backend services. We use cards like these for showing recommended properties, recently viewed properties, search results, properties on a trip board, etc. Making this a protocol allows all of these existing data models to simply conform to the protocol instead of creating new data models.
We have a new problem now though. We need the parent UIViewController
to provide data to our UICollectionViewCell
, but we won't have this data on initialization.
We need to refactor our code a bit to account for this.
A few things to note:
embed(in parent: UIViewController)
has been updated to also accept the data for our card.- Instead of setting up
UIHostingController
and adding the view on initialization, we've now moved this logic to theembed()
method. - If the
UIHostingController
has already been initialized, we update the host'srootView
with a newCard
instead of spinning up anotherUIHostingController
and having to re-add the view to the cell. - We’re no longer using constraints to layout the SwiftUI
Card
. This isn't required, but is another alternative to laying out an embedded SwiftUIView
.
Our last step is to pass the data from our parent UIViewController
to the cell, so we’ll add some fixture data for our example and then update our UICollectionViewDataSource
methods to pass this data along.
We can optimize this even further and make a SwiftUICollectionViewCell
so that this is reusable.
Now we can make MyCollectionViewCell
a subclass of SwiftUICollectionViewCell
.
And then update our parent UIViewController to call our new .configure()
method.
let item = items[indexPath.row]
cell.configure(with: item, parent: self)
// cell.embed(in: self, withContent: item)
return cell
Alright! Here’s what we’ve got in our UI now.
Layout Considerations
We’re getting pretty close here, but have a couple of things we could improve in the UI. For instance, we want our images to be the same size, so that the content under each image also lines up with the rest of the cards.
The first thing to note is that we’re sizing our cells via the UICollectionViewDelegateFlowLayout
, so our SwiftUI Card
needs to support its parent container determining its size.
In its current form, our SwiftUI Card
doesn't do a very good job of adapting to its size. Our images run the risk of being skewed.
Luckily, this is a common issue when resizing images inside a container with a specified size that we have already solved. :) We’re going to apply our .fitToAspectRatio()
modifier so that our Card
is more adaptable to its container. For details about the .fitToAspectRatio()
modifier and resizing images in SwiftUI, check out Resizing Images in SwiftUI.
Our images are now the same size, but our cards are not all aligned. Our SwiftUI Card can vary in height due to the title .lineLimit()
being able to span up to 2 lines.
By default, SwiftUI Views are centered vertically to their container, which is what is happening here. To ensure our content gets aligned to the top of our UIViewCollectionCell
, we need to add a Spacer()
.
Nice. :) This is looking good now.
Caveats
So far so good, right? Let’s add a button to our SwiftUI View
.
Do not use @State
Remember that UICollectionViewCell
gets reused for performance? For this reason, we need to make sure that our embedded SwiftUI Views do not hold any @State
, as it could get wiped out as soon as the cell is no longer visible.
Here, you'll notice we lose the heart state of the first cell.
If your cell needs state, move this logic up higher in the View hierarchy by leveraging @EnvironmentObject
.
Using @EnvironmentObject
will ensure your cell gets its state every time it's reinitialized.
Beware UICollectionViewDelegate and SwiftUI Buttons
When we are showing a property card in the Vrbo iOS app, we typically present that property’s details once you tap on the card.
In our example, we would leverage the UICollectionViewDelegate
to achieve this.
Let’s see what happens with this hierarchy in conjunction with the didSelectItemAt
method of the UICollectionViewDelegate
:
// View HierarchyUICollectionViewCell
-> SwiftUI View - (Property Card)
-> SwiftUI Button - (Heart Button)
Not what we want at all! We expect the heart button to be toggled and not to be taken to the property details view, but instead we get both since the didSelectItemAt
delegate method is also getting called.
This is what happens when we use a UIButton
instead in conjunction with the didSelectItemAt
method of the UICollectionViewDelegate.
// View HierarchyUICollectionViewCell
-> SwiftUI View - (Property Card)
-> UIButton - (Heart Button)
This is the behavior we want. The heart should be toggled when tapped and you should only be taken to the property details when the card is tapped.
What are our options for handling interactivity in a SwiftUI View embedded in a UICollectionViewCell?
- Don’t hook into the
didSelectItemAt UICollectionViewDelegate
method if you're integrating a wrapped SwiftUIView
. You could implement your owndidSelectItemAt
delegate method.SwiftUICollectionViewCell
could even implement such a delegate out of the box if needed. - Don’t bother with an embedded SwiftUI
View
if yourUICollectionViewCell
content has a lot of interactive elements AND you also need to hook into thedidSelectItemAt UICollectionViewDelegate
method. - Define a
UIButton
inside ofUICollectionViewCell
and position it above your SwiftUIView
, as we did above. - Just wait until your app deprecates support for iOS 13 and then replace your
UICollectionView
with a 100% SwiftUILazyStack
solution.
Conclusion
While it’s technically possible to use SwiftUI inside of a UICollectionViewCell
, whether you choose to do so will likely depend on your use case and whether it makes sense given the caveats. These are some questions you might ask yourself before attempting a SwiftUI/UICollectionView integration:
- Is my app close to deprecating iOS 13? If so, maybe you can wait until that happens and implement a SwiftUI
LazyStack
solution. - Does my cell content have interactive elements? If so, are you okay with implementing some workarounds if you also need to leverage
UICollectionViewDelegate.
Thanks for reading!