collectionview global header does not push cells down and stays on top instead - ios

I have a collection view header (blue) and cells (red). I want to be able to show/hide header programmatically, however when I show the header programatically it appears on top of the cell (or makes scrollview go down a bit). I would like the header to push the whole scrollview down, so I wouldn't have to scroll up after clicking "Toggle header".
I tried very hard to reproduce minimal code for the issue which is below. Please share any insights.
struct Section: Hashable {
var items: [Int]
}
class ViewController: UIViewController {
var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Section, Int>?
var showHeader = true
override func viewDidLoad() {
super.viewDidLoad()
collectionView = UICollectionView(frame: view.frame, collectionViewLayout: createCompositionalLayout())
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.backgroundColor = .systemBackground
collectionView.register(CellView.self, forCellWithReuseIdentifier: "cellId")
collectionView.register(HeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "headerId")
view.addSubview(collectionView)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
collectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
createDataSource()
addData()
}
func createCompositionalLayout() -> UICollectionViewLayout {
// layout for cell
let layout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize)
layoutItem.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0)
let layoutGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(200))
let layoutGroup = NSCollectionLayoutGroup.vertical(layoutSize: layoutGroupSize, subitems: [layoutItem])
let layoutSection = NSCollectionLayoutSection(group: layoutGroup)
return layoutSection
}
let config = UICollectionViewCompositionalLayoutConfiguration()
if showHeader {
let layoutSectionHeader = createGlobalHeader()
config.boundarySupplementaryItems = [layoutSectionHeader]
}
layout.configuration = config
return layout
}
func createGlobalHeader() -> NSCollectionLayoutBoundarySupplementaryItem {
let layoutSectionHeaderSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50))
let layoutSectionHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: layoutSectionHeaderSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
layoutSectionHeader.pinToVisibleBounds = true
return layoutSectionHeader
}
func createDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Int>(collectionView: collectionView) { collectionView, indexPath, item in
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cellId", for: indexPath) as? CellView else { fatalError("Unable to dequeue ") }
cell.button.addTarget(self, action: #selector(self.onButtonClick), for: .touchUpInside)
return cell
}
dataSource?.supplementaryViewProvider = { (collectionView, kind, indexPath) in
guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "headerId", for: indexPath) as? HeaderView else { return nil }
return header
}
}
#objc func onButtonClick() {
print("toggle")
showHeader.toggle()
collectionView.collectionViewLayout = createCompositionalLayout()
collectionView.layoutIfNeeded()
}
func addData() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Int>()
var sections: [Section] = []
sections.append(Section(items: [0,1,2,3,4,5,6,7,8,9,10,11,12]))
snapshot.appendSections(sections)
for section in sections {
snapshot.appendItems(section.items, toSection: section)
}
dataSource?.apply(snapshot)
}
}
class CellView: UICollectionViewCell {
let button = UIButton()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .red
addSubview(button)
button.setTitle("Toggle header", for: .normal)
button.setTitleColor(.black, for: .normal)
button.translatesAutoresizingMaskIntoConstraints = false
button.topAnchor.constraint(equalTo: topAnchor).isActive = true
button.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class HeaderView: UICollectionReusableView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .blue
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

If I got your question right, you can scroll the collection view to top with:
collectionView.setContentOffset(.zero, animated: false)
whenever you want to display header.
It's a work around but I think it should do the trick.

I think you need to embed the collection view and a regular view(that you will use as a header) in a Stack View, and programmatically hide and show the header view. This will make the collectionView expand in the stack view when the header is hidden and vise-versa. I would completely abandon the UICollectionReusableView header view. So the view hierarchy would look something like this:
StackView
HeaderView
CollectionView
I hope I explained myself clear enough.

Related

Simple orthogonal collection layout, but has odd behaviour on tvOS. Not sure if it's me?

My ViewController loads and lays out correctly. This is a pretty simple layout! Works as expected on iOS, but I have weird issues on tvOS.
So - at first all looks good. But when the user scrolls down, then scrolls back to the top - the collectionview stays partially under the titlebar. Focus then moves to the barItem, which is correct - but the top section header is under the bar. If the user then scrolls the top section right (to load new cells) - the layout gets fixed. Once this has happened the whole view works as expected.
Some code:
class ViewController: UIViewController {
var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Int, String>!
override var preferredFocusEnvironments: [UIFocusEnvironment] {
return [collectionView]
}
override func viewDidLoad() {
super.viewDidLoad()
setup()
makeDataSource()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationItem.title = "Some Title"
navigationItem.leftBarButtonItem = .init(systemItem: .refresh)
applySnapshot()
}
// Hack..
override func viewSafeAreaInsetsDidChange() {
collectionView.contentInset.top = view.safeAreaInsets.top
collectionView.layoutMargins = .init(top: 0, left: view.safeAreaInsets.left, bottom: 0, right: view.safeAreaInsets.right)
}
init() {
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setup() {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: TestLayout.makeSampleLayout() )
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.contentInsetAdjustmentBehavior = .never
collectionView.insetsLayoutMarginsFromSafeArea = false
// Some margins to move headers from sides
collectionView.layoutMargins = .init(top: 0, left: view.safeAreaInsets.left, bottom: 0, right: view.safeAreaInsets.right)
// Some (any) space on top contentInset
//collectionView.contentInset.top = 150
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
self.collectionView = collectionView
}
func applySnapshot() {
var snap = NSDiffableDataSourceSnapshot<Int, String>()
snap.appendSections([1])
snap.appendItems(["item1-1", "item1-2", "item1-3", "item1-4", "item1-5", "item1-6", "item1-7", "item1-8", "item1-9"])
snap.appendSections([2])
snap.appendItems(["item2-1", "item2-2", "item2-3", "item2-4"])
snap.appendSections([3])
snap.appendItems(["item3-1", "item3-2", "item3-3", "item3-4", "item3-5"])
snap.appendSections([4])
snap.appendItems(["item4-1", "item4-2", "item4-3", "item4-4", "item4-5"])
snap.appendSections([5])
snap.appendItems(["item5-1", "item5-2", "item5-3", "item5-4", "item5-5"])
snap.appendSections([6])
snap.appendItems(["item6-1", "item6-2", "item6-3"])
dataSource.apply(snap, animatingDifferences: false)
}
func makeDataSource() {
let textHeader = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader,
handler: { (cell: FixedHeaderView, string: String, indexPath: IndexPath) in
})
let testCell = UICollectionView.CellRegistration(handler: { (cell: TestCell, indexPath: IndexPath, itemIdentifier: String) in
})
// Make dataSource and supplementaryViewProvider
dataSource = UICollectionViewDiffableDataSource<Int, String> (collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
return collectionView.dequeueConfiguredReusableCell(using: testCell, for: indexPath, item: itemIdentifier)
})
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
switch kind {
case UICollectionView.elementKindSectionHeader:
return collectionView.dequeueConfiguredReusableSupplementary(using: textHeader, for: indexPath)
default:
assertionFailure("Unknown SupplementaryView")
return nil
}
}
}
}
class TestLayout {
// Return a standard section header
static func standardSectionHeader() -> NSCollectionLayoutBoundarySupplementaryItem {
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(78) )
let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize,
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top)
header.extendsBoundary = true
return header
}
static func orthogonalSectionInsets() -> UIEdgeInsets {
return .init(top: 38, left: 0, bottom: 38, right: 0)
}
static func orthogonalSectionSpacing() -> CGFloat {
return 52
}
static func orthogonalSection() -> NSCollectionLayoutSection? {
let insets = orthogonalSectionInsets()
let itemSpacing = orthogonalSectionSpacing()
let viewItems = 6 + 0.5
let heightDimension = NSCollectionLayoutDimension.estimated(1)
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1 / viewItems),
heightDimension: heightDimension)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// iOS15
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitem: item, count: 1)
// iOS16 - weird output?
//let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, repeatingSubitem: item, count: 1)
let section = NSCollectionLayoutSection(group: group)
// Add space to section sides, and between headers
section.contentInsets = NSDirectionalEdgeInsets(top: insets.top, leading: insets.left, bottom: insets.bottom, trailing: insets.right)
section.contentInsetsReference = .layoutMargins
// Add space between horizontal items
section.interGroupSpacing = itemSpacing
section.orthogonalScrollingBehavior = .continuous
// Header
section.boundarySupplementaryItems = [standardSectionHeader() ]
section.supplementaryContentInsetsReference = .layoutMargins
return section
}
static func makeSampleLayout() -> UICollectionViewCompositionalLayout {
let layout = UICollectionViewCompositionalLayout { (sectionNumber, env)
-> NSCollectionLayoutSection? in
return orthogonalSection()
}
// Top level layout settings
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
layoutConfig.scrollDirection = .vertical
layout.configuration = layoutConfig
return layout
}
}
class TestCell: UICollectionViewCell {
let someView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setup() {
insetsLayoutMarginsFromSafeArea = false
someView.translatesAutoresizingMaskIntoConstraints = false
someView.backgroundColor = .red
// We shouldn't be able to see this if margins are correct
contentView.backgroundColor = .yellow
someView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
someView.setContentHuggingPriority(.defaultHigh, for: .vertical)
contentView.addSubview(someView)
let bottom: NSLayoutConstraint = someView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
bottom.priority = .defaultHigh // .init(998)
NSLayoutConstraint.activate([
someView.topAnchor.constraint(equalTo: contentView.topAnchor),
someView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
someView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
someView.heightAnchor.constraint(equalTo: someView.widthAnchor),
bottom
])
}
// Some visible focus so we can see what is going on
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
if (context.nextFocusedView == self) {
someView.backgroundColor = .green
}
else if (context.previouslyFocusedView == self) {
someView.backgroundColor = .red
}
}
}
class FixedHeaderView: UICollectionReusableView {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup() {
backgroundColor = .none
label.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
label.setContentHuggingPriority(.defaultHigh, for: .vertical)
label.textColor = .white
label.backgroundColor = .red
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: 40, weight: .heavy)
label.text = "Some Header Title"
addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: leadingAnchor),
label.bottomAnchor.constraint(equalTo: bottomAnchor)
])
backgroundColor = .red.withAlphaComponent(0.25)
}
}
AppDelegate:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
// Create the window
let newWindow = UIWindow(frame: UIScreen.main.bounds)
let nav = UINavigationController(rootViewController: ViewController() )
newWindow.rootViewController = nav
newWindow.makeKeyAndVisible()
self.window = newWindow
return true
}
I've tried using all sorts of different margin and safeArea layouts, but this issue appears to affect any layout that requires the content to sit off the top of the screen.
Sometimes it loads and works perfectly first time, making me think it's a race condition or something.
Cells are dynamic height (because I need to support a lot of cell types). If I set the height to 'absolute' values, it seems to work - but this would create a lot of work and I'd lose accessibility such as dynamic text sizing.
But I've been at this for 2 days, nudging code and reading docs. I don't do much tvOS and I'm a sole developer (so I don't have any other devs to bounce off) - would really appreciate some input!! Thanks

UICollectionView with Compositional Layout & Diff Datasources disappears on scroll

I currently have a UICollectionView which I'm using compositional layouts and Diffable Data sources. I'm not doing anything crazy just loading 150 cells into it and defining a 4 column layout. I seem to be encountering some weird behaviour whenever I tap the collectionview to scroll the items it seems to be disappearing... Below is my file with the entire code that I'm using so that you can copy & paste this and see this weird behaviour. Does anyone know why this might happening?
import UIKit
class ViewController: UIViewController {
private lazy var myCollectionViewLayout = MyCollectionViewLayout()
override func viewDidLoad() {
super.viewDidLoad()
setup()
// Do any additional setup after loading the view.
}
}
private extension ViewController {
func setup() {
let collectionVw = UICollectionView(frame: .zero, collectionViewLayout: myCollectionViewLayout.createLayout())
collectionVw.translatesAutoresizingMaskIntoConstraints = false
collectionVw.register(MyCustomCell.self, forCellWithReuseIdentifier: MyCustomCell.cellId)
self.view.addSubview(collectionVw)
NSLayoutConstraint.activate([
collectionVw.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
collectionVw.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor),
collectionVw.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
collectionVw.trailingAnchor.constraint(equalTo: self.view.trailingAnchor)
])
let dataSource = UICollectionViewDiffableDataSource<Int, UUID>(collectionView: collectionVw) { (collectionView, indexPath, item) -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MyCustomCell.cellId, for: indexPath) as? MyCustomCell
cell?.configure(at: indexPath.row)
return cell
}
collectionVw.dataSource = dataSource
var snapshot = NSDiffableDataSourceSnapshot<Int, UUID>()
snapshot.appendSections([0])
Range(0...150).forEach { item in
snapshot.appendItems([UUID()], toSection: 0)
}
dataSource.apply(snapshot, animatingDifferences: true)
}
}
class MyCollectionViewLayout {
func createLayout() -> UICollectionViewCompositionalLayout {
let layout = UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnv: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.25),
heightDimension: .fractionalHeight(1))
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .fractionalWidth(0.25))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
subitem: item,
count: 4)
group.interItemSpacing = .fixed(16)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 16
section.contentInsets = NSDirectionalEdgeInsets(top: 0,
leading: 16,
bottom: 0,
trailing: 16)
return section
}
return layout
}
}
class MyCustomCell: UICollectionViewCell {
static let cellId = "MyCustomCell"
private var lbl: UILabel?
func configure(at index: Int) {
self.contentView.layer.cornerRadius = 8
self.contentView.backgroundColor = .blue
lbl = UILabel()
lbl?.translatesAutoresizingMaskIntoConstraints = false
lbl?.text = index.description
lbl?.textAlignment = .center
lbl?.font = .preferredFont(forTextStyle: .headline)
self.contentView.addSubview(lbl!)
NSLayoutConstraint.activate([
lbl!.topAnchor.constraint(equalTo: self.contentView.topAnchor),
lbl!.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor),
lbl!.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor),
lbl!.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor)
])
}
override func prepareForReuse() {
super.prepareForReuse()
lbl?.removeFromSuperview()
}
}
The reason is that your data source is a local variable inside your setup method (let dataSource...). Therefore it works just the one time to populate the collection view initially, and then vanishes in a puff of smoke, leaving the collection view with no data.
The data source needs to be a property of the view controller so that it persists! Once you make that change, all will be well.
It's actually a little tricky to do that, because the data source depends on the collection view. So you have to use lazy instantiation. You create the collection view with an empty layout and hook everything together in the first step of the setup.
Here's a minimal rewrite of your code; look for my comments explaining the changes.
import UIKit
class ViewController: UIViewController {
// delete; this is pointless
// private lazy var myCollectionViewLayout = MyCollectionViewLayout()
override func viewDidLoad() {
super.viewDidLoad()
setup()
}
// need persistent pointer to collection view
let collectionVw = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout())
// need persistent pointer to data source
lazy var dataSource = UICollectionViewDiffableDataSource<Int, UUID>(collectionView: collectionVw) { (collectionView, indexPath, item) -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MyCustomCell.cellId, for: indexPath) as? MyCustomCell
cell?.configure(at: indexPath.row)
return cell
}
}
private extension ViewController {
func setup() {
// let collectionVw = UICollectionView(frame: .zero, collectionViewLayout: myCollectionViewLayout.createLayout())
// everything is persistent, now just hook them together
collectionVw.collectionViewLayout = MyCollectionViewLayoutMaker.createLayout()
collectionVw.translatesAutoresizingMaskIntoConstraints = false
collectionVw.register(MyCustomCell.self, forCellWithReuseIdentifier: MyCustomCell.cellId)
self.view.addSubview(collectionVw)
NSLayoutConstraint.activate([
collectionVw.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
collectionVw.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor),
collectionVw.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
collectionVw.trailingAnchor.constraint(equalTo: self.view.trailingAnchor)
])
// let dataSource = UICollectionViewDiffableDataSource<Int, UUID>(collectionView: collectionVw) { (collectionView, indexPath, item) -> UICollectionViewCell? in
//
// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MyCustomCell.cellId, for: indexPath) as? MyCustomCell
// cell?.configure(at: indexPath.row)
// return cell
// }
collectionVw.dataSource = dataSource
var snapshot = NSDiffableDataSourceSnapshot<Int, UUID>()
snapshot.appendSections([0])
Range(0...150).forEach { item in
snapshot.appendItems([UUID()], toSection: 0)
}
dataSource.apply(snapshot, animatingDifferences: true)
}
}
class MyCollectionViewLayoutMaker {
// make this static; the class is not the layout, it serves no purpose except as a factory
static func createLayout() -> UICollectionViewCompositionalLayout {
let layout = UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnv: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.25),
heightDimension: .fractionalHeight(1))
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .fractionalWidth(0.25))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
subitem: item,
count: 4)
group.interItemSpacing = .fixed(16)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 16
section.contentInsets = NSDirectionalEdgeInsets(top: 0,
leading: 16,
bottom: 0,
trailing: 16)
return section
}
return layout
}
}
class MyCustomCell: UICollectionViewCell {
static let cellId = "MyCustomCell"
private var lbl: UILabel?
func configure(at index: Int) {
self.contentView.layer.cornerRadius = 8
self.contentView.backgroundColor = .blue
lbl = UILabel()
lbl?.translatesAutoresizingMaskIntoConstraints = false
lbl?.text = index.description
lbl?.textAlignment = .center
lbl?.font = .preferredFont(forTextStyle: .headline)
self.contentView.addSubview(lbl!)
NSLayoutConstraint.activate([
lbl!.topAnchor.constraint(equalTo: self.contentView.topAnchor),
lbl!.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor),
lbl!.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor),
lbl!.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor)
])
}
override func prepareForReuse() {
super.prepareForReuse()
lbl?.removeFromSuperview()
}
}

UICollectionCellView delegate not being fired

A UICollectionView I have created doesn't want to fire a custom cell delegate.
The cells are displaying, and the test button accepts presses.
I have debugged the code and the cell.delegate is being assigned self.
I have researched this on google and I have come up with nothing. I think my code is ok but I must be missing something?
I would really appreciate any help.
//HomeControllerDelegateTesting.swift
import UIKit
class HomeControllerDelegateTesting: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, PostCellOptionsDelegate {
var collectionView: UICollectionView! = nil
override func viewDidLoad() {
super.viewDidLoad()
self.collectionView = UICollectionView(frame: self.view.bounds, collectionViewLayout: self.createLayout())
self.collectionView.register(PostCell.self, forCellWithReuseIdentifier: PostCell.reuseIdentifier)
self.collectionView.backgroundColor = .systemBackground
self.view.addSubview(collectionView)
self.collectionView.dataSource = self
self.collectionView.delegate = self
}
private func createLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(300))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
subitems: [item])
let section = NSCollectionLayoutSection(group: group)
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PostCell.reuseIdentifier, for: indexPath) as? PostCell else { fatalError("Cannot create cell") }
cell.postTextLabel.text = "Test"
cell.delegate = self
//Test to eliminiate the button accepts events
//cell.optionsButtons.addTarget(self, action: #selector(test), for: .touchUpInside)
return cell
}
#objc func handlePostOptions(cell: PostCell) {
print("123")
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 3
}
}
// PostCell.swift
import UIKit
protocol PostCellOptionsDelegate: class {
func handlePostOptions(cell: PostCell)
}
class PostCell: UICollectionViewCell {
static let reuseIdentifier = "list-cell-reuse-identifier"
var delegate: PostCellOptionsDelegate?
let usernameLabel: UILabel = {
let label = UILabel()
label.text = "Username"
label.font = .boldSystemFont(ofSize: 15)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let postImageView: UIImageView = {
let control = UIImageView()
control.translatesAutoresizingMaskIntoConstraints = false
control.contentMode = .scaleAspectFill
control.clipsToBounds = true
return control
}()
let postTextLabel: UILabel = {
let label = UILabel()
label.text = "Post text spanning multiple lines"
label.font = .systemFont(ofSize: 15)
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
// private let optionsButtons: UIButton = {
// let control = UIButton.systemButton(with: #imageLiteral(resourceName: "post_options"), target: self, action: #selector(handleOptions))
// control.translatesAutoresizingMaskIntoConstraints = false
// return control
// }()
let optionsButtons: UIButton = {
let control = UIButton(type: .system)
control.setTitle("Test", for: .normal)
control.addTarget(self, action: #selector(handleOptions), for: .touchUpInside)
control.translatesAutoresizingMaskIntoConstraints = false
return control
}()
#objc fileprivate func handleOptions() {
print("Handle options button")
delegate?.handlePostOptions(cell: self)
}
override init(frame: CGRect) {
super.init(frame: frame)
setupComponents()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupComponents() {
self.optionsButtons.heightAnchor.constraint(equalToConstant: 50).isActive = true
let labelStack = UIStackView(arrangedSubviews: [optionsButtons])
labelStack.translatesAutoresizingMaskIntoConstraints = false
labelStack.axis = .horizontal
labelStack.alignment = .fill
labelStack.distribution = .fill
labelStack.isLayoutMarginsRelativeArrangement = true
labelStack.layoutMargins.left = 16
labelStack.layoutMargins.right = 16
labelStack.layoutMargins.top = 16
labelStack.layoutMargins.bottom = 16
let postTextLabelStack = UIStackView(arrangedSubviews: [postTextLabel])
postTextLabelStack.translatesAutoresizingMaskIntoConstraints = false
postTextLabelStack.axis = .vertical
postTextLabelStack.alignment = .fill
postTextLabelStack.distribution = .fill
postTextLabelStack.isLayoutMarginsRelativeArrangement = true
postTextLabelStack.layoutMargins.left = 16
postTextLabelStack.layoutMargins.right = 16
postTextLabelStack.layoutMargins.top = 16
postTextLabelStack.layoutMargins.bottom = 16
let stack = UIStackView(arrangedSubviews: [labelStack, postImageView, postTextLabelStack])
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.alignment = .fill
stack.distribution = .fill
stack.backgroundColor = .blue
self.addSubview(stack)
//postImageView.heightAnchor.constraint(equalTo: postImageView.widthAnchor).isActive = true
stack.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
stack.leadingAnchor.constraint(equalTo: self.leadingAnchor).isActive = true
stack.trailingAnchor.constraint(equalTo: self.trailingAnchor).isActive = true
stack.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true
}
}
Change your button declaration to this:
// make this a lazy var
lazy var optionsButtons: UIButton = {
let control = UIButton(type: .system)
control.setTitle("Test", for: .normal)
// add self. to the selector
control.addTarget(self, action: #selector(self.handleOptions), for: .touchUpInside)
control.translatesAutoresizingMaskIntoConstraints = false
return control
}()
The issue is that when the optionsButtons is created the self is still not initialized. Hence you need to make the button lazy var so that it could be loaded lazily when the optionsButtons is called and the UICollectionViewCell is initialized. Adding the target before self is initialized doesn't work.
To fix your issue, modify your optionsButtons declaration to lazy var, like this:
lazy var optionsButtons: UIButton = {
OR
Add the target after the UICollectionViewCell is initialized, like this:
override init(frame: CGRect) {
super.init(frame: frame)
optionsButtons.addTarget(self, action: #selector(handleOptions), for: .touchUpInside)
setupComponents()
}

Collection View Diffable Data Source cells disappearing and not resizing properly?

I'm having a really weird issue with my collection view. I'm using the Compositional Layout and Diffable Data Source APIs for iOS 13+, but I'm getting some really weird behavior. As seen in the video below, when I update the data source, the first cell that is added to the top section doesn't resize properly, then when I add the second cell both cells disappear, and then when I add a third cell, all load in with the proper sizes and appear. When I unadd all the cells and add them back in a similar fashion a second time, that initial issue doesn't happen again.
Video of Error
I have tried using the following solutions in some fashion:
collectionView.collectionViewLayout.invalidateLayout()
cell.contentView.setNeedsLayout() followed by cell.contentView.layoutIfNeeded()
collectionView.reloadData()
I can't seem to figure out what might be causing this issue. Perhaps it could be that I have two different cells registered with the collection view and dequeueing them improperly or my data types aren't correctly conforming to hashable. I believe I've fixed both of those issues, but I will also provide my code to help. Also the data controller mentioned is a simple class that stores an array of view models for the cells to use for configuration (there shouldn't be any issue there). Thanks!
Collection View Controller
import UIKit
class PartyInvitesViewController: UIViewController {
private var collectionView: UICollectionView!
private lazy var layout = createLayout()
private lazy var dataSource = createDataSource()
private let searchController = UISearchController(searchResultsController: nil)
private let dataController = InvitesDataController()
override func loadView() {
super.loadView()
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
override func viewDidLoad() {
super.viewDidLoad()
let backButton = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
backButton.tintColor = UIColor.Fiesta.primary
navigationItem.backBarButtonItem = backButton
let titleView = UILabel()
titleView.text = "invite"
titleView.textColor = .white
titleView.font = UIFont.Fiesta.Black.header
navigationItem.titleView = titleView
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
// definesPresentationContext = true
navigationItem.largeTitleDisplayMode = .never
navigationController?.navigationBar.isTranslucent = true
extendedLayoutIncludesOpaqueBars = true
collectionView.register(InvitesCell.self, forCellWithReuseIdentifier: InvitesCell.reuseIdentifier)
collectionView.register(InvitedCell.self, forCellWithReuseIdentifier: InvitedCell.reuseIdentifier)
collectionView.register(InvitesSectionHeaderReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: InvitesSectionHeaderReusableView.reuseIdentifier)
collectionView.delegate = self
collectionView.dataSource = dataSource
dataController.cellPressed = { [weak self] in
self?.update()
}
dataController.start()
update(animate: false)
view.backgroundColor = .secondarySystemBackground
collectionView.backgroundColor = .secondarySystemBackground
}
}
extension PartyInvitesViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
// cell.contentView.setNeedsLayout()
// cell.contentView.layoutIfNeeded()
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if indexPath.section == InvitesSection.unselected.rawValue {
let viewModel = dataController.getAll()[indexPath.item]
dataController.didSelect(viewModel, completion: nil)
}
}
}
extension PartyInvitesViewController {
func update(animate: Bool = true) {
var snapshot = NSDiffableDataSourceSnapshot<InvitesSection, InvitesCellViewModel>()
snapshot.appendSections(InvitesSection.allCases)
snapshot.appendItems(dataController.getTopSelected(), toSection: .selected)
snapshot.appendItems(dataController.getSelected(), toSection: .unselected)
snapshot.appendItems(dataController.getUnselected(), toSection: .unselected)
dataSource.apply(snapshot, animatingDifferences: animate) {
// self.collectionView.reloadData()
// self.collectionView.collectionViewLayout.invalidateLayout()
}
}
}
extension PartyInvitesViewController {
private func createDataSource() -> InvitesCollectionViewDataSource {
let dataSource = InvitesCollectionViewDataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, viewModel -> UICollectionViewCell? in
switch indexPath.section {
case InvitesSection.selected.rawValue:
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: InvitedCell.reuseIdentifier, for: indexPath) as? InvitedCell else { return nil }
cell.configure(with: viewModel)
cell.onDidCancel = { self.dataController.didSelect(viewModel, completion: nil) }
return cell
case InvitesSection.unselected.rawValue:
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: InvitesCell.reuseIdentifier, for: indexPath) as? InvitesCell else { return nil }
cell.configure(with: viewModel)
return cell
default:
return nil
}
})
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath -> UICollectionReusableView? in
guard kind == UICollectionView.elementKindSectionHeader else { return nil }
guard let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: InvitesSectionHeaderReusableView.reuseIdentifier, for: indexPath) as? InvitesSectionHeaderReusableView else { return nil }
switch indexPath.section {
case InvitesSection.selected.rawValue:
view.titleLabel.text = "Inviting"
case InvitesSection.unselected.rawValue:
view.titleLabel.text = "Suggested"
default: return nil
}
return view
}
return dataSource
}
}
extension PartyInvitesViewController {
private func createLayout() -> UICollectionViewLayout {
let layout = UICollectionViewCompositionalLayout { section, _ -> NSCollectionLayoutSection? in
switch section {
case InvitesSection.selected.rawValue:
return self.createSelectedSection()
case InvitesSection.unselected.rawValue:
return self.createUnselectedSection()
default: return nil
}
}
return layout
}
private func createSelectedSection() -> NSCollectionLayoutSection {
let width: CGFloat = 120
let height: CGFloat = 60
let layoutSize = NSCollectionLayoutSize(widthDimension: .estimated(width), heightDimension: .absolute(height))
let item = NSCollectionLayoutItem(layoutSize: layoutSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: layoutSize, subitems: [item])
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(60))
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [sectionHeader]
section.orthogonalScrollingBehavior = .continuous
// for some reason content insets breaks the estimation process idk why
section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)
section.interGroupSpacing = 20
return section
}
private func createUnselectedSection() -> NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(60))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(60))
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [sectionHeader]
section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20)
section.interGroupSpacing = 20
return section
}
}
Invites Cell (First Cell Type)
class InvitesCell: FiestaGenericCell {
static let reuseIdentifier = "InvitesCell"
var stackView = UIStackView()
var userStackView = UIStackView()
var userImageView = UIImageView()
var nameStackView = UIStackView()
var usernameLabel = UILabel()
var nameLabel = UILabel()
var inviteButton = UIButton()
override func layoutSubviews() {
super.layoutSubviews()
userImageView.layer.cornerRadius = 28
}
override func arrangeSubviews() {
stackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stackView)
stackView.addArrangedSubview(userStackView)
stackView.addArrangedSubview(inviteButton)
userStackView.addArrangedSubview(userImageView)
userStackView.addArrangedSubview(nameStackView)
nameStackView.addArrangedSubview(usernameLabel)
nameStackView.addArrangedSubview(nameLabel)
setNeedsUpdateConstraints()
}
override func loadConstraints() {
// Stack view constraints
NSLayoutConstraint.activate([
stackView.widthAnchor.constraint(equalTo: contentView.widthAnchor),
stackView.heightAnchor.constraint(equalTo: contentView.heightAnchor)
])
// User image view constraints
NSLayoutConstraint.activate([
userImageView.heightAnchor.constraint(equalToConstant: 56),
userImageView.widthAnchor.constraint(equalToConstant: 56)
])
}
override func configureSubviews() {
// Stack view configuration
stackView.axis = .horizontal
stackView.alignment = .center
stackView.distribution = .equalSpacing
// User stack view configuration
userStackView.axis = .horizontal
userStackView.alignment = .center
userStackView.spacing = Constants.inset
// User image view configuration
userImageView.image = UIImage(named: "Image-4")
userImageView.contentMode = .scaleAspectFill
userImageView.clipsToBounds = true
// Name stack view configuration
nameStackView.axis = .vertical
nameStackView.alignment = .leading
nameStackView.spacing = 4
nameStackView.distribution = .fillProportionally
// Username label configuration
usernameLabel.textColor = .white
usernameLabel.font = UIFont.Fiesta.Black.text
// Name label configuration
nameLabel.textColor = .white
nameLabel.font = UIFont.Fiesta.Light.footnote
// Invite button configuration
let configuration = UIImage.SymbolConfiguration(weight: .heavy)
inviteButton.setImage(UIImage(systemName: "circle", withConfiguration: configuration), for: .normal)
inviteButton.tintColor = .white
}
}
extension InvitesCell {
func configure(with viewModel: InvitesCellViewModel) {
usernameLabel.text = viewModel.username
nameLabel.text = viewModel.name
let configuration = UIImage.SymbolConfiguration(weight: .heavy)
if viewModel.isSelected {
inviteButton.setImage(UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration), for: .normal)
inviteButton.tintColor = .green
} else {
inviteButton.setImage(UIImage(systemName: "circle", withConfiguration: configuration), for: .normal)
inviteButton.tintColor = .white
}
}
}
Invited Cell (Second Cell Type)
import UIKit
class InvitedCell: FiestaGenericCell {
static let reuseIdentifier = "InvitedCell"
var mainView = UIView()
var usernameLabel = UILabel()
// var cancelButton = UIButton()
var onDidCancel: (() -> Void)?
override func layoutSubviews() {
super.layoutSubviews()
mainView.layer.cornerRadius = 8
}
override func arrangeSubviews() {
mainView.translatesAutoresizingMaskIntoConstraints = false
usernameLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(mainView)
mainView.addSubview(usernameLabel)
}
override func loadConstraints() {
// Main view constraints
NSLayoutConstraint.activate([
mainView.widthAnchor.constraint(equalTo: contentView.widthAnchor),
mainView.heightAnchor.constraint(equalTo: contentView.heightAnchor)
])
// Username label constraints
NSLayoutConstraint.activate([
usernameLabel.topAnchor.constraint(equalTo: mainView.topAnchor, constant: 20),
usernameLabel.leftAnchor.constraint(equalTo: mainView.leftAnchor, constant: 20),
usernameLabel.rightAnchor.constraint(equalTo: mainView.rightAnchor, constant: -20),
usernameLabel.bottomAnchor.constraint(equalTo: mainView.bottomAnchor, constant: -20)
])
}
override func configureSubviews() {
// Main view configuration
mainView.backgroundColor = .tertiarySystemBackground
// Username label configuration
usernameLabel.textColor = .white
usernameLabel.font = UIFont.Fiesta.Black.text
}
}
extension InvitedCell {
func configure(with viewModel: InvitesCellViewModel) {
usernameLabel.text = viewModel.username
}
#objc func cancel() {
onDidCancel?()
}
}
Invites Cell View Model (model for the cells)
import Foundation
struct InvitesCellViewModel {
var id = UUID()
private var model: User
init(_ model: User, selected: Bool) {
self.model = model
self.isSelected = selected
}
var username: String?
var name: String?
var isSelected: Bool
mutating func toggleIsSelected() {
isSelected = !isSelected
}
}
extension InvitesCellViewModel: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(isSelected)
}
static func == (lhs: InvitesCellViewModel, rhs: InvitesCellViewModel) -> Bool {
lhs.id == rhs.id && lhs.isSelected == rhs.isSelected
}
}
If I need to provide anything else to better assist in answering this question, please let me know in the comments!
This may not be a solution for everyone, but I ended up fully switching over to RxSwift. For those who are debating the switch, I now use RxDataSources and the UICollectionViewCompositionalLayout with virtually no problems (outside of the occasional bug or two). I know this may not be the answer most are looking for, but looking back, this issue seems to be on Apple's end, so I figured it was best to find another path. If anybody has found a solution that is simpler than completely jumping over to Rx, please feel free to add your answer as well.

Swift add items in scrollable list

right now this is all I have in my project:
In the end it should look and function pretty like this:
1. How do I add items into the ScrollView (in a 2 x X View)
2. How do I make the ScrollView actually be able to scroll (and refresh like in the 3 pictures below) or is this maybe solvable with just a list?
UPDATE
The final view should look like this:
The "MainWishList" cell and the "neue Liste erstellen" (= add new cell) should be there from the beginning. When the user clicks the "add-Cell" he should be able to choose a name and image for the list.
Part of the built-in functionality of a UICollectionView is automatic scrolling when you have more items (cells) than will fit in the frame. So there is no need to embed a collection view in a scroll view.
Here is a basic example. Everything is done via code (no #IBOutlet, #IBAction or prototype cells). Create a new UIViewController and assign its class to ExampleViewController as found below:
//
// ExampleViewController.swift
// CollectionAddItem
//
// Created by Don Mag on 10/22/19.
//
import UIKit
// simple cell with label
class ContentCell: UICollectionViewCell {
let theLabel: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
v.textAlignment = .center
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
contentView.backgroundColor = .yellow
contentView.addSubview(theLabel)
// constrain label to all 4 sides
NSLayoutConstraint.activate([
theLabel.topAnchor.constraint(equalTo: contentView.topAnchor),
theLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
theLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
theLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
])
}
}
// simple cell with button
class AddItemCell: UICollectionViewCell {
let btn: UIButton = {
let v = UIButton()
v.translatesAutoresizingMaskIntoConstraints = false
v.setTitle("+", for: .normal)
v.setTitleColor(.systemBlue, for: .normal)
v.titleLabel?.font = UIFont.systemFont(ofSize: 40.0)
return v
}()
// this will be used as a "callback closure" in collection view controller
var tapCallback: (() -> ())?
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
contentView.backgroundColor = .green
contentView.addSubview(btn)
// constrain button to all 4 sides
NSLayoutConstraint.activate([
btn.topAnchor.constraint(equalTo: contentView.topAnchor),
btn.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
btn.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
btn.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
])
btn.addTarget(self, action: #selector(didTap(_:)), for: .touchUpInside)
}
#objc func didTap(_ sender: Any) {
// tell the collection view controller we got a button tap
tapCallback?()
}
}
class ExampleViewController: UIViewController, UICollectionViewDataSource {
let theCollectionView: UICollectionView = {
let v = UICollectionView(frame: CGRect.zero, collectionViewLayout: UICollectionViewFlowLayout())
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .white
v.contentInsetAdjustmentBehavior = .always
return v
}()
let columnLayout = FlowLayout(
itemSize: CGSize(width: 100, height: 100),
minimumInteritemSpacing: 10,
minimumLineSpacing: 10,
sectionInset: UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
)
// track collection view frame change
var colViewWidth: CGFloat = 0.0
// example data --- this will be filled with simple number strings
var theData: [String] = [String]()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemYellow
view.addSubview(theCollectionView)
// constrain collection view
// 100-pts from top
// 60-pts from bottom
// 40-pts from leading
// 40-pts from trailing
NSLayoutConstraint.activate([
theCollectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 100.0),
theCollectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -60.0),
theCollectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 40.0),
theCollectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -40.0),
])
// register the two cell classes for reuse
theCollectionView.register(ContentCell.self, forCellWithReuseIdentifier: "ContentCell")
theCollectionView.register(AddItemCell.self, forCellWithReuseIdentifier: "AddItemCell")
// set collection view dataSource
theCollectionView.dataSource = self
// use custom flow layout
theCollectionView.collectionViewLayout = columnLayout
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// only want to call this when collection view frame changes
// to set the item size
if theCollectionView.frame.width != colViewWidth {
let w = theCollectionView.frame.width / 2 - 15
columnLayout.itemSize = CGSize(width: w, height: w)
colViewWidth = theCollectionView.frame.width
}
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
// return 1 more than our data array (the extra one will be the "add item" cell
return theData.count + 1
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
// if item is less that data count, return a "Content" cell
if indexPath.item < theData.count {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ContentCell", for: indexPath) as! ContentCell
cell.theLabel.text = theData[indexPath.item]
return cell
}
// past the end of the data count, so return an "Add Item" cell
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "AddItemCell", for: indexPath) as! AddItemCell
// set the closure
cell.tapCallback = {
// add item button was tapped, so append an item to the data array
self.theData.append("\(self.theData.count + 1)")
// reload the collection view
collectionView.reloadData()
collectionView.performBatchUpdates(nil, completion: {
(result) in
// scroll to make newly added row visible (if needed)
let i = collectionView.numberOfItems(inSection: 0) - 1
let idx = IndexPath(item: i, section: 0)
collectionView.scrollToItem(at: idx, at: .bottom, animated: true)
})
}
return cell
}
}
// custom FlowLayout class to left-align collection view cells
// found here: https://stackoverflow.com/a/49717759/6257435
class FlowLayout: UICollectionViewFlowLayout {
required init(itemSize: CGSize, minimumInteritemSpacing: CGFloat = 0, minimumLineSpacing: CGFloat = 0, sectionInset: UIEdgeInsets = .zero) {
super.init()
self.itemSize = itemSize
self.minimumInteritemSpacing = minimumInteritemSpacing
self.minimumLineSpacing = minimumLineSpacing
self.sectionInset = sectionInset
sectionInsetReference = .fromSafeArea
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttributes = super.layoutAttributesForElements(in: rect)!.map { $0.copy() as! UICollectionViewLayoutAttributes }
guard scrollDirection == .vertical else { return layoutAttributes }
// Filter attributes to compute only cell attributes
let cellAttributes = layoutAttributes.filter({ $0.representedElementCategory == .cell })
// Group cell attributes by row (cells with same vertical center) and loop on those groups
for (_, attributes) in Dictionary(grouping: cellAttributes, by: { ($0.center.y / 10).rounded(.up) * 10 }) {
// Set the initial left inset
var leftInset = sectionInset.left
// Loop on cells to adjust each cell's origin and prepare leftInset for the next cell
for attribute in attributes {
attribute.frame.origin.x = leftInset
leftInset = attribute.frame.maxX + minimumInteritemSpacing
}
}
return layoutAttributes
}
}
When you run this, the data array will be empty, so the first thing you'll see is:
Each time you tap the "+" cell, a new item will be added to the data array (in this example, a numeric string), reloadData() will be called, and a new cell will appear.
Once we have enough items in our data array so they won't all fit in the collection view frame, the collection view will become scrollable:

Resources