EXPEDIA GROUP TECHNOLOGY — SOFTWARE

SwiftUI with UICollectionView

How to bring the two together

Kari Grooms
Expedia Group Technology

--

Image of a collection view on the Vrbo iOS app main screen overlay on a code snippet for a SwiftUICollectionViewCell class.
Image by author

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:

Sample property card with an image of a luxury condo above some description text about the property and its price.

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.

Screenshot of app displaying a horizontal collection view of the SwiftUI property card

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 UIViewControllers 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.

Screenshot of debugger printing out our setup/cleanup messages.

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.

Screenshot of Swift code displaying error message: Missing argument for parameter ‘content’ in call

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 the embed() method.
  • If the UIHostingController has already been initialized, we update the host's rootView with a new Card instead of spinning up another UIHostingController 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 SwiftUI View.

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 MyCollectionViewCella 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.

Animated gif scrolling through the collection view; showing different property cards

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.

Screenshot of collection view showing that the images in our collection view are not all the same size.

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.

SwiftUI Preview showing the card image skewed when resized.

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.

Screenshot of collection view showing the SwiftUI cards not all aligned to the top.

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.

Screenshot of collection view showing the SwiftUI cards are now all aligned to the top.

Caveats

So far so good, right? Let’s add a button to our SwiftUI View.

Screenshot of the SwiftUI property card that has a heart button added to the top right corner of the card over the property image.

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.

Animated gif showing the user scrolling through the collection view and “hearting” several properties. When the user scrolls back through the collection view, the hearts are no longer “hearted”.

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.

Animated gif showing the user scrolling through the collection view and “hearting” several properties. Now, when the user scrolls back through the collection view, the hearts are still “hearted”, which is what we would expect.

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.

Screenshot of property details modal that shows the property image, description, availability and other information about the property.

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)
Animated gif showing that when the heart button is tapped, the property details modal is presented. This is not the expected behavior. The property details modal should only be presented when the card is tapped, not when the card’s heart button is tapped.

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)
Animated gif showing that when the heart button it is tapped, the property details modal is not presented.

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 SwiftUI View. You could implement your own didSelectItemAt delegate method. SwiftUICollectionViewCell could even implement such a delegate out of the box if needed.
  • Don’t bother with an embedded SwiftUI View if your UICollectionViewCell content has a lot of interactive elements AND you also need to hook into the didSelectItemAt UICollectionViewDelegate method.
  • Define a UIButton inside of UICollectionViewCell and position it above your SwiftUI View, as we did above.
  • Just wait until your app deprecates support for iOS 13 and then replace your UICollectionView with a 100% SwiftUI LazyStack 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!

--

--