如何使用SwiftUI视图替换表格视图单元格

30

我如何在UITableViewController中使用SwiftUI视图结构体代替传统的cell和xib?

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
    }
} ...

问题显而易见,即UIHostedController SwiftUI视图不是表格单元格,但我该如何像使用表格单元格一样使用它?


你尝试过什么?你知道如何使用UIHostingController导入SwiftUI View吗?能否请您展示一些代码,更具体一些,不要那么模糊? - user7014451
我想在UITableViewController中使用SwiftUI视图来替换UITableViewCell。尝试使用UIHostingController注册单元格或使其可重用将无法正常工作。目前,我只是在单元格中嵌入一个UIView,然后让XIB渲染出一个UIHostingController,但我认为这不是最佳方法。 - Jon Reed
@dfd 代码已添加示例。 - Jon Reed
我敢打赌,在表格视图单元格中嵌入UIViewControllers的问题以前已经有答案了。 - Fabian
首先,我撤回了我的负评和关闭投票 - 因为你的问题确实是含糊不清的。话虽如此,我可以问一个实际的问题吗?SwiftUI将在一个月内仅适用于iOS 13。通过使用UIKit中的某些深度东西 - UITableView - 并通过将SwiftUI视图嵌入UITableCell而更深入地进行嵌套,您能获得什么?特别是当SwiftUI已经有一个完整的替代品 - 列表?我会尝试创建完整的列表,包括所有行/单元格,并在UIKit应用程序中使用UIHostingController。正如您所看到的,您将遇到注册和排队的问题。带着尊重的意思... - user7014451
1
@dfd 没有任何不敬之意。我们在一个非常大的项目中有很多遗留的UIKit代码,希望逐步转换为SwiftUI。我已经想出了解决方法,并将更新我的答案。 - Jon Reed
5个回答

36

感谢您在这里回答自己的问题。您的解决方案帮助我创建了一个通用的HostingTableViewCell类。如果有人像我一样在谷歌上找到这个问题,我将在这里发布它。


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()
        }
    }
}

在您的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
}

如果人们似乎在使用它,我可能会将其打包成一个软件包。


2
这对我非常有用,让我起步了,但是现在我在UITableView中遇到了很多问题,无法正确设置行的大小:我的内容是动态的,每一行基本上都是一个带有短段落文本的文本标签。我的问题是有些单元格太小,有些太大,而且没有任何常规方法可以让它们达到相同的大小,似乎对我不起作用。有人解决了这个问题吗? - SirVer
5
对于问动态单元格大小的评论者,我遇到了类似的问题(在想出类似解决方案后)。在这里,你需要调用controller.view.invalidateIntrinsicContentSize()而不是layoutIfNeeded - Noah Gilmore
有没有办法让 SwiftUI 视图上的 .onTapGesture 修饰符起作用?或者使用 NavigationLink? - Chris
有没有办法让 SwiftUI 视图上的 .onTapGesture 修饰符起作用?或者使用 NavigationLink? - undefined

15

对答案进行幻灯片修改以修复内存泄漏问题,因为他们只将托管控制器添加为子项,但从未将其删除。

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)
        }
    }
}

很棒的解决方案!你知道是否有一种方法可以对任何UIView(而不仅仅是UITableViewCells)执行此操作吗?我尝试了一个天真的解决方案,其中我用override init(frame:)替换了override init(style:reuseIdentifier:),但frame逻辑似乎与约束冲突,视图在屏幕上显示不正确。 - Evan Kaminsky
实际上,忘掉那个。只要手动添加HostingTableViewCell的UIView等效部分,它似乎按预期工作。然而,我注意到一个小问题,就是我的UIView中包含的按钮无法选择。 - Evan Kaminsky
@EvanKaminsky,你能分享一下你找到的创建HostingView的解决方案吗?这相当于UIView中的HostingTableViewCell。非常感谢。 - Plato

4

我对这个问题的解决方案与另一个回答者类似,但我意识到如果您正确设置约束并调用 invalidateIntrinsicContentSize(),则不需要使用 layoutIfNeeded 强制布局。我在 这里 进行了详细介绍,但适合我的 UITableViewCell 子类是:

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)
        }
    }
}

您应该能够像普通的表格单元格一样注册它,并在出列单元格后调用set(rootView:controller:),使其起作用。


我尝试了所有这些实现,但在滚动时表格视图变得混乱,尽管内容只是 Text("Hello") - Hesham Ali Kamal

2

我自己找到了一个答案。这个答案虽然有些hacky,但是可以将托管控制器放置在单元格中作为其内容视图。

"Original Answer"翻译成中文是"最初的回答"。

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()
    
}

1
也许这已经不相关了,除非你的目标是iOS 13,但当你开始出列可重用的单元格时,事情变得不稳定。 只需确保在任一解决方案中添加此内容。
override func prepareForReuse() {
    super.prepareForReuse()
    controller?.view.removeFromSuperview()
    controller?.removeFromParent()
    controller = nil
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接