Challenges When Re-using SwiftUI Cells in a UITableView
A Case of Confused Identity
Author: Lucas van DongenWhenever I do take-home assignments for a company I'm interviewing at, I always try to do something from the to-do list of things I still want to explore. Of course you shouldn't go overboard, the person reviewing your code should be able to understand it, but there's always something that will both reflect well on you while you are learning.
Today, I'll share something interesting I discovered when I finally had the opportunity to use iOS 16's new APIs for hosting SwiftUI views in UITableViewCells
and UICollectionViewCells
.
The reason you still might want to use UITableView
or UICollectionView
in 2024 is the fact that they allow more fine-grained control over certain aspects of your list (like scrolling!) and allow for better performance-tuning when scrolling over very large sets of data.
Let's take a look what using SwiftUI in a UITableViewCell
looks like nowadays, and what edge cases you can solve by properly handling SwiftUI View identifiers.
SwiftUI Lists and Grids
Whenever we deal with a collection-type of View anywhere in SwiftUI, you often see something like the following ForEach
-loop:
var body: some View {
ForEach(books, id: \.isbn) { book in
HStack(alignment: .top) {
AsyncImage(url: book.thumbnailURL) { image in
image.frame(width: 120, height: 180)
} placeholder: {
ProgressView()
}
HStack {
Text(book.writer)
Text(book.title)
}
}
}
}
ForEach
always expects an id
for each item, either explicitly set through the id
key path, or implicitly by implementing the Identifiable
protocol on the data item:
struct Book: Identifiable {
var id: String { isbn } // The `id` is required by `Identifiable`
let isbn: String
let title: String
let author: String
let thumbnailImage: URL
}
This has to do with the internal model of SwiftUI Views
Every SwiftUI View needs to have an ID, so the layout system can diff the changes in our layout and related values for example stored in @State
can be applied to the right View.
This happens automatically for statically defined Views, but this is not possible for arbitrarily sized collections. For example if the user sells the first book, SwiftUI needs to understand it needs to animate out the first row, and it can only deduce that fact by seeing that that specific id
for that book no longer exist for any item in the ForEach
block.
UIKit Tables and Collections
When we use classic UITableViewCell
s built in UIKit, we know we need to reset and update the contents of the cell manually as they might have been re-used. Running into re-use issues and understanding how to work with cell re-use has been a rite of passage into the world of iOS development for all coders new to the platform.
Our code used to look like this:
public override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
guard
let cell = tableView.dequeueReusableCell(
withIdentifier: cellID,
for: indexPath
) as? BookCell,
let book = books[indexPath.row, default: nil]
else {
return UITableViewCell()
}
// Inside of this function, we need to make sure everything the
// previous book set gets completely re-set by the new book
cell.configure(book: book)
return cell
}
SwiftUI Cells in UIKit
This all changed since iOS 16 because we are now allowed to set SwiftUI directly in our UITableView
or UICollectionView
's cells using UIHostingConfiguration
.
Here's an example of what using UIHostingConfiguration
looks like in a UITableView
:
public override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
guard
let book = books[indexPath.row]
else {
return UITableViewCell()
}
let cell = tableView.dequeueReusableCell(
withIdentifier: cellIdentifier,
for: indexPath
)
cell.contentConfiguration = UIHostingConfiguration {
HStack(align: .top) {
AsyncImage(url: book.thumbnailURL) { image in
image.frame(width: 120, height: 180)
} placeholder: {
ProgressView()
}
VStack {
Text(book.writer)
Text(book.title)
}
}
}
return cell
}
This syntax is much cleaner and easier than using UIHostingController
or UITableViewCell
and it is more reliable while being just as performant in most cases.
But I started noticing when using an AsyncImage
that when a re-used cell scrolled into view, it would shortly flicker the previous image before either showing the cached correct image or the ProgressView()
.
A Case of Confused Identity
In the pure SwiftUI example, there was an id
forced upon the book contents by the ForEach
, that allowed SwiftUI to deduce if a book was added or removed, but this does not happen in our mixed example. The SwiftUI view does know that it's hosted inside a given instance of an UITableViewCell
, but once we start to re-use these cells SwiftUI does not fully realize that it's in fact displaying a new, different book.
Take a look at this simplified diagram of what cell re-use looks like:
Initial State | First Re-Use | Continuous Re-Use |
---|---|---|
Initially, 3 cells are visible and a 4th one is being prepared to be scrolled into view while the user scrolls | The 4th row became visible while the 1st cell scrolled out of view and is being re-used to prepare the 5th row | The 5th row now scrolls into view, which is the re-used 1st cell while the 2nd cell is prepared for re-use |
💡 Cell re-use also happens in a SwiftUI
List
, butLists
always enforce the identifiers being set on the cells through compiler checks onIdentifiable
conformance, or passing theid
key path manually, so you should not encounter this issue.
Let's take a look at what we see when we print the URL's of the images we're trying to load and the debug values for the UITableViewCell
s that are displaying our books to understand better what happens:
// First, UITableView creates new cells for each book...
<UITableViewCell: 0x104f11480>
<UITableViewCell: 0x102b16480>
<UITableViewCell: 0x102b18b80>
https://acme.com/file/73f06f02-6853-414d-ac6e-7c5803d7cb03 -->
<UITableViewCell: 0x104a193d0>
https://acme.com/file/dbd6ff94-0b2d-4ab9-8c5b-97a584ff6091 -->
<UITableViewCell: 0x102911f80>
<UITableViewCell: 0x104f11480>
<UITableViewCell: 0x102b16480>
// ... but after 7 rows we start to see the same memory addresses repeating, indicating re-use...
<UITableViewCell: 0x102b18b80>
<UITableViewCell: 0x104f17da0>
// ...and here is where we see the first glitch: the same cell is used for a different image:
https://acme.com/file/700c45da-2f23-49db-a9fc-12b446e56dd0 -->
<UITableViewCell: 0x104a193d0>
...
💡 The hexadecimal numbers that look like
0x104f11480
are the locations of each given cell in the phone's memory. If this number is the same, we're using the same instance of a cell.
Most of the cell's data we're displaying (like the book's title and author) gets overwritten really quickly and is replaced before the cell is scrolled into view. But the AsyncImage
is somewhat unique: it first needs to visit its cache, asynchronously, before it knows whether it needs to fetch a new one or can display the one from cache.
This takes enough time for the old image - for which SwiftUI has no direct evidence it was outdated - to show itself briefly before it gets replaced, making the cell look glitchy:
Cell Size Changes
The following clip shows you a very slowed down scroll over a few table cells. It's clear that the cell that scrolls into view believes its contents are updated to a different size, and it should animate its changes:
Unwanted cell animations are both unappealing and affect scroll performance.
Incorrect Image Until Cache Has Been Checked
The worst visual glitch caused by the mistaken identity of the SwiftUI contents of the re-used cell was the fact that it did not realize that the image it was displaying was not the one it was supposed to load.
In this slowed down example you can see that the old image stays around for a few moments until it gets replaced by the image it was actually supposed to load:
Help SwiftUI Understand What Really Has Changed
It took some serious head-scratching on my side before I realized what exactly was the root cause of the issue. But the solution, in retrospect, was easy:
HStack(align: .top) {
AsyncImage(url: book.thumbnailURL) { image in
image.frame(width: 120, height: 180)
} placeholder: {
ProgressView()
}
VStack {
Text(book.writer)
Text(book.title)
}
}.id(book.isbn) // Now, we have the same cell-wide id as in our `ForEach` example
All we needed to do was to set the id
. With this, SwiftUI understands immediately that when we have a different isbn
, we are in a completely different cell.
This time, AsyncImage
picked up on the fact that it needed to load a different image from scratch:
It went straight to loading, no flicker! 👍
Setting the ID's on your Cells Helps Preventing Re-Use Issues
It got a lot easier to add SwiftUI content to your UITableViewCells
and UICollectionViewCells
using UIHostingConfiguration
. It deals quite nicely with re-use scenarios, it's a lot easier than the old configure(data:)
technique most people used on a UIKit based cell.
But still there are edge-cases where SwiftUI cannot reliably identify it's not representing the same data item anymore after cell re-use, and in that case it really helps to set the id
manually on each SwiftUI cell.
📫 If you are interested in seeing the full contents of this project ping me. I need to clean it up a bit to remove company specific names, keys and URL's.