Jon Reed
Jon Reed

Reputation: 653

How to use a SwiftUI view in place of table view cell

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

Answers (7)

ice
ice

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

Jim
Jim

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

Cristian Pena
Cristian Pena

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

mohamede1945
mohamede1945

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

cookednick
cookednick

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

Jon Reed
Jon Reed

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

Noah Gilmore
Noah Gilmore

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

Related Questions