How to use a SwiftUI view in place of table view cell
Asked Answered
R

7

31

How can I use a SwiftUI view struct in place of a traditional cell and xib in a UITableViewController?

import UIKit
import SwiftUI

class MasterViewController: UITableViewController {

    var detailViewController: DetailViewController? = nil
    var objects = [Any]()

    // MARK: - View Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        navigationItem.title = "Table View"

        //...
    }



    // MARK: - Table View Methods

    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return objects.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        let cell = tableView.dequeueReusableCell(MySwiftUIView())

        // ...
        return cell
    }
} ...

The issue is obvious in that UIHostedController SwiftUI view is not a table cell, but how could I use it like one?

Rumph answered 22/8, 2019 at 20:15 Comment(6)
What have you tried? Do you know how to import a SwiftUI View using UIHostingController? Could you please show us some code, be more specific, of not be so vague?Nerland
@dfd I would simply like to use a SwiftUI view in place of a UITableViewCell within a UITableViewController. Using a UIHostingController when trying to register the cell, or making it dequeuable won't work. Right now I'm just embedding a UIView in the cell, and then making the cell XIB render out a UIHostingController, but I don't think this is the optimal approach.Rumph
@dfd Code added as an example.Rumph
I bet embedding UIViewControllers inside table view cell was answered before.Trilingual
First, I removed my downvote and vote-to-close - as it was, your question was vague. That said, may I ask a practical question? SwiftUI will be iOS 13 only in a month. What can you gain by using something deep in UIKit - a UITableView - and go deeper by embedding a SwiftUI view as a UITableCell? Particularly when SwiftUI already has a complete alternative - a List? I'd try to create the full List, rows/cells and all, and use UIHostingController in a UIKit app. As you are seeing, you'll run into issues with registering and dequeuing. Meant with respect....Nerland
@dfd No disrespect taken. We have a lot of legacy UIKit code in a very large project and would like to swap to SwiftUI piece by piece. I figured this out and will update my answer.Rumph
K
37

Thanks for answering your own question here. Your solution helped me make a generic HostingTableViewCell class. I'll post it here if anyone finds this question on Google like I did.

import SwiftUI

class HostingTableViewCell<Content: View>: UITableViewCell {

    private weak var controller: UIHostingController<Content>?

    func host(_ view: Content, parent: UIViewController) {
        if let controller = controller {
            controller.rootView = view
            controller.view.layoutIfNeeded()
        } else {
            let swiftUICellViewController = UIHostingController(rootView: view)
            controller = swiftUICellViewController
            swiftUICellViewController.view.backgroundColor = .clear
            
            layoutIfNeeded()
            
            parent.addChild(swiftUICellViewController)
            contentView.addSubview(swiftUICellViewController.view)
            swiftUICellViewController.view.translatesAutoresizingMaskIntoConstraints = false
            contentView.addConstraint(NSLayoutConstraint(item: swiftUICellViewController.view!, attribute: NSLayoutConstraint.Attribute.leading, relatedBy: NSLayoutConstraint.Relation.equal, toItem: contentView, attribute: NSLayoutConstraint.Attribute.leading, multiplier: 1.0, constant: 0.0))
            contentView.addConstraint(NSLayoutConstraint(item: swiftUICellViewController.view!, attribute: NSLayoutConstraint.Attribute.trailing, relatedBy: NSLayoutConstraint.Relation.equal, toItem: contentView, attribute: NSLayoutConstraint.Attribute.trailing, multiplier: 1.0, constant: 0.0))
            contentView.addConstraint(NSLayoutConstraint(item: swiftUICellViewController.view!, attribute: NSLayoutConstraint.Attribute.top, relatedBy: NSLayoutConstraint.Relation.equal, toItem: contentView, attribute: NSLayoutConstraint.Attribute.top, multiplier: 1.0, constant: 0.0))
            contentView.addConstraint(NSLayoutConstraint(item: swiftUICellViewController.view!, attribute: NSLayoutConstraint.Attribute.bottom, relatedBy: NSLayoutConstraint.Relation.equal, toItem: contentView, attribute: NSLayoutConstraint.Attribute.bottom, multiplier: 1.0, constant: 0.0))
        
            swiftUICellViewController.didMove(toParent: parent)
            swiftUICellViewController.view.layoutIfNeeded()
        }
    }
}

In your UITableViewController:

override func viewDidLoad() {
    super.viewDidLoad()
    tableView.register(HostingTableViewCell<Text>.self, forCellReuseIdentifier: "textCell")
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "textCell") as! HostingTableViewCell<Text>
    cell.host(Text("Yay!"), parent: self)
    return cell
}

Might turn this into a package if people seem to use it.

Kathrinkathrine answered 24/12, 2019 at 23:29 Comment(3)
Thas was very useful to me to get me off the ground, however I have a lot of trouble getting my rows in the UITableView to be the correct size now: My content is dynamic, essentially a text label with a short paragraph of text for each row. I have the problem that some Cells are too small, some are too large and none of the regular approaches to get them to the same size seem to work for me. Does anybody have figured this use case out too?Retrorocket
For the commenter who asked about dynamic cell sizes, I ran into a similar issue (after coming up with a similar solution to this answer). You'll want to call controller.view.invalidateIntrinsicContentSize() on in place of layoutIfNeeded here.Guaco
Any way to get an .onTapGesture modifier on the SwiftUI view working ? Or a NavigationLink?Mckinney
S
16

A slide modification to the answer to fix a memory leak as they only add the hosting controller as a child but never remove it.

final class HostingTableViewCell<Content: View>: UITableViewCell {
    private let hostingController = UIHostingController<Content?>(rootView: nil)
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        hostingController.view.backgroundColor = .clear
    }
    
    private func removeHostingControllerFromParent() {
        hostingController.willMove(toParent: nil)
        hostingController.view.removeFromSuperview()
        hostingController.removeFromParent()
    }
    
    deinit {
        // remove parent
        removeHostingControllerFromParent()
    }
    
    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func set(rootView: Content, parentController: UIViewController) {
        hostingController.rootView = rootView
        hostingController.view.invalidateIntrinsicContentSize()
        
        let requiresControllerMove = hostingController.parent != parentController
        if requiresControllerMove {
            // remove old parent if exists
            removeHostingControllerFromParent()
            parentController.addChild(hostingController)
        }
        
        if !contentView.subviews.contains(hostingController.view) {
            contentView.addSubview(hostingController.view)
            hostingController.view.translatesAutoresizingMaskIntoConstraints = false
            hostingController.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
            hostingController.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
            hostingController.view.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
            hostingController.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
        }
        
        if requiresControllerMove {
            hostingController.didMove(toParent: parentController)
        }
    }
}
Southworth answered 27/11, 2020 at 1:38 Comment(3)
Great solution! Any chance you know of a way to perform this with any UIView (not just UITableViewCells)? I tried a naive solution where I replaced override init(style:reuseIdentifier:) with override init(frame:) but the frame logic seems to clash with the constraints and the view does not appear properly on screen.Hapten
Actually, scratch that. As long as you manually add the UIView equivalent of the HostingTableViewCell it seems to work as expected. The one quirk I'm noticing, however, is that the Button included in my UIView is not selectable.Hapten
@EvanKaminsky could you share the solution you found to create a HostingView, the equivalent for HostingTableViewCell for UIView? That would be much appreciated.Humanism
G
4

I came to a similar solution for this issue as another answerer, but I realized that you don't need to force a layout pass using layoutIfNeeded if you set up constraints correctly and call invalidateIntrinsicContentSize(). I wrote about this in-depth here, but the UITableViewCell subclass that worked for me was:

final class HostingCell<Content: View>: UITableViewCell {
    private let hostingController = UIHostingController<Content?>(rootView: nil)

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        hostingController.view.backgroundColor = .clear
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func set(rootView: Content, parentController: UIViewController) {
        self.hostingController.rootView = rootView
        self.hostingController.view.invalidateIntrinsicContentSize()

        let requiresControllerMove = hostingController.parent != parentController
        if requiresControllerMove {
            parentController.addChild(hostingController)
        }

        if !self.contentView.subviews.contains(hostingController.view) {
            self.contentView.addSubview(hostingController.view)
            hostingController.view.translatesAutoresizingMaskIntoConstraints = false
            hostingController.view.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor).isActive = true
            hostingController.view.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor).isActive = true
            hostingController.view.topAnchor.constraint(equalTo: self.contentView.topAnchor).isActive = true
            hostingController.view.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor).isActive = true
        }

        if requiresControllerMove {
            hostingController.didMove(toParent: parentController)
        }
    }
}

You should be able to register it like a regular table cell, and call set(rootView:controller:) after dequeueing the cell to make it work.

Guaco answered 21/7, 2020 at 23:21 Comment(1)
I tried all of these implementations but the table view get messy on scrolling, although the content is just Text("Hello")Abbotsun
R
2

Discovered an answer on my own. The answer is hacky, but to take a cell and place a hosted controller as its content view.

func configureCellFromSwiftUIView(cell: UITableViewCell, rootView: AnyView){
    
    let swiftUICellViewController = UIHostingController(rootView: rootView)
    
    cell.layoutIfNeeded()
    cell.selectionStyle = UITableViewCell.SelectionStyle.none
    
    self.addChild(swiftUICellViewController)
    cell.contentView.addSubview(swiftUICellViewController.view)
    swiftUICellViewController.view.translatesAutoresizingMaskIntoConstraints = false
    cell.contentView.addConstraint(NSLayoutConstraint(item: swiftUICellViewController.view!, attribute: NSLayoutConstraint.Attribute.leading, relatedBy: NSLayoutConstraint.Relation.equal, toItem: cell.contentView, attribute: NSLayoutConstraint.Attribute.leading, multiplier: 1.0, constant: 0.0))
    cell.contentView.addConstraint(NSLayoutConstraint(item: swiftUICellViewController.view!, attribute: NSLayoutConstraint.Attribute.trailing, relatedBy: NSLayoutConstraint.Relation.equal, toItem: cell.contentView, attribute: NSLayoutConstraint.Attribute.trailing, multiplier: 1.0, constant: 0.0))
    cell.contentView.addConstraint(NSLayoutConstraint(item: swiftUICellViewController.view!, attribute: NSLayoutConstraint.Attribute.top, relatedBy: NSLayoutConstraint.Relation.equal, toItem: cell.contentView, attribute: NSLayoutConstraint.Attribute.top, multiplier: 1.0, constant: 0.0))
    cell.contentView.addConstraint(NSLayoutConstraint(item: swiftUICellViewController.view!, attribute: NSLayoutConstraint.Attribute.bottom, relatedBy: NSLayoutConstraint.Relation.equal, toItem: cell.contentView, attribute: NSLayoutConstraint.Attribute.bottom, multiplier: 1.0, constant: 0.0))
    
    swiftUICellViewController.didMove(toParent: self)
    swiftUICellViewController.view.layoutIfNeeded()
    
}
Rumph answered 26/8, 2019 at 15:24 Comment(0)
H
2

Maybe this is not relevant anymore unless you are targeting iOS 13, but when you start dequeuing reusable cells, things become unstable. Just make sure you add this in either solution

override func prepareForReuse() {
    super.prepareForReuse()
    controller?.view.removeFromSuperview()
    controller?.removeFromParent()
    controller = nil
}
Hackler answered 10/6, 2022 at 20:31 Comment(0)
O
0

If you are using iOS 16+, UIHostingConfiguration is an easy solution. You could do something like:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

 let cell: UITableViewCell = tableView.dequeueReusableCell(withIdentifier: "your cell id", for: indexPath)

 cell.contentConfiguration = UIHostingConfiguration {
  YourSwiftUIView()
 }

 return cell
}
Oballa answered 20/12, 2023 at 21:43 Comment(0)
H
0

Integrate SwiftUI Views in UICollectionViewCell(or UITableViewCell) using UIHostingConfiguration (iOS 16+). Here's an example:

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let item = tableData[indexPath.item]
    return collectionView.dequeueConfiguredReusableCell(using: tableRowRegistration, for: indexPath, item: item)
}

private var tableRowRegistration: UICollectionView.CellRegistration<UICollectionViewCell, TableData> = {
    .init { cell, indexPath, item in
        cell.contentConfiguration = UIHostingConfiguration {
            TableRow(table: item)
        }
        .margins(.vertical, 8)
        .margins(.horizontal, 14)
    }
}()
Haas answered 24/5 at 9:57 Comment(2)
TableRow(table: item) is a SwiftUI view, and TableData is the model data for the view. It doesn't have anything to do with UITableView.Haas
Thank you for your interest in contributing to the Stack Overflow community. This question already has quite a few answers—including one that has been extensively validated by the community. Are you certain your approach hasn’t been given previously? If so, it would be useful to explain how your approach is different, under what circumstances your approach might be preferred, and/or why you think the previous answers aren’t sufficient. Can you kindly edit your answer to offer an explanation?Maund

© 2022 - 2024 — McMap. All rights reserved.