• GitHub
  • Twitter
  • LinkedIn
  • Challenges When Re-using SwiftUI Cells in a UITableView

    A Case of Confused Identity

    Author:

    The AI generated hero image, showing a robot engraving identities on red blood cells

    Whenever 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 UITableViewCells 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
    This image shows 3 visible cells 1, 2 and 3 while number 4 is out of view Cell 1 is scrolled out of view and appears again at the bottom of the list Now the same happens to the 2nd cell while the 1st scrolled into view again
    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, but Lists always enforce the identifiers being set on the cells through compiler checks on Identifiable conformance, or passing the id 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 UITableViewCells 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:

    An animated image that shows the cell that scrolls into view animates a size change because it thinks it content changed rather than was replaced

    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:

    An animated image that shows the cell that scrolls into view suddenly flickers to a different image

    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:

    A short movie clip that shows a cell goes to loading state immediately before showing the right image

    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.

    An image of the author
    is an Apple platforms developer that likes to write and discuss about technical subjects.

    Do you:

    Drop me a line!

    Contact Me