Reputation: 653
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?
Upvotes: 31
Views: 17952
Reputation: 11
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)
}
}()
Upvotes: 1
Reputation: 65
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
}
Upvotes: 2
Reputation: 2229
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
}
Upvotes: 4
Reputation: 7198
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)
}
}
}
Upvotes: 17
Reputation: 1088
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.
Upvotes: 37
Reputation: 653
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()
}
Upvotes: 2
Reputation: 1389
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.
Upvotes: 4