Articles, podcasts and news about Swift development, by John Sundell.

Rendering SwiftUI views within UITableView or UICollectionView cells on iOS 16

Published on 07 Jun 2022
Discover page available: SwiftUI

Ever since its original introduction in 2019, SwiftUI has had really strong interoperability with UIKit. Both UIView and UIViewController instances can be wrapped to become fully SwiftUI-compatible, and UIHostingController lets us render any SwiftUI view within a UIKit-based view controller.

However, even though macOS has had an NSHostingView for inlining SwiftUI views directly within any NSView (no view controller required) since the very beginning, there has never been a built-in way to do the same thing on iOS. Sure, we could always grab the underlying view from a UIHostingController instance, or add our hosting controller to a parent view controller in order to be able to use its view deeper within a UIView-based hierarchy — but neither of those solutions have ever felt entirely smooth.

That brings us to iOS 16 (which is currently in beta at the time of writing). Although Apple haven’t introduced a fully general-purpose UIHostingView as part of this release, they have addressed one of the most common challenges that the lack of such a view presents us with — which is how to render a SwiftUI view within either a UITableViewCell or UICollectionViewCell.

Just a quick note before we begin: All of this article’s code samples will be entirely UITableView-based, but the exact same techniques can also be used with UICollectionView as well.

Say hello to UIHostingConfiguration

In iOS 14, Apple introduced a new way to configure cells that are being rendered as part of a UITableView or UICollectionView, which lets us use so-called content configurations to decouple the content that we’re rendering from the actual subviews that our cells contain. This API has now been extended with a new UIHostingConfiguration type, which lets us define a cell’s content using any SwiftUI view hierarchy.

So wherever we’re configuring our cells — for example using the good-old-fashioned UITableViewDataSource dequeuing method — we can now opt to assign such a hosting configuration to a given cell’s contentConfiguration property, and UIKit will automatically render the SwiftUI views that we provide within that cell:

class ListViewController: UIViewController, UITableViewDataSource {
    private var items: [Item]
    private let databaseController: DatabaseController
    private let cellReuseID = "cell"
    private lazy var tableView = UITableView()
    ...

    func tableView(_ tableView: UITableView,
                   cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(
            withIdentifier: cellReuseID,
            for: indexPath
        )

        let item = items[indexPath.row]

        cell.contentConfiguration = UIHostingConfiguration {
            HStack {
                Text(item.title)
                Spacer()
                Button(action: { [weak self] in
                    if let indexPath = self?.tableView.indexPath(for: cell) {
                        self?.toggleFavoriteStatusForItem(atIndexPath: indexPath)
                    }
                }, label: {
                    Image(systemName: "star" + (item.isFavorite ? ".fill" : ""))
                        .foregroundColor(item.isFavorite ? .yellow : .gray)
                })
            }
        }

        return cell
    }

    private func toggleFavoriteStatusForItem(atIndexPath indexPath: IndexPath) {
        items[indexPath.row].isFavorite.toggle()
        databaseController.update(items[indexPath.row])
    }
}

Neat! As the above code sample shows, we can easily send events back from our cell’s SwiftUI views to our view controller using closures, but there are a few things that are good to keep in mind when doing so.

First, you might’ve noticed that we’re asking the table view for our cell’s current index path before passing it to our toggleFavoriteStatusForItem method. That’s because, if our table view supports operations such as moves and deletions, our cell might be at a different index path once our favorite button is tapped — so if we were to capture and use the indexPath parameter that was passed into our cell configuration method, then we might accidentally end up updating the wrong model.

Second, we have to remember that we still very much remain in the world of UIKit, which means that we have to take memory management into account (at least, more so than when operating in the wonderful value type-based world of SwiftUI). Hence the above [weak self] closure capture, in order to avoid retain cycles between our view controller and its UITableView.

Finally, there’s state management, and just like when bridging the gap between UIKit and SwiftUI using tools like UIHostingController or UIViewRepresentable, we probably need to write a little bit of custom code to ensure that our cell’s SwiftUI view hierarchy is rendering the latest version of our view controller’s data.

Managing state updates

For example, when the user taps our cell’s favorite button, our cell is not currently updated to reflect its Item model’s new state. That’s because we’re not actually using SwiftUI’s declarative state management system to drive those updates, so there’s no way for our nested SwiftUI view hierarchy to know that its data model was modified.

One way to address that would be to use UIKit to react to those changes, rather than using SwiftUI’s view updating mechanisms. We could, for instance, call our table view’s reloadRows method whenever an item’s favorite status was changed — like this:

class ListViewController: UIViewController, UITableViewDataSource {
    ...

    private func toggleFavoriteStatusForItem(atIndexPath indexPath: IndexPath) {
        items[indexPath.row].isFavorite.toggle()
        databaseController.update(items[indexPath.row])
        tableView.reloadRows(at: [indexPath], with: .none)
    }
}

That works great, since it’ll make the table view call our data source cell configuration method, which’ll give us a chance to update the changed item’s cell and the SwiftUI views that are embedded within it.

But let’s say that we instead wanted to use SwiftUI’s state management system to keep track of when our cell’s state was modified. One way to make that happen would be to introduce some form of ObservableObject that our view controller could then inject into each cell’s SwiftUI view hierarchy.

A side-benefit of introducing such a type in this case is that it’ll also give us a neat place to encapsulate all view-related logic that’s tied to our Item model, such as the code that’s used to compute our favorite button’s title and foreground color. Let’s go ahead and introduce such a class, which we’ll call ItemViewModel:

class ItemViewModel: ObservableObject {
    @Published private(set) var item: Item
    private let onItemChange: (Item) -> Void

    init(item: Item, onItemChange: @escaping (Item) -> Void) {
        self.item = item
        self.onItemChange = onItemChange
    }

    func toggleFavoriteStatus() {
        item.isFavorite.toggle()
        onItemChange(item)
    }
}

extension ItemViewModel {
    var favoriteButtonIconName: String {
        "star" + (item.isFavorite ? ".fill" : "")
    }

    var favoriteButtonColor: Color {
        item.isFavorite ? .yellow : .gray
    }
}

Note that we’re enabling an onItemChange closure to be passed into our view model, which’ll let our view controller observe whenever the view model’s copy of the underlying Item model was modified. We could definitely have chosen to use Combine here instead, by having our view controller hook into the publisher that’s connected to the view model’s item property, but a simple closure will get the job done in this case.

Next, let’s extract our cell-embedded SwiftUI code into a dedicated View type, which will let us make our view hierarchy observe our new view model as an ObservedObject:

struct ItemView: View {
    @ObservedObject var viewModel: ItemViewModel

    var body: some View {
        HStack {
            Text(viewModel.item.title)
            Spacer()
            Button(action: viewModel.toggleFavoriteStatus) {
                Image(systemName: viewModel.favoriteButtonIconName)
                    .foregroundColor(viewModel.favoriteButtonColor)
            }
        }
    }
}

Finally, all that remains is to update our view controller to use our newly introduced types, which will also let us remove the toggleFavoriteStatusForItem method that we were previously using to manage each item’s favorite status:

class ListViewController: UIViewController, UITableViewDataSource {
    ...

    func tableView(_ tableView: UITableView,
                   cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(
            withIdentifier: cellReuseID,
            for: indexPath
        )

        let viewModel = ItemViewModel(
            item: items[indexPath.row],
            onItemChange: { [weak self] item in
                guard let indexPath = self?.tableView.indexPath(for: cell) else {
                    return
                }
            
                self?.items[indexPath.row] = item
                self?.databaseController.update(item)
            }
        )

        cell.contentConfiguration = UIHostingConfiguration {
    ItemView(viewModel: viewModel)
}

        return cell
    }
}

Very nice! Of course, the above implementation only handles internal data model changes that are performed by our view controller and its nested SwiftUI views. If those models could also be modified elsewhere, then we’d have to notify our view controller whenever that happens, and reload our table view’s data accordingly. However, the fact that we’re using SwiftUI and the new UIHostingConfiguration API doesn’t really affect the way we’d do that, since any such external updates would lead to us re-configuring our cells anyway.

Bridged swipe actions

Another neat aspect of the new UIHostingConfiguration API and the way it bridges the gap between UIKit and SwiftUI is that it’ll automatically convert any SwiftUI swipeActions that our view hierarchy contains into UIKit-based swipe actions.

So for example, if we wanted to add a delete swipe action to each of our item cells, then we could do that using a modifier placed right within our UIHostingConfiguration closure — like this:

cell.contentConfiguration = UIHostingConfiguration {
    ItemView(viewModel: viewModel).swipeActions {
        Button(role: .destructive, action: { [weak self] in
            guard let indexPath = self?.tableView.indexPath(for: cell) else {
                return
            }

            self?.items.remove(at: indexPath.row)
            self?.databaseController.delete(viewModel.item)
            self?.tableView.deleteRows(at: [indexPath], with: .automatic)
        }, label: {
            Label("Delete", systemImage: "trash")
        })
    }
}

However, while the above is really neat, it’s important to point out that (at the time of writing) not every aspect of SwiftUI is automatically bridged that way. For example, if we were to place a NavigationLink within our UIHostingConfiguration, then that wouldn’t automatically be wired up to any UINavigationController that our view controller is embedded in (which does happen when using UIHostingController). Hopefully that’s something that’ll be addressed at a later time.

Conclusion

The interoperability between SwiftUI and UIKit keeps getting more and more powerful, which in turn enables us to continue mixing the two UI frameworks within the apps that we build. The introduction of UIHostingConfiguration is especially welcome, since UITableView and UICollectionView (which supports this new hosting configuration API in the exact same way) still offer much more functionality than their SwiftUI counterparts — so being able to inline SwiftUI views within them should definitely prove to be very useful.

If you have any questions, comments, or feedback, then feel free to reach out via either Twitter or email.

Thanks for reading!