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!
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.
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
}
}
}
}
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
}
}
}
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With