Collection view within another collection view: section header or cell? - ios

I already have a UICollectionView which scrolls vertically and shows a collection of custom UICollectionViewCells which have a fixed size.
Now I've been asked for showing another UICollectionView in top of all other cells, which should scroll horizontally and whose cells size is dynamic (I'll only know the size after at an async network call completion). In addition, this inner collection view may not be always necessary to be shown (it depends on the data received from the network call), but if it is, it should be shown only once (on top of everything).
My question is: how the best way to deal with this second and inner collection view should be? Should I add it to the outer view controller as a different kind of cell of it, or maybe as a section header?
Maybe another approach to layout this would be better?
EDIT: More considerations:
I'd need to animate the inner collection view when I'm going to show it
The whole thing should be vertically scrollable, this inner collection view should not stick to the top of the screen

"how the best way to deal with this second and inner collection view should be?"
"Should I add it to the outer view controller as a different kind of cell of it, or maybe as a section header?"
"I'd need to animate the inner collection view when I'm going to show it"
"The whole thing should be vertically scrollable, this inner collection view should not stick to the top of the screen."
It sometimes helps to take a step back and write out your requirements, think of each one independently:
1) The first cell of the CollectionView Should Scroll Horizontally.
2) The first cell should scroll past the screen vertically.
First cell of the CollectionView needs to contain a CollectionView itself.
3a) The CollectionView's other cells are of static size.
3b) The CollectionViews's first cells are of dynamic size.
Two Cell Classes are needed, or one cell class with dynamic constrains and subviews.
4) The CollectionView's First cells should be animated.
The first cells' CollectionView needs to be the delegate of its dynamic cells. (Animation occurs in cellForItemAt indexPath)
Keep in mind that UICollectionView's are independent views. A UICollectionViewController is essentially a UIViewController, UICollectionViewDelegate and UICollectionViewDataSource that contains a UICollectionView. Just like any UIView you can subclass UICollectionView and add it to a subview of another view, say UICollectionViewCell. In this way you can add a collection view to a cell and add cells to that nested collection view. You can also allow that nested collection view handle all the delegate methods from UICollectionViewDelegate and UICollectionViewDataSource essentially making it modular and reusable. You can pass the data to be displayed in each cell of the nested UICollectionView within a convenience init method and allow that class to handle animation and setup. This is by far the best way of doing it, not only for reuse but also for performance, especially when you are creating the views programmatically.
In the example below I have one UICollectionViewController named ViewController that will be the view controller for all other views.
I also have two CollectionViews, ParentCollectionView and HorizontalCollectionView. ParentCollectionView is an empty implementation of UICollectionView. I could use the collectionView of my UICollectionViewController but because I want this to be thoroughly modular I will later assign my ParentCollectionView to the ViewController's collectionView. ParentCollectionView will handle all the cells static cells in the view, including the one containing our HorizontalCollectionView. HorizontalCollectionView will be the delegate and data source for all 'cells objects' (your data model) passed to it within its convenience initializer. That is to say that HorizontalCollectionView will manage it own cells so that our UICollectionViewController doesn't get fat.
In addition to two CollectionViews and a UICollectionViewController, I have two UICollectionViewCell classes one of static sizing and the other dynamic (randomly generated CGSize). For ease of use I also have a extension that returns the classname as the identifier, I don't like using hard coded strings for reusable cells. These cell classes are not all that different, one could use the same cell and change the cell size in sizeForItemAt indexPath or cellForItemAt indexPath but for the sake of demonstration I'm going to say that they are completely different cells that require entirely different data models.
Now, we don't want the first cell in our ParentCollectionView to be dequeued, this is because the cell will be removed from memory and thrown back into the queue for reuse and we certainly don't want our HorizontalCollectionView popping up randomly. To avoid this we need to register both our StaticCollectionViewCell and a generic cell that will only ever be used once, since I added an extension that gives me the classname for the cell earlier I will just use UICollectionViewCell as the identifier.
I'm sure you won't have much trouble figuring out the rest, Here is my full implementation:
ViewController.swift
import UIKit
class ViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout {
// Programmically add our empty / custom ParentCollectionView
let parentCollectionView: ParentCollectionView = {
let layout = UICollectionViewFlowLayout()
let cv = ParentCollectionView(frame: .zero, collectionViewLayout: layout)
cv.translatesAutoresizingMaskIntoConstraints = false
return cv
}()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
setup()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
func setup() {
// Assign this viewcontroller's collection view to our own custom one.
self.collectionView = parentCollectionView
// Set delegate and register Static and empty cells for later use.
parentCollectionView.delegate = self
parentCollectionView.register(StaticCollectionViewCell.self, forCellWithReuseIdentifier: StaticCollectionViewCell.identifier)
parentCollectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: UICollectionViewCell.identifier)
// Add simple Contraints
let guide = self.view.safeAreaLayoutGuide
parentCollectionView.topAnchor.constraint(equalTo: guide.topAnchor).isActive = true
parentCollectionView.leftAnchor.constraint(equalTo: guide.leftAnchor).isActive = true
parentCollectionView.rightAnchor.constraint(equalTo: guide.rightAnchor).isActive = true
parentCollectionView.bottomAnchor.constraint(equalTo: guide.bottomAnchor).isActive = true
}
// MARK: - CollectionView
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
// Erroneous Data from your network call, data should be a class property.
let data = Array.init(repeating: "0", count: 12)
// Skip if we dont have any data to show for the first row.
if (indexPath.row == 0 && data.count > 0) {
// Create a new empty cell for reuse, this cell will only be used for the frist cell.
let cell = parentCollectionView.dequeueReusableCell(withReuseIdentifier: UICollectionViewCell.identifier, for: IndexPath(row: 0, section: 0))
// Programmically Create a Horizontal Collection View add to the Cell
let horizontalView:HorizontalCollectionView = {
// Only Flow Layout has scroll direction
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
// Init with Data.
let hr = HorizontalCollectionView(frame: cell.frame, collectionViewLayout: layout, data: data)
return hr
}()
// Adjust cell's frame and add it as a subview.
cell.addSubview(horizontalView)
return cell
}
// In all other cases, just create a regular cell.
let cell = parentCollectionView.dequeueReusableCell(withReuseIdentifier: StaticCollectionViewCell.identifier, for: indexPath)
// Update Cell.
return cell
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
// 30 sounds like enough.
return 30
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
//If you need your first row to be bigger return a larger size.
if (indexPath.row == 0) {
return StaticCollectionViewCell.size()
}
return StaticCollectionViewCell.size()
}
}
ParentCollectionView.swift
import UIKit
class ParentCollectionView: UICollectionView {
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
HorizontalCollectionView.swift
import Foundation
import UIKit
class HorizontalCollectionView: UICollectionView, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
// Your Data Model Objects
var data:[Any]?
// Required
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
}
convenience init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout, data:[Any]) {
self.init(frame: frame, collectionViewLayout: layout)
// Set These
self.delegate = self
self.dataSource = self
self.data = data
// Setup Subviews.
setup()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
// return zero if we have no data to show.
guard let count = self.data?.count else {
return 0
}
return count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = self.dequeueReusableCell(withReuseIdentifier: DynamicCollectionViewCell.identifier, for: indexPath)
// Do Some fancy Animation when scrolling.
let endingFrame = cell.frame
let transitionalTranslation = self.panGestureRecognizer.translation(in: self.superview)
if (transitionalTranslation.x > 0) {
cell.frame = CGRect(x: endingFrame.origin.x - 200, y: endingFrame.origin.y - 100, width: 0, height: 0)
} else {
cell.frame = CGRect(x: endingFrame.origin.x + 200, y: endingFrame.origin.y - 100, width: 0, height: 0)
}
UIView.animate(withDuration: 1.2) {
cell.frame = endingFrame
}
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
// See DynamicCollectionViewCell size method, generate a random size.
return DynamicCollectionViewCell.size()
}
func setup(){
self.backgroundColor = UIColor.white
self.register(DynamicCollectionViewCell.self, forCellWithReuseIdentifier: DynamicCollectionViewCell.identifier)
// Must call reload, Data is not loaded unless explicitly told to.
// Must run on Main thread this class is still initalizing.
DispatchQueue.main.async {
self.reloadData()
}
}
}
DynamicCollectionViewCell.swift
import Foundation
import UIKit
class DynamicCollectionViewCell: UICollectionViewCell {
/// Get the Size of the Cell
/// Will generate a random width element no less than 100 and no greater than 350
/// - Returns: CGFloat
class func size() -> CGSize {
let width = 100 + Double(arc4random_uniform(250))
return CGSize(width: width, height: 100.0)
}
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setup() {
self.backgroundColor = UIColor.green
}
}
StaticCollectionViewCell.swift
import Foundation
import UIKit
class StaticCollectionViewCell: UICollectionViewCell {
/// Get the Size of the Cell
/// - Returns: CGFloat
class func size() -> CGSize {
return CGSize(width: UIScreen.main.bounds.width, height: 150.0)
}
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setup() {
self.backgroundColor = UIColor.red
}
}
CollectionViewCellExtentions.swift
import UIKit
extension UICollectionViewCell {
/// Get the string identifier for this class.
///
/// - Returns: String
class var identifier: String {
return NSStringFromClass(self).components(separatedBy: ".").last!
}
}

Related

UICollectionView dynamic header size

I have a collectionView with a header designed in a .xib file. It has a simple label and it's text supports dynamicType.
How can I set the height of that header to be dynamic based on that label and the auto layout constraints in Storyboard?
So far, I've got this:
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
let kind = UICollectionView.elementKindSectionHeader
let indexPath = IndexPath(row: 0, section: section)
if let headerView = collectionView.supplementaryView(forElementKind: kind, at: indexPath) as? SectionHeaderView {
headerView.layoutIfNeeded()
headerView.setNeedsLayout()
let size = headerView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
return size
}
return CGSize(width: 0, height: 0)
}
But it does not show any header.
SectionHeaderView.xib looks like this:
CollectionView looks like this: you see 3 sections, but you don't see a header.
What can I do to let AutoLayout determine the correct hight of the header?
Use a custom flow layout to perfectly manage the height of a header in a collection view with the Dynamic Type feature.
A header element is seen as a supplementary element for a collection view and the referenceSizeForHeaderInSection 'method' is only used for initialization: it's not called with the Dynamic Type feature. 🤯
The solution hereafter is based on the layoutAttributesForElements method of the custom layout that will be able to adapt the header height thanks to the UIFontMetrics scaledValue.
All that is fired by the invalidateLayout method called in the traitCollectionDidChange triggered when the user changes the font size. 🤓
STEP 1 âźą create a simple custom header class as follows for instance:
class MyHeaderClass: UICollectionReusableView {
override init(frame: CGRect) { super.init(frame: frame) }
required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) }
}
STEP 2 âźą create a new empty .xib adding a reusable view and name it the exact same name as the class it refers to: don't forget to change its class name in the Identity Inspector.
STEP 3 âźą register the .xib file in the controller:
collectionView.register(UINib(nibName: collectionViewHeaderFooterReuseIdentifier bundle: nil),
forSupplementaryViewOfKind: UICollectionElementKindSectionHeader,
withReuseIdentifier:collectionViewHeaderFooterReuseIdentifier)
STEP 4 âźą support this new cell in your data source (a header is a supplementary element for a collection view):
func collectionView(_ collectionView: UICollectionView,
viewForSupplementaryElementOfKind kind: String,
at indexPath: IndexPath) -> UICollectionReusableView {
if (kind == UICollectionView.elementKindSectionHeader) {
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind,
withReuseIdentifier: collectionViewHeaderReuseIdentifier,
for: indexPath) as! MyHeader
headerView.myLabel.text = "Your Header Title"
return headerView
} else {
return UICollectionReusableView(frame: CGRect.null) }
}
... and in your delegate (this intializes the header size and makes it appear):
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
referenceSizeForHeaderInSection section: Int) -> CGSize {
return CGSize(width: collectionView.frame.width, height: headerHeight)
}
.. after adding a global var headerHeight: CGFloat = 90.0 for initialization.
STEP 5 âźą create the custom flow layout to adapt the header height to the new font size:
class FlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttributes = super.layoutAttributesForElements(in: rect)
layoutAttributes?.forEach({ (attribute) in
if (attribute.representedElementKind == UICollectionView.elementKindSectionHeader) {
headerHeight = UIFontMetrics.default.scaledValue(for: 22.0)
attribute.frame.size.height = headerHeight
}
})
return layoutAttributes
}
}
Don't forget to update the storyboard in Interface Builder:
STEP 6 âźą inform the controller to trigger a layout update when the user changes the font size:
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
if (previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory) {
collectionView?.collectionViewLayout.invalidateLayout()
}
}
Following this rationale, the correct height of the headers is automatically set up according to the headers font size âźą I suggest to use the Xcode 11 new feature to test the Dynamic Type very quickly. đź‘Ť
Assuming you've got the proper constraints setup for your header, and you return a dynamically deduced height in your referenceSizeForHeaderInSection:
Observe UIContentSizeCategory.didChangeNotification in your class:
NotificationCenter.default.addObserver(self, selector: #selector(fontSizeChanged), name: UIContentSizeCategory.didChangeNotification, object: nil)
Use your fontSizeChanged function for updation:
#objc private func fontSizeChanged(_ sender: Any) {
//If you're dealing with minimal data, you can simply 'reloadData()'
//If not, I'm sure there are other efficient ways to make this work. I'm just exposing the provision
self.collectionView.reloadData()
}
Oh, and make sure you:
deinit {
NotificationCenter.default.removeObserver(self)
}

UICollectionView dequeues hidden cells when paging is enabled

When paging is enabled a UICollectionView dequeues only 3 cells and the rest of the cells are dequeued as hidden for no reason. Perhaps, it is the way how UICollectionView works in general, but in my project I really need that every time a cell is dequeued cellForItemAt indexPath: IndexPath method is called it actually creates a non-hidden instance of a custom UICollectionViewCell.
Maybe, it only creates 3 instances because it needs to manage the memory correctly. However, in my project a custom UICollectionViewCell also contains a another collectionView which consists of 3 custom collectionView cells. These collectionView cells also have tableViews inside of them the data of which is encapsulated in these cells. My main questions are: why does a UICollectionView only creates 3 instances in my case and what can I do to avoid this behavior?
The hierarchy in my actual project looks like this: UICollectionView -> UICollectionView -> 3 custom UICollectionViewCell -> each UICollectionViewCell contains a tableView -> each tableView contains a specific custom TableViewCell.
Here is the whole code that I wrote in as an example of what’s going in my actual project (this is not the actual project, but the behavior is the same):
ViewController:
class ViewController: UIViewController {
let cellId = "uniqueCellId"
let sampleWords: [String] = ["one", "two", "three", "four", "five", "six"]
let colors: [UIColor] = [.green, .yellow, .blue, .purple, .gray, .red]
override func viewDidLoad() {
super.viewDidLoad()
registerCollectionViewCustomCell()
prepareUI()
setupViews()
setCollectionViewLayoutToHorizontal()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
//collectionView is instantiated as a computed property. Initialized with a system flow layout. The frame is initially assigned to CGRect.zero because it is controlled by the constrains
lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
let cv = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
cv.backgroundColor = .white
cv.layer.cornerRadius = 8
cv.isPagingEnabled = true
cv.delegate = self
cv.dataSource = self
return cv
}()
}
Extension 1
extension ViewController {
private func prepareUI() {
view.backgroundColor = UIColor.black
navigationController?.navigationBar.barTintColor = .white
navigationItem.title = "Collection View"
}
private func registerCollectionViewCustomCell() {
collectionView.register(CustomCollectionViewCell.self, forCellWithReuseIdentifier: cellId)
}
private func setCollectionViewLayoutToHorizontal() {
if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
layout.scrollDirection = .horizontal
}
}
private func setupViews() {
view.addSubview(collectionView)
view.addConstraintsWithFormat(format: "H:|-15-[v0]-15-|", views: collectionView)
view.addConstraintsWithFormat(format: "V:|-80-[v0]-140-|", views: collectionView)
}
}
Extension 2:
//configuring the dataSource and the delegate methods for the collectionView
extension ViewController: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! CustomCollectionViewCell
cell.backgroundColor = colors[indexPath.item]
cell.wordLabel.text = sampleWords[indexPath.item]
print("_____________________________________")
print(cell.isHidden)
if cell.isHidden {
print("CUSTOM CELL INSTANCE NOT CREATED")
}
print("_____________________________________")
return cell
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return sampleWords.count
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return 0
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let size = CGSize(width: collectionView.frame.width, height: collectionView.frame.height)
return size
}
}
CustomCollectionViewCell class:
//custom cell class
class CustomCollectionViewCell: UICollectionViewCell {
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
print("instance of CustomCollectionViewCell is created")
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
let wordLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private func setupViews() {
addSubview(wordLabel)
wordLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
addConstraint(NSLayoutConstraint(item: wordLabel, attribute: .top, relatedBy: .equal, toItem: self, attribute: .top, multiplier: 1, constant: 70))
}
}
Helper method addConstraintsWithFormat:
//helper method to add constraints to a view
extension UIView {
func addConstraintsWithFormat(format: String, views: UIView...){
var viewsDictionary = [String : UIView]()
for (index, view) in views.enumerated() {
let key = "v\(index)"
view.translatesAutoresizingMaskIntoConstraints = false
viewsDictionary[key] = view
}
addConstraints(NSLayoutConstraint.constraints(withVisualFormat: format, options: NSLayoutFormatOptions(), metrics: nil, views: viewsDictionary))
}
}
This is all the code that I have. Everything is done without a storyboard only programmatically.
To debug and to understand what’s going on for myself I’ve added a few print statements:
1) “Print” statement that outputs the boolean value if a cell is hidden, and if a cell.isHidden -> true -> an instance of the custom CollectionViewCell has not been created.
2) “Print” statement within the custom CollectionViewCell class in the init method to see whether a cell has been created or not.
The output is always the following:
instance of CustomCollectionViewCell is created
______________________________________
false
______________________________________
instance of CustomCollectionViewCell is created
______________________________________
false
______________________________________
instance of CustomCollectionViewCell is created
______________________________________
false
______________________________________
______________________________________
true
CUSTOM CELL INSTANCE NOT CREATED
and after that the cell.Hidden always returns a true values.
I found that some people were struggling with the same problem. But the solutions did not help me. Because I don't use the method collectionView.reloadData() anywhere and I cannot change the size of a single cell.
UICollectionViewCell gets hidden randomly
UICollectionView dequeues the cells that will be immediately visible on screen as isHidden = false and additional ones it is preparing that are offscreen as isHidden = true. This reflects the actual state that these cells will have when you view is displayed. You cannot override this behaviour nor the state of isHidden by setting hidden = false. This state automatically is updated by the UICollectionView as cells scroll into and out of view.
I have implemented a similar structure, where a UICollectionViewCell contains another UICollectionView. (One scrolling horizontally, the second vertically within that cell.) I can state categorically it is not necessary for a UICollectionView cell to have isHidden = false in order to correctly lay out subviews of the cell. It sounds like you're probably thinking that this hidden attribute is the cause of a problem you're having (I've been down that exact thought path) but you're actually wrong and the cause of the issue is something else.
In my case, for the record, the problem cells were reporting an auto layout constraint conflict, which I had been ignoring as I thought it was unrelated. The fix was that after I added the subview to my UICollectionViewCell, I needed to call layoutSubviews() on my cell view and reloadData() on the collection view it contained. If I did not do this, the embedded collections worked sometimes, but when it re-used previously created cells, rather than creating new ones, they would fail to display due to autolayout conflicts. This meant there were specific paths to get there that would or would not display the issue depending on what cells were created in previous views that were available for reuse.

Make nested collection views with large cells accessible with VoiceOver

In the App that I am currently working on, there is a nested collection view setup. The outer collection view contains large cells that are vertically scrollable, and each cell contains another collection view with smaller cells (the layout in the sub-collection-views differs, that's why there are multiple ones).
Visually, everything works fine, but I came across an issue with Voice Over: When I reach the last item in the first child-collection view (i.e., the last item of the first cell of the outer collection view), I can't select the next item by swiping. Instead, iOS makes the sound as if the last item had been reached.
I can three-finger swipe to scroll down, and then select the next element, but this should obviously not be necessary. Selecting the last element and going back up in reverse order works as intended.
The problem only seems to occur when only one cell (of the outer collection view) is visible initially. If multiple cells are visible, everything works. However, I cannot change the sizes of the outer cells, because that would completely change the layout.
I created a sample view controller below that demonstrates the issue. The two collection views in the project are the same, except for the sizes of the cells. The first collection view with the smaller cells works as expected. The second one does not (when swiping to select the next cell, iOS plays the "beep" sound when the last visible cell was selected, even though there are more cells further down).
So, my question is:
How can I make the second collection view behave like the first one?
How can I make the cells further down in the second collection view accessible through horizontal swiping?
Solutions I have attempted so far:
One SO post suggests creating a wrapper view for the nested collection views. This seems to make no difference.
I attempted to implement the UIAccessibilityContainer protocol myself in a custom subclass of the collection view. This seems to break scrolling in strange ways.
Placing "layout changed" notifications in random places to see if it helps (it does not)
Edit:
Full code of a ViewController that demonstrates the issue:
class ViewController: UIViewController, UICollectionViewDataSource {
let outerCollectionView = UICollectionView(frame: CGRect(x: 0, y: 40, width: 320, height: 250), collectionViewLayout: UICollectionViewFlowLayout())
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .green
outerCollectionView.backgroundColor = .blue
(outerCollectionView.collectionViewLayout as! UICollectionViewFlowLayout).itemSize = CGSize(width: 250, height: 300)
outerCollectionView.register(OuterCell.self, forCellWithReuseIdentifier: "outerCell")
view.addSubview(outerCollectionView)
outerCollectionView.dataSource = self
}
func numberOfSections(in collectionView: UICollectionView) -> Int { return 1 }
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return 4 }
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "outerCell", for: indexPath) as! OuterCell
cell.outerIndex = indexPath.row + 1
return cell
}
}
class OuterCell: UICollectionViewCell, UICollectionViewDataSource {
var innerCollectionView = UICollectionView(frame: CGRect(x: 0, y: 0, width: 300, height: 500), collectionViewLayout: UICollectionViewFlowLayout())
var outerIndex: Int = 0
override init(frame: CGRect) {
super.init(frame: frame)
(innerCollectionView.collectionViewLayout as! UICollectionViewFlowLayout).itemSize = CGSize(width: 140, height: 80)
innerCollectionView.backgroundColor = .yellow
innerCollectionView.register(InnerCell.self, forCellWithReuseIdentifier: "innerCell")
contentView.addSubview(innerCollectionView)
innerCollectionView.dataSource = self
}
required init?(coder aDecoder: NSCoder) {
fatalError("unused")
}
func numberOfSections(in collectionView: UICollectionView) -> Int { return 1 }
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return 3 }
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "innerCell", for: indexPath) as! InnerCell
cell.label.text = "Cell \(outerIndex) / \(indexPath.item+1)"
return cell
}
}
class InnerCell: UICollectionViewCell {
let label = UILabel(frame: CGRect(origin: .zero, size: CGSize(width: 100, height: 30)))
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.addSubview(label)
}
required init?(coder aDecoder: NSCoder) {
fatalError("unused")
}
}
Edit: Here is a video that shows the issue: https://vimeo.com/229249955 (Note that I've added a little bit of description there).
From what i understood the problem is you are not able to scroll to the next cell in outer collection view once you have reached the last cell in inner collection view. But however a three finger swipe works.
This is because of the bounce property on collection view. Try turning off the bounce property on the inner collection view.
In the above code, add the following line of code in the OuterCell init method.
innerCollectionView.bounces = false

Add UICollectionView in UICollectionViewCell

I am using Swift to build an iOS application for the Hospital I work at.
Somehow, in a specific feature I have to put a UICollectionView inside the UICollectionViewCell. The one I want to achieve was for every content of the parent UICollectionView (vertical scrolling) would have several children (Which can be scrolled horizontal) depending on the parent row.
For illustration, I have to display list of doctors (name & photo) and then I have to display each of the practice schedule of them below their name and photo. The practice schedule would vary depending on each doctor. So, I have to put it inside the UICollectionView.
I have tried several solutions that I found on the web, but I still cannot approach it.
The most problem that I can't solve was: I don't know where is the place in the code to load the child data source (doctor schedule) and when I could load it, because I can't have two functions like below:
collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
this is the one I want to achieve
the UIImage and doctor name (UILabel) was in the parent UICollectionViewCell (scroll vertically), and then everything in the box (practice day n practice time) are the child UICollectionView (scroll horizontally)
PS: there are many doctors, and each of the doctor has several practice day.
please help me how to do this
If you really want to insert an collectionView inside a collectionViewCell then there is a pretty simple step. Create an instance of UICollectionView and add it the collectionViewCell. You can use this example if you like.
//
// ViewController.swift
// StackOverFlowAnswer
//
// Created by BIKRAM BHANDARI on 18/6/17.
// Copyright © 2017 BIKRAM BHANDARI. All rights reserved.
//
import UIKit
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
let cellId = "CellId"; //Unique cell id
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .red; //just to test
collectionView.register(Cell.self, forCellWithReuseIdentifier: cellId) //register collection view cell class
setupViews(); //setup all views
}
func setupViews() {
view.addSubview(collectionView); // add collection view to view controller
collectionView.delegate = self; // set delegate
collectionView.dataSource = self; //set data source
collectionView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true; //set the location of collection view
collectionView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true; // top anchor of collection view
collectionView.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true; // height
collectionView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true; // width
}
let collectionView: UICollectionView = { // collection view to be added to view controller
let cv = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()); //zero size with flow layout
cv.translatesAutoresizingMaskIntoConstraints = false; //set it to false so that we can suppy constraints
cv.backgroundColor = .yellow; // test
return cv;
}();
//deque cell
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath);
// cell.backgroundColor = .blue;
return cell;
}
// number of rows
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 5;
}
//size of each CollecionViewCell
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: view.frame.width, height: 200);
}
}
// first UICollectionViewCell
class Cell: UICollectionViewCell, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
let cellId = "CellId"; // same as above unique id
override init(frame: CGRect) {
super.init(frame: frame);
setupViews();
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: cellId); //register custom UICollectionViewCell class.
// Here I am not using any custom class
}
func setupViews(){
addSubview(collectionView);
collectionView.delegate = self;
collectionView.dataSource = self;
collectionView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true;
collectionView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true;
collectionView.topAnchor.constraint(equalTo: topAnchor).isActive = true;
collectionView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true;
}
let collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout();
layout.scrollDirection = .horizontal; //set scroll direction to horizontal
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout);
cv.backgroundColor = .blue; //testing
cv.translatesAutoresizingMaskIntoConstraints = false;
return cv;
}();
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath);
cell.backgroundColor = .red;
return cell;
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 5;
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: self.frame.width, height: self.frame.height - 10);
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
This might be a little late, but for people out here still trying to find an answer.
After some research and digging, I stumbled upon several posts stating reasons why you should NOT have your cell be the delegate for you collectionView. So, I was lost because pretty much all solutions I had found were doing this, until I finally found what I believe is the best way to have nested collectionViews.
To give some background, my app included not only one but 2 collectionViews inside different cells of another collectionView, so setting the delegates with tags and all that, wasn't really the best approach nor the correct OO solution.
So the best way to do it is the following:
First you have to created a different class to serve as your delegate for the inner collectionView. I did it as such:
class InnerCollectionViewDelegate: NSObject, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
// CollectionView and layout delegate methods here
// sizeForItemAt, cellForItemAt, etc...
}
Now, in your inner collectionView (or rather the cell where you have the inner collectionView) create a function that will allow you to set its delegates
class InnerCell: UICollectionViewCell {
var collectionView: UICollectionView
init() {
let layout = UICollectionViewFlowLayout()
collectionView = UICollectionView(frame: CGRect(x: 0, y: 0, width: frame.width, height: frame.height), collectionViewLayout: layout)
}
func setCollectionViewDataSourceDelegate(dataSourceDelegate: UICollectionViewDataSource & UICollectionViewDelegate) {
collectionView.delegate = dataSourceDelegate
collectionView.dataSource = dataSourceDelegate
collectionView.reloadData()
}
}
And lastly, in your ViewController where you have your outermost (main) collectionView do the following:
First instantiate the delegate for the inner collectionView
var innerDelegate = InnerCollectionViewDelegate()
and then
override func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
if let cell = cell as? InnerCell {
cell.setCollectionViewDataSourceDelegate(dataSourceDelegate: innerDelegate)
}
}
This might not be perfect, but at least you have separation of concerns, as your cell should NOT be the delegate. Remember your cell should only be responsible for displaying info, not trying to figure out what the size of the collectionView should be, etc.
I did find similar answers that dealt with setting the collectionViews tag and whatnot, but I found that that made it way harder to deal with each collectionView individually, plus dealing with tags can't result in spaghetti code or unintended behaviours.
I left out registering and dequeuing the cell, but I'm sure you're all familiar with that. If not, just let me know and I'll try to walk you through it.
There are multiple ways to tackle the problem of a horizontal collection inside another a vertical list collection.
The simplest would be to make the ViewController you are presenting the main UICollectionView to the dataSouce and delegate for both collection views. You can set the collection view inside the cell also to be served from here.
This article about placing collection view inside a table view explains the problem in a much elaborate way and the code for the same can be found here.
Add collectionView in collection view cell , and add delagate methods in collectionviewclass.swift. Then pass list you want to show in cell in collectionview's cellforrowatindexpath. If you didn't success on implimenting it then let me know . i will provide you code as i have already implemented it in that way.

Collection View isn't being displayed, cellForItemAtIndexPath is never called

I have a UITableView with custom cells containing UIStackView. As part of preparing a new cell, when the Table View adds new cells via its cellForRowAtIndexPath method, it instantiates and adds any collection views (of type MultaCollectionView) and any UILabel which need to be added to the cell’s Stack View (a cell may include various collection views). In theory, a stack view contains a sequence of Labels and Collection Views.
Although labels are displaying correctly, the Collection View is not being displayed at runtime. The Collection View I’m attempting to display is defined in a .xib Interface Builder document.
The Collection View’s numberOfSectionsInCollectionView method is getting called, however the collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) method is never called.
Why is the collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) method never called? Why aren’t Collection Views being rendered in the Stack View?
import UIKit
private let reuseIdentifier = "Cell"
class MultaCollectionView: UICollectionView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, UICollectionViewDelegate {
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.delegate = self
self.dataSource = self
}
class func instanceFromNib() -> UICollectionView {
return UINib(nibName: "multas", bundle: nil).instantiateWithOwner(nil, options: nil)[0] as! UICollectionView
}
func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 2
}
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(reuseIdentifier, forIndexPath: indexPath)
return cell
}
}
The parent TableView adds Cells via the following method:
func addSubviewsToCellStack(cell: ArticuloTableViewCell, texto: [[String: String]]) {\
if isLabel == true {
let label = UILabel()
label.text = "blah"
subview = label
cell.textoStack.addArrangedSubview(subview)
} else {
let colview = MultaCollectionView.instanceFromNib()
cell.textoStack.addArrangedSubview(colview)
}
}
}
Since you are getting the call back for the numberOfSectionsInCollectionView this would suggest the datasource is connected up correctly but if a cell is not going to be visible based on the current layout then the collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) will not get called until it is needed. The reason a cell might not be visible could be because your sectionInsets for the collectionView are set large enough that the first cell would be positioned outside the visible area of the collectionView.
Another reason could be that the collectionView's frame is too small to display any cells.
If your collectionview as a size of {0,0} you should get the calls as you are seeing to the numberOfSectionsInCollectionView but will not get the calls to cellForItemAtIndexPath since the cells will not be visible.
Perhaps try ensuring that your collectionView has a frame size that is large enough to display the cells you expect to see. You may be seeing the UILabels correctly because they have an implicit intrinsicContentSize which ensures they display well in a stackView whereas the CollectionView is just as happy to have a size of 0,0 as it would be with any other size. Try giving the collectionView explicit width and height constraints to see if that helps.
My hypothesis would be that since this is happening inside cells of a UITableView, whenever a table view cell gets put back in the "reuse pool", the delegates of the collection view get disconnected or disabled. This would depends on the code that provide cells to the table view (which we're not seeing in the code sample).
There could also be some code elsewhere that clears the datasource from the collection view. (unlikely but worth checking)
The issue was resolved by overriding UIView's intrinsicContentSize() method in my UICollectionView subclass as follows (check out this question):
override func intrinsicContentSize() -> CGSize {
return self.collectionViewLayout.collectionViewContentSize()
}
As an aside, I had also forgotten to register a class for the Cell reuse identifier. So I modified the initializer:
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.delegate = self
self.dataSource = self
self.registerClass(UICollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)
}
I FINALLY got it to work. When transitioning from a storyboard view controller to a non storyboard collection view controller you must do this while pushing to the controller :
func showChatLogController(user: User) {
let chatLogController = ChatLogController(collectionViewLayout: UICollectionViewFlowLayout())
chatLogController.user = user
chatLogController.hidesBottomBarWhenPushed = true
navigationController?.pushViewController(chatLogController, animated: true)
}
To be more specific you must use collectionViewLayout: UICollectionViewFlowLayout

Resources