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)
}
Related
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!
}
}
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.
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
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.
Scenario - I have to create a custom UICollectionView class programmatically which has to be presented in any place I want.
Code till now -
For custom UICollectionView
class ABSegmentView: UICollectionView,UICollectionViewDelegateFlowLayout,UICollectionViewDataSource {
var segmentProperties=segmentControlProperties()//segmentControlProperties is a modal class having relevant details regarding about population of collection view.
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
self.dataSource = self
self.delegate = self
self.registerClass(UICollectionViewCell.self, forCellWithReuseIdentifier: "cellIdentifier")
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int{
return 1
}
func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
print(segmentProperties.titleArray)
return segmentProperties.titleArray.count//data properly being received over here
}
//not getting called
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = self.dequeueReusableCellWithReuseIdentifier("cellIdentifier", forIndexPath: indexPath)
cell.backgroundColor = UIColor.redColor()
return cell
}
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize{
return CGSizeMake(self.segmentProperties.segmentHeight, self.segmentProperties.segmentWidth)
}
}
Code for adding this collection view in some place -
let segment = ABSegmentView(frame: CGRectMake(0, 0, 200, 200), collectionViewLayout: UICollectionViewLayout())
segment.segmentProperties.segmentWidth = 60
segment.segmentProperties.segmentHeight = 50
segment.segmentProperties.titleArray = ["heyy","heyy","heyy","heyy","heyy","heyy"]
self.view.addSubview(segment)
So what is getting added is only an empty collection view.
Reason Figured out -
On debugging I found that my data source method cellForItemAtIndexPath() & func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) are not getting called.
Question - I am not sure what I am doing for my required scenario is the right implementation or not. Please amend me if I am missing something somewhere or what might be my mistakes.
Edit :
Answer -
Thanks to Santosh's answer. I figured out that I misunderstood the concept of collectionViewLayout.
Findings -
I have to set a proper flow layout for the collection view as a
proper flow layout with correct spacing and other values are quite
essential for a collection view to be properly laid.
CollectionView Flow layout is what lays the UI of collection view i.e the grid view.
There are many questions in StackOverflow which relates of data source methods not being called due to improper laying of collectionViewFlowLayout.
References that worked out for me apart from accepted answer -
https://stackoverflow.com/a/14681999/5395919
Other instances when some one can encounter such problems -
-When we set our cell size quite bigger than our collection view.
-When our cell layout size is too big or isn't appropriately held by the collection view.
You are using UICollectionViewLayout to instantiate your custom collectionView with layout. Try using UICollectionViewFlowLayout. Below code may help you, let me know if it works:
let layout = UICollectionViewFlowLayout()
layout.minimumInteritemSpacing = 20//you can change this value
layout.scrollDirection = .Vertical//or may be .Horizontal
let segment = ABSegmentView(frame: CGRectMake(0, 0, 200, 200), collectionViewLayout: layout)
segment.segmentProperties.segmentWidth = 60
segment.segmentProperties.segmentHeight = 50
segment.segmentProperties.titleArray = ["heyy","heyy","heyy","heyy","heyy","heyy"]
self.view.addSubview(segment)
segment.reloadData()
In order to get datasource updated you need to call reloadData()