SwiftUI 包装 UICollectionView 并使用组合布局

Posted

技术标签:

【中文标题】SwiftUI 包装 UICollectionView 并使用组合布局【英文标题】:SwiftUI wrapping UICollectionView and use compositional layout 【发布时间】:2020-04-04 19:55:59 【问题描述】:

我尝试通过 UIViewRepresentable 在 SwiftUI 中实现 CollectionView 并将 SwiftUI 视图用作可重用的单元格,但单元格根本没有出现。似乎在 SwiftUI 中包装集合视图和使用组合布局是不可能的,或者我可能遗漏了一些东西。

我知道我可以完全在 UIKit 中实现这个 CollectionView 并且只包装所有视图控制器,但是如果我想使用 SwiftUI 视图作为 UICollectionView 的单元格呢?

当我使用 UICollectionViewFlowLayout() 视图正确显示

import SwiftUI
import UIKit

struct CollectionView<Section: Hashable & CaseIterable, Item: Hashable>: UIViewRepresentable 

    // MARK: - Properties
    let layout: UICollectionViewLayout
    let sections: [Section]
    let items: [Section: [Item]]

    // MARK: - Actions
    let snapshot: (() -> NSDiffableDataSourceSnapshot<Section, Item>)?
    let content: (_ indexPath: IndexPath, _ item: Item) -> AnyView

    // MARK: - Init
    init(layout: UICollectionViewLayout,
         sections: [Section],
         items: [Section: [Item]],
         snapshot: (() -> NSDiffableDataSourceSnapshot<Section, Item>)? = nil,
         @ViewBuilder content: @escaping (_ indexPath: IndexPath, _ item: Item) -> AnyView) 
        self.layout = layout

        self.sections = sections
        self.items = items

        self.snapshot = snapshot
        self.content = content
    


    func makeCoordinator() -> Coordinator 
        Coordinator(self)
    

    func generateLayout() -> UICollectionViewLayout 

        let itemHeightDimension = NSCollectionLayoutDimension.absolute(44)
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeightDimension)
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeightDimension)
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 1)

        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)

        let layout = UICollectionViewCompositionalLayout(section: section)
        return layout
    

    func makeUIView(context: Context) -> UICollectionView 

        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
        collectionView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
        collectionView.backgroundColor = .clear

        collectionView.delegate = context.coordinator

        collectionView.register(HostingControllerCollectionViewCell<AnyView>.self, forCellWithReuseIdentifier: HostingControllerCollectionViewCell<AnyView>.reuseIdentifier)

        context.coordinator.dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: context.coordinator.cellProvider)


        return collectionView
    

    func updateUIView(_ uiView: UICollectionView, context: Context) 

        context.coordinator.dataSource.apply( (snapshot ?? reloadSnaphot)() , animatingDifferences: true)
    

    func reloadSnaphot() -> NSDiffableDataSourceSnapshot<Section, Item> 

        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections(Array(items.keys))

        items.forEach  (section, items) in
            snapshot.appendItems(items, toSection: section)
        

        return snapshot
    

    class Coordinator: NSObject, UICollectionViewDelegate 

        var dataSource: UICollectionViewDiffableDataSource<Section, Item>! = nil

        // MARK: - Properties
        let parent: CollectionView

        // MARK: - Init
        init(_ parent: CollectionView) 
            self.parent = parent
        

        func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) 

            print("Did select item at \(indexPath)")
        


        // MARK: - Cell Provider
        func cellProvider(collectionView: UICollectionView, indexPath: IndexPath, item: Item) -> UICollectionViewCell? 

            print("Providing cell for \(indexPath)")
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HostingControllerCollectionViewCell<AnyView>.reuseIdentifier, for: indexPath) as? HostingControllerCollectionViewCell<AnyView> else 
                fatalError("Coult not load cell!")
            

            cell.host(parent.content(indexPath, item))
            //cell.host(rootView: parent.content(indexPath, item))
            return cell
        
    


class HostingControllerCollectionViewCell<Content: View> : UICollectionViewCell 

    weak var controller: UIHostingController<Content>?

    func host(_ view: Content, parent: UIViewController? = nil) 

        if let controller = controller 
            controller.rootView = view
            controller.view.layoutIfNeeded()
         else 
            let controller = UIHostingController(rootView: view)
            self.controller = controller
            controller.view.backgroundColor = .clear

            layoutIfNeeded()

            parent?.addChild(controller)
            contentView.addSubview(controller.view)
            controller.view.translatesAutoresizingMaskIntoConstraints = false

            NSLayoutConstraint.activate([
                controller.view.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor),
                controller.view.trailingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.trailingAnchor),
                controller.view.topAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.topAnchor),
                controller.view.bottomAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.bottomAnchor)
            ])

            if let parent = parent 
                controller.didMove(toParent: parent)
            
            controller.view.layoutIfNeeded()
        
    


【问题讨论】:

【参考方案1】:

我已经将它改写成 UIViewControllerRepresentable 并且现在可以正常工作了

struct CollectionView<Section: Hashable & CaseIterable, Item: Hashable>: UIViewControllerRepresentable 

    // MARK: - Properties
    let layout: UICollectionViewLayout
    let sections: [Section]
    let items: [Section: [Item]]

    // MARK: - Actions
    let snapshot: (() -> NSDiffableDataSourceSnapshot<Section, Item>)?
    let content: (_ indexPath: IndexPath, _ item: Item) -> AnyView

    // MARK: - Init
    init(layout: UICollectionViewLayout,
         sections: [Section],
         items: [Section: [Item]],
         snapshot: (() -> NSDiffableDataSourceSnapshot<Section, Item>)? = nil,
         @ViewBuilder content: @escaping (_ indexPath: IndexPath, _ item: Item) -> AnyView) 
        self.layout = layout

        self.sections = sections
        self.items = items

        self.snapshot = snapshot
        self.content = content
    


    func makeCoordinator() -> Coordinator 
        Coordinator(self)
    

    func makeUIViewController(context: Context) -> CollectionViewController<Section, Item> 

        let controller = CollectionViewController<Section, Item>()

        controller.layout = self.layout

        controller.snapshotForCurrentState = 

            if let snapshot = self.snapshot 
                return snapshot()
            

            var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()

            snapshot.appendSections(self.sections)

            self.sections.forEach  section in
                snapshot.appendItems(self.items[section]!, toSection: section)
            

            return snapshot
        

        controller.content = content

        controller.collectionView.delegate = context.coordinator

        return controller
    

    func updateUIViewController(_ uiViewController: CollectionViewController<Section, Item>, context: Context) 

        uiViewController.updateUI()
    


    class Coordinator: NSObject, UICollectionViewDelegate 

        // MARK: - Properties
        let parent: CollectionView

        // MARK: - Init
        init(_ parent: CollectionView) 
            self.parent = parent
        

        func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) 

            print("Did select item at \(indexPath)")
        
    


struct CollectionView_Previews: PreviewProvider 

    enum Section: CaseIterable 
        case features
        case categories
    

    enum Item: Hashable 
        case feature(feature: Feature)
        case category(category: Category)
    

    class Feature: Hashable
        let id: String
        let title: String

        init(id: String, title: String) 
            self.id = id
            self.title = title
        

        func hash(into hasher: inout Hasher) 
            hasher.combine(self.id)
        

        static func ==(lhs: Feature, rhs: Feature) -> Bool 
            lhs.id == rhs.id
        
    

    class Category: Hashable 
        let id: String
        let title: String

        init(id: String, title: String) 
            self.id = id
            self.title = title
        

        func hash(into hasher: inout Hasher) 
            hasher.combine(self.id)
        

        static func ==(lhs: Category, rhs: Category) -> Bool 
            lhs.id == rhs.id
        
    

    static let items: [Section: [Item]] = 
        return [
            .features : [
                .feature(feature: Feature(id: "1", title: "Feature 1")),
                .feature(feature: Feature(id: "2", title: "Feature 2")),
                .feature(feature: Feature(id: "3", title: "Feature 3"))
            ],
            .categories : [
                .category(category: Category(id: "1", title: "Category 1")),
                .category(category: Category(id: "2", title: "Category 2")),
                .category(category: Category(id: "3", title: "Category 3"))
            ]
        ]
    ()

    static var previews: some View 

        func generateLayout() -> UICollectionViewLayout 

            let itemHeightDimension = NSCollectionLayoutDimension.absolute(44)
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: itemHeightDimension)
            let item = NSCollectionLayoutItem(layoutSize: itemSize)

            let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: itemHeightDimension)
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 2)

            let section = NSCollectionLayoutSection(group: group)
            section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)

            let layout = UICollectionViewCompositionalLayout(section: section)
            return layout
        

        return CollectionView(layout: generateLayout(), sections: [.features], items: items, snapshot: 

            var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
            snapshot.appendSections(Section.allCases)

            items.forEach  (section, items) in
                snapshot.appendItems(items, toSection: section)
            

            return snapshot

        )  (indexPath, item) -> AnyView in

            switch item 
            case .feature(let item):
                return AnyView(Text("Feature \(item.title)"))
            case .category(let item):
                return AnyView(Text("Category \(item.title)"))
            
        

    


class CollectionViewController<Section, Item>: UIViewController
    where Section : Hashable & CaseIterable, Item : Hashable 

    var layout: UICollectionViewLayout! = nil
    var snapshotForCurrentState: (() -> NSDiffableDataSourceSnapshot<Section, Item>)! = nil
    var content: ((_ indexPath: IndexPath, _ item: Item) -> AnyView)! = nil

    lazy var dataSource: UICollectionViewDiffableDataSource<Section, Item> = 
        let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: cellProvider)
        return dataSource
    ()

    lazy var collectionView: UICollectionView = 
        let collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
        collectionView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
        collectionView.backgroundColor = .clear
        return collectionView
    ()

    override func viewDidLoad() 
        super.viewDidLoad()

        configureCollectionView()
        configureDataSource()
    


extension CollectionViewController 

    private func configureCollectionView() 
        view.addSubview(collectionView)

        collectionView.register(HostingControllerCollectionViewCell<AnyView>.self, forCellWithReuseIdentifier: HostingControllerCollectionViewCell<AnyView>.reuseIdentifier)
    


    private func configureDataSource() 

        // load initial data
        let snapshot : NSDiffableDataSourceSnapshot<Section, Item> = snapshotForCurrentState()
        dataSource.apply(snapshot, animatingDifferences: false)
    

    private func cellProvider(collectionView: UICollectionView, indexPath: IndexPath, item: Item) -> UICollectionViewCell? 

        print("Providing cell for \(indexPath)")

        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HostingControllerCollectionViewCell<AnyView>.reuseIdentifier, for: indexPath) as? HostingControllerCollectionViewCell<AnyView> else 
            fatalError("Coult not load cell!")
        

        cell.host(content(indexPath, item))

        return cell
    


extension CollectionViewController 

    func updateUI() 
        let snapshot : NSDiffableDataSourceSnapshot<Section, Item> = snapshotForCurrentState()
        dataSource.apply(snapshot, animatingDifferences: true)
    

【讨论】:

以上是关于SwiftUI 包装 UICollectionView 并使用组合布局的主要内容,如果未能解决你的问题,请参考以下文章

在 SwiftUI 中使用 @State 属性包装器未更新视图

SwiftUI - 使用自定义绘图包装 UIView 时出现 UIViewRepresentable 故障

SwiftUI - 在视图中包装 Button,以创建自定义按钮

SwiftUI CollectionView 包装器无法从 updateUIViewController 应用快照

自定义属性包装器无法准确反映我在 SwiftUI 中的 TextField 的状态,知道为啥吗?

如何在 SwiftUI 视图中包装 UIBarButtonItem?