7

I've implemented a UICollectionView list with custom a custom UICollectionViewCell and UIContentConfiguration using the new iOS 14 API. I've been following this tutorial: https://swiftsenpai.com/development/uicollectionview-list-custom-cell/ (alongside Apple's example project)

Basically you now have a UICollectionViewCell, a UIContentConfiguration and a UIContentView. The cell merely sets up its configuration, the content configuration holds the data for the cell and all its possible states, and the content view is the actual UIView that replaces UICollectionViewCell.contentView.

I got it working and it's quite awesome and clean. But there's one thing I don't understand:

How would you add callbacks to the UIContentView, or something to communicate changes made in the cell (UISwitch toggle or UITextField change, for example) to the viewController? The only connection between viewController and cell is inside the cell registration when creating the collectionView's data source:

// 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 */ }
}

That's the only place I can think of where you could put such a connection, as given in the example above. However, this does not work because the cell isn't responsible for its content anymore. This closure has to be passed along to the UIContentView that's creating the actual views for the cell.

The only connection between the cell and its content view is the content configuration but that cannot have closures as properties because they aren't equatable. So I can't establish a connection.

Does anyone know how one would do that?

Thanks!

SwiftedMind
  • 3,701
  • 3
  • 29
  • 63
  • This question is different from the other one! Please reread that question. The other one is about cell refreshing, this about communication between cell and view controller. Very different! – SwiftedMind Sep 24 '20 at 15:32
  • 1
    Please reopen this question. It is not a duplicate – SwiftedMind Sep 24 '20 at 17:28

3 Answers3

9

If you are writing your own configuration, you are in charge of its properties. So have your configuration define a protocol and give it a delegate property! The cell registration object sets the view controller (or whoever) as the configuration's delegate. The content view configures the UISwitch or whatever to signal to it, the content view, and the content view passes that signal along to the configuration's delegate.

A Working Example

Here's the complete code for a working example. I chose to use a table view instead of a collection view, but that's completely irrelevant; a content configuration applies to both.

All you need to do is put a table view in your view controller, make the view controller the table view's data source, and make the table view the view controller's 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
            }
        }
    }
}

The Key Parts of That Example

Okay, it may look like a lot, but it's mostly pure boilerplate for any table view with a custom content configuration. The only interesting part is the SwitchListener protocol and its implementation, and the addAction line in the content view's initializer; that's the stuff that the first paragraph of this answer describes.

So, in the content view's initializer:

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

And in the extension, the method that responds to that call:

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

An Alternative Approach

That answer still uses a protocol-and-delegate architecture, and the OP would rather not do that. The modern way is to supply a property whose value is a function that can be called directly.

So instead of giving our configuration a delegate, we give it a callback property:

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

The content view's initializer configures the interface element so that when it emits a signal, the isOnChanged function is called:

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

It remains only to show what the isOnChanged function is. In my example, it's exactly the same as the delegate method from the previous architecture. So, when we configure the cell:

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
matt
  • 515,959
  • 87
  • 875
  • 1,141
  • Hm, that would probably work, yeah. Thanks for the idea. I just don't like delegation at all and try to not use it but maybe it's useful here. One thing though: From the cell registration I can't access the configuration directly, this is only done inside the cell's subclass (that's how Apple does it in their examples. It makes the cell registration very clean). So I'd have to create two delegates, one for the cell and one for the configuration – SwiftedMind Sep 24 '20 at 18:15
  • I don't know what you mean by two delegates. If you don't like the delegate idea, call it something else. Most of us have been using a configuration object for years — Apple is very late to this game — and including a reference to `self` in the configure call so that the cell can call back when something happens in the cell is standard procedure. If you really don't like it, hey, you can use a nil-targeted action or something and just let the event flow up the responder chain. But I don't see that as any improvement. – matt Sep 24 '20 at 18:53
  • Thanks for this, I think I understand what you mean. Maybe that's the best way in this case. However, inside your `cellForRowAt` you create the content configuration and assign it to the cell. I do that inside the cell class' `updateConfiguration(using:)` method, so I don't have access to the configuration inside `cellForRowAt`, and therefore I can't assign the delegate there. I have to assign a delegate to the cell, and inside the cell's `updateConfiguration(using:)` I can forward that delegate to the configuration. – SwiftedMind Sep 25 '20 at 06:52
  • As far as I understand, Apple recommends doing it like this (the configuration part at least) and it separates the code a bit more. But in this case, it just makes everything more complicated, I think – SwiftedMind Sep 25 '20 at 06:54
  • I think I've found an alternative solution that doesn't use delegates and still keeps the configuration setup inside the cell class. I've posted it in an answer. I'm gonna try both and experiment a bit what works better – SwiftedMind Sep 25 '20 at 10:47
2

So I think I've come up with an alternative solution that doesn't use delegates.

For this example, I have a data model Event that just holds a year and a name, and the collectionView simply displays all events:


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

The cell registration then looks like this:

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

This keeps the configuration part entirely inside the cell and just exposes relevant stuff to the cell registration process in the view controller.

I'm not entirely sure that this is the best approach but it seems to work right now.

SwiftedMind
  • 3,701
  • 3
  • 29
  • 63
  • I appreciate the idea that must lie at the heart of this, and I'll modify my own answer to include it. But as it stands, we have no idea what the key line `self?.eventsProvider.update(event)` refers to: there is no `eventsProvider` or `update` anywhere in the code you have shown us. So this is not a useful presentation of what you are suggesting. – matt Sep 25 '20 at 16:36
  • Sorry, forgot to remove that part. `eventsProvider` is just a class that handles the fetching of events (from a database). The `update` method simply writes the changed `event` to the database. I didn't include `eventProvider` in the code because it's not related to the core idea. In the end, you can do whatever you want inside `onNameChanged`, in this case I just update the database – SwiftedMind Sep 25 '20 at 16:41
  • As I said, I like your idea of _passing_ a function rather than using a protocol-delegate architecture and giving, as it were, the mere _name_ of a function. I've added that to my own answer, so it remains a complete working solution using either approach. – matt Sep 25 '20 at 16:44
  • Thanks, I'm going to accept your answer as it's the most complete one. Appreciate your help! – SwiftedMind Sep 25 '20 at 16:49
  • Warning, warning, my solution had a huge memory leak in it. Fixing that now, and be sure to check this in your own solution too. When you pass functions around, you need to use `weak self`. – matt Sep 25 '20 at 16:56
  • Good catch, I've updated my answer as well. Haven't referenced self inside the closure but this is probably still a good idea to make it weak, just in case anyone wants to try it – SwiftedMind Sep 25 '20 at 17:00
0

You can still use func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell for setting a delegate to the cell, just you don't have to create it anymore:

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
}
Itay Brenner
  • 800
  • 6
  • 19
  • That could work, yes. But I don't really want to use the old way. It's easy with the old way but I want to try the new techniques introduced over the past two years. – SwiftedMind Sep 24 '20 at 18:16