具有自定义配置的 UICollectionView 列表 - 如何将单元格中的更改传递给视图控制器?

UICollectionView List With Custom Configuration - how to pass changes in cell to view controller?

我已经使用新的 iOS 14 API 实现了一个 UICollectionView 列表,其中包含自定义 UICollectionViewCellUIContentConfiguration。我一直在学习本教程:https://swiftsenpai.com/development/uicollectionview-list-custom-cell/(以及 Apple 的示例项目)

基本上你现在有一个 UICollectionViewCell、一个 UIContentConfiguration 和一个 UIContentViewcell 仅设置其配置,content configuration 保存单元格及其所有可能状态的数据,content view 是替换 [=22] 的实际 UIView =].

我让它工作了,它非常棒而且干净。但是有一件事我不明白:

您将如何向 UIContentView 添加回调,或者如何将单元格中所做的更改(例如 UISwitch 切换或 UITextField 更改)传达给 viewControllerviewController 和单元格之间的唯一联系是在创建 collectionView 的数据源时单元格注册内部:

// Cell
class Cell: UICollectionViewListCell {
    
    var event: Event?
    var onEventDidChange: ((_ event: Event) -> Void)?
    //...
}


// Example cell registration in ViewController
let eventCellRegistration = UICollectionView.CellRegistration<Event.Cell, Event> { [weak self] (cell, indexPath, event) in
    cell.event = event // Setting the data model for the cell
    // This is what I tried to do. A closure that the cell calls, whenever the cell made changes to the event (the model)
    cell.onEventDidChange = { event in /* update database */ }
}

这是我能想到的唯一可以放置此类连接的地方,如上例所示。但是,这不起作用,因为单元格不再对其内容负责。此闭包必须传递给正在为单元格创建实际视图的 UIContentView

单元格与其内容视图之间的唯一联系是内容配置,但不能将闭包作为属性,因为它们不是等式的。所以我无法建立连接。

有人知道怎么做吗?

谢谢!

您仍然可以使用 func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell 为单元格设置委托,只是您不必再创建它:

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
  let model = SOME_MODEL

  let cell = collectionView.dequeueConfiguredReusableCell(using: eventCellRegistration,
                                                      for: indexPath,
                                                      item: model)
  cell.delegate = self
  return cell
}

如果您正在编写自己的配置,则您负责其属性。所以让你的配置定义一个协议并给它一个 delegate 属性!单元格注册对象将视图控制器(或任何人)设置为配置的委托。内容视图配置 UISwitch 或任何向它发送信号的内容视图,内容视图将该信号传递给配置的委托。

一个工作示例

这是工作示例的完整代码。我选择使用 table 视图而不是集合视图,但这完全无关紧要;内容配置适用于两者。

您需要做的就是在视图控制器中放置一个 table 视图,使视图控制器成为 table 视图的数据源,并使 table 视图成为视图控制器的数据源tableView.

extension UIResponder {
    func next<T:UIResponder>(ofType: T.Type) -> T? {
        let r = self.next
        if let r = r as? T ?? r?.next(ofType: T.self) {
            return r
        } else {
            return nil
        }
    }
}
protocol SwitchListener : AnyObject {
    func switchChangedTo(_:Bool, sender:UIView)
}
class MyContentView : UIView, UIContentView {
    var configuration: UIContentConfiguration {
        didSet {
            config()
        }
    }
    let sw = UISwitch()
    init(configuration: UIContentConfiguration) {
        self.configuration = configuration
        super.init(frame:.zero)
        sw.translatesAutoresizingMaskIntoConstraints = true
        self.addSubview(sw)
        sw.center = CGPoint(x:self.bounds.midX, y:self.bounds.midY)
        sw.autoresizingMask = [.flexibleTopMargin, .flexibleBottomMargin, .flexibleLeftMargin, .flexibleRightMargin]
        sw.addAction(UIAction {[unowned sw] action in
            (configuration as? Config)?.delegate?.switchChangedTo(sw.isOn, sender:self)
        }, for: .valueChanged)
        config()
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    func config() {
        self.sw.isOn = (configuration as? Config)?.isOn ?? false
    }
}
struct Config: UIContentConfiguration {
    var isOn = false
    weak var delegate : SwitchListener?
    func makeContentView() -> UIView & UIContentView {
        return MyContentView(configuration:self)
    }
    func updated(for state: UIConfigurationState) -> Config {
        return self
    }
}
class ViewController: UIViewController, UITableViewDataSource {
    @IBOutlet var tableView : UITableView!
    var list = Array(repeating: false, count: 100)
    override func viewDidLoad() {
        super.viewDidLoad()
        self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
    }
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.list.count
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        var config = Config()
        config.isOn = list[indexPath.row]
        config.delegate = self
        cell.contentConfiguration = config
        return cell
    }
}
extension ViewController : SwitchListener {
    func switchChangedTo(_ newValue: Bool, sender: UIView) {
        if let cell = sender.next(ofType: UITableViewCell.self) {
            if let ip = self.tableView.indexPath(for: cell) {
                self.list[ip.row] = newValue
            }
        }
    }
}

该示例的关键部分

好吧,它可能看起来很多,但对于任何具有自定义内容配置的 table 视图来说,它大部分都是纯样板文件。唯一有趣的部分是 SwitchListener 协议及其实现,以及内容视图初始化程序中的 addAction 行;这就是这个答案的第一段描述的内容。

因此,在内容视图的初始化程序中:

sw.addAction(UIAction {[unowned sw] action in
    (configuration as? Config)?.delegate?.switchChangedTo(sw.isOn, sender:self)
}, for: .valueChanged)

并且在扩展中,响应该调用的方法:

func switchChangedTo(_ newValue: Bool, sender: UIView) {
    if let cell = sender.next(ofType: UITableViewCell.self) {
        if let ip = self.tableView.indexPath(for: cell) {
            self.list[ip.row] = newValue
        }
    }
}

另一种方法

该答案仍然使用 protocol-and-delegate 架构,OP 宁愿不这样做。现代的方法是提供一个 属性 ,其值是一个函数,可以直接调用 .

所以我们没有给我们的配置一个委托,而是给它一个回调 属性:

struct Config: UIContentConfiguration {
    var isOn = false
    var isOnChanged : ((Bool, UIView) -> Void)?

内容视图的初始化程序配置界面元素,以便在它发出信号时调用 isOnChanged 函数:

sw.addAction(UIAction {[unowned sw] action in
    (configuration as? Config)?.isOnChanged?(sw.isOn, self)
}, for: .valueChanged)

它只是为了显示 isOnChanged 函数 是什么 。在我的示例中,它与之前架构中的委托方法完全相同。所以,当我们配置单元格时:

config.isOn = list[indexPath.row]
config.isOnChanged = { [weak self] isOn, v in
    if let cell = v.next(ofType: UITableViewCell.self) {
        if let ip = self?.tableView.indexPath(for: cell) {
            self?.list[ip.row] = isOn
        }
    }
}

cell.contentConfiguration = config

所以我想我想出了一个不使用委托的替代解决方案。

对于这个例子,我有一个数据模型 Event 只包含年份和名字,collectionView 只显示所有事件:


struct Event: Identifiable, Codable, Hashable {
    let id: UUID
    var date: Date
    var name: String
    var year: Int { ... }
    //...
}

extension Event {
    
    // The collection view cell
    class Cell: UICollectionViewListCell {
       
        // item is an abstraction to the event type. In there, you can put closures that the cell can call
        var item: ContentConfiguration.Item?
        
        override func updateConfiguration(using state: UICellConfigurationState) {
            let newBackgroundConfiguration = UIBackgroundConfiguration.listGroupedCell()
            backgroundConfiguration = newBackgroundConfiguration
            
            var newConfiguration = Event.ContentConfiguration().updated(for: state)
            
            // Assign the item to the new configuration
            newConfiguration.item = item
            
            contentConfiguration = newConfiguration
        }
    }
    
    struct ContentConfiguration: UIContentConfiguration, Hashable {
        
        /// The view model associated with the configuration. It handles the data that the cell holds but is not responsible for stuff like `nameColor`, which goes directly into the configuration struct.
        struct Item: Identifiable, Hashable {
            var id = UUID()
            var event: Event? = nil
            var onNameChanged: ((_ newName: String) -> Void)? = nil
            var isDraft: Bool = false
            
            // This is needed for being Hashable. You should never modify an Item, simply create a new instance every time. That's fast because it's a struct.
            static func == (lhs: Item, rhs: Item) -> Bool {
                return lhs.id == rhs.id
            }
            
            func hash(into hasher: inout Hasher) {
                hasher.combine(id)
            }
        }
        
        /// The associated view model item.
        var item: Item?
        
        // Other stuff the configuration is handling
        var nameColor: UIColor?
        var nameEditable: Bool?
        
        func makeContentView() -> UIView & UIContentView {
            ContentView(configuration: self)
        }
        
        func updated(for state: UIConfigurationState) -> Event.ContentConfiguration {
            guard let state = state as? UICellConfigurationState else { return self }
            
            var updatedConfiguration = self
            
            // Example state-based change to switch out the label with a text field
            if state.isSelected {
                updatedConfiguration.nameEditable = true
            } else {
                updatedConfiguration.nameEditable = false
            }
            
            return updatedConfiguration
        }
        
    }
    
    // Example content view. Simply showing the year and name
    class ContentView: UIView, UIContentView, UITextFieldDelegate {
        private var appliedConfiguration: Event.ContentConfiguration!
        var configuration: UIContentConfiguration {
            get {
                appliedConfiguration
            }
            set {
                guard let newConfiguration = newValue as? Event.ContentConfiguration else {
                    return
                }
                
                apply(configuration: newConfiguration)
            }
        }
        
        let yearLabel: UILabel = UILabel()
        let nameLabel: UILabel = UILabel()
        let nameTextField: UITextField = UITextField()
        
        init(configuration: Event.ContentConfiguration) {
            super.init(frame: .zero)
            setupInternalViews()
            apply(configuration: configuration)
        }
        
        required init?(coder: NSCoder) {
            fatalError()
        }
        
        private func setupInternalViews() {
            addSubview(yearLabel)
            addSubview(nameLabel)
            addSubview(nameTextField)
            
            nameTextField.borderStyle = .roundedRect
            
            nameTextField.delegate = self
            yearLabel.textAlignment = .center
            
            yearLabel.translatesAutoresizingMaskIntoConstraints = false
            nameLabel.translatesAutoresizingMaskIntoConstraints = false
            
            yearLabel.snp.makeConstraints { (make) in
                make.leading.equalToSuperview().offset(12)
                make.top.equalToSuperview().offset(12)
                make.bottom.equalToSuperview().offset(-12)
                make.width.equalTo(80)
            }
            
            nameLabel.snp.makeConstraints { (make) in
                make.leading.equalTo(yearLabel.snp.trailing).offset(10)
                make.top.equalToSuperview().offset(12)
                make.bottom.equalToSuperview().offset(-12)
                make.trailing.equalToSuperview().offset(-12)
            }
            
            nameTextField.snp.makeConstraints { (make) in
                make.leading.equalTo(yearLabel.snp.trailing).offset(10)
                make.top.equalToSuperview().offset(12)
                make.bottom.equalToSuperview().offset(-12)
                make.trailing.equalToSuperview().offset(-12)
            }
        }
        
        /// Apply a new configuration.
        /// - Parameter configuration: The new configuration
        private func apply(configuration: Event.ContentConfiguration) {
            guard appliedConfiguration != configuration else { return }
            appliedConfiguration = configuration
            
            yearLabel.text = String(configuration.item?.event?.year ?? 0)
            nameLabel.text = configuration.item?.event?.name
            nameLabel.textColor = configuration.nameColor
            
            if configuration.nameEditable == true {
                nameLabel.isHidden = true
                nameTextField.isHidden = false
                nameTextField.text = configuration.item?.event?.name
            } else {
                nameLabel.isHidden = false
                nameTextField.isHidden = true
            }
        }
        
        
        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
            textField.resignFirstResponder()
            // Simply use the item to call the given closure
            appliedConfiguration.item?.onNameChanged?(nameTextField.text ?? "")
            return true
        }
    }
}

单元格注册如下所示:

let eventCellRegistration = UICollectionView.CellRegistration<Event.Cell, Event> { [weak self] (cell, indexPath, event) in
    
    var item = Event.ContentConfiguration.Item()
    item.event = event
    item.onNameChanged = { [weak self] newName in
        // Do what you need to do with the changed value, i.e. send it to your data provider in order to update the database with the changed data
    }
    
}

这将配置部分完全保留在单元格中,并且只将相关内容公开给视图控制器中的单元格注册过程。

我不完全确定这是最好的方法,但它现在似乎有效。