Almost every application contain a list screen where a vertical/horizontal list of scrollable items is used. Except a few, almost all cases can be fulfilled either by a TableView, or a CollectionView using Flow-Layout. In this post, we will be working on one of such few cases, creating a Custom CollectionView Layout for implementing a Mark-Sheet layout, showing user’s achieved marks in their respective subjects like:

Before starting, I hope that you already have used CollectionViews and Flow-Layout before. If not, I recommend you to go through WWDC18 video.

Getting Started:

The demo app that we are creating will contain a list (implemented using a UICollectionView) having a simple vertical scrollable behavior, where each Student will define a separate section, and for each student, their marks in a particular subject will be the item. Therefore,
– N
umber of Sections = Total Number of Students
– Number of Items in each Section = Total number of Subjects 

To get started, download the starter project and open MarkSheet.xcodeproj with Xcode. The starter project contains a storyboard scene, having a UICollectionView. The UICollectionView’s data-source object is also connected and conformed to the ViewController class. The data-models have already been created.

*  *  * 

Step 1: Create the CollectionView elements

Cells:

These are the collection view’s main elements, representing a single data item in the collection’s data-source. For our demo app, each student’s marks will be a Cell element

Open Main.Storyboard and select the CollectionView’s default provided cell. After that, set an identifier for the cell (I am using marksCell as an identifier) and then, add your UILabel as shown:

CollectionView’s Cell Item

Now, create a subclass of UICollectionViewCell, set the above-created cell’s class to this in the Storyboard and connect the UILabel to its outlet.

class MarksCollectionViewCell: UICollectionViewCell {
   static let identifier = "marksCell"
   @IBOutlet weak private var marksLabel: UILabel!
   func setup(userMarks: Double, outOf totalMarks: Double) {
      marksLabel.text = String(format: "%.2f / %.2f", userMarks, totalMarks)
   }
}

Supplementary Views:

These also contain data, but are different than Cells. They are mostly used as Header/Footer view’s for different sections or for the whole collection view as well. As one may guess, the Subject’s names (on the top of each column) and the Student’s names (at the start of each row) will all be Supplementary Views.

For our demo, we need to create two kinds of headers, one for each row and column. As both of them would be having the same kind of API’s, create a protocol HeaderSupplementaryViewProtocol containing the necessary requirements for our Supplementary View’s.

protocol CollectionReusableViewProtocol: class {
   static var identifier: String { get }
   static var reuseIdentifier: String { get }
}
   
protocol HeaderSupplementaryViewProtocol: CollectionReusableViewProtocol {
   func setTitle(_ title: String)
}
   
typealias HeaderSupplementaryView = UICollectionReusableView & HeaderSupplementaryViewProtocol

NOTE: Here, we are using two protocols rather than one, so that we can easily work with a different kind of UICollectionReusableView if any such requirement comes.

Create two XIB’s (for the row and column headers). Delete the default provided UIView in the both the XIB and add the UICollectionReusableView from the UI Controls Library Pane (Go View > Libraries > Show Library or hit Shift-Cmd-L).

For both the header’s XIB, create a layout similar to that of the MarksCollectionCell’s layout. As for any changes, set the alignment for the row header XIB’s label to be right aligned and center aligned for the column header XIB’s label and update constraints, as shown in the image below.

Column and Row Header’s XIBs

NOTE: Set the identifier property (in the attributes inspector, right side). This is the reuse-identifier for these UICollectionReusableView’s which will be used later.  

class ColumnHeaderSupplementaryView: HeaderSupplementaryView {
    static let identifier = "ColumnHeaderSupplementaryView"
    static let reuseIdentifier = "columnHeaderCRView"
    @IBOutlet weak private var titleLbl: UILabel!    
    func setTitle(_ title: String) {
        titleLbl.text = title
    }
}
   
class RowHeaderSupplementaryView: HeaderSupplementaryView {
    static let identifier = "RowHeaderSupplementaryView"
    static let reuseIdentifier = "rowHeaderCRView"
    @IBOutlet weak private var titleLbl: UILabel!    
    func setTitle(_ title: String) {
        titleLbl.text = title
    }
}

Set the class for the column and row XIB’s to the above defined classes respectively, connect the respective outlets for both the headers and do check that the reuseIdentifier and the one set in the XIB’s Attributes Inspector are both same.

Decoration Views:

These elements are a part of the Collection View’s Layout and are not connected to the collection’s data. These are basically used for the visual decorations. We will be using these for showing the separator lines in our demo app.

As we will be using these to show separator lines, we won’t be needing a XIB for that. We can simply subclass UICollectionReusableView and set the backgroundColor property as per our needs.

class CollectionDecorationView: UICollectionReusableView, CollectionReusableViewProtocol {
    static var identifier = "CollectionDecorationView"
    static var reuseIdentifier = "decorationView"
    
    override var reuseIdentifier: String? {
        return CollectionDecorationView.reuseIdentifier
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        customInit()
    }
    
    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)!
        customInit()
    }
    
    func customInit() {
        backgroundColor = UIColor.black.withAlphaComponent(0.3)
    }
}

*  *  * 

Step 2: Subclass UICollectionViewLayout

Create a new class MarkSheetCollectionViewLayout, inheriting UICollectionViewLayout. Add the code below in your layout class.

class MarkSheetCollectionViewLayout: UICollectionViewLayout {
    
    //MARK: #1
    private enum Constants {
        static let rowHeaderWidth: CGFloat = 100
        static let oneRowHeight: CGFloat = 50
        static let columnHeaderHeight: CGFloat = 40
        static let separatorHeight: CGFloat = 1
    }
    
    //MARK: #2
    private var numberOfColumns: Int {
        return collectionView!.numberOfItems(inSection: 0)
    }
   
    private var numberOfRows: Int {
        return collectionView!.numberOfSections
    }
   
    private var columnWidth: CGFloat {
        let contentWidth = collectionViewContentSize.width - Constants.rowHeaderWidth
        return (contentWidth / CGFloat(numberOfColumns))
    }
    
    //MARK: #3
    override var collectionViewContentSize: CGSize {
        let contentHght = Constants.columnHeaderHeight + Constants.oneRowHeight * CGFloat(numberOfRows)
        return CGSize(width: collectionView?.bounds.width ?? 0, height: contentHght)
    }
    
    //MARK: #4
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        return collectionView?.bounds.width != newBounds.width
    }

    //MARK: #5
    override init() {
        super.init()
        commonInit()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    private func commonInit() {
        register(CollectionDecorationView.self, forDecorationViewOfKind: CollectionDecorationView.identifier)
    }
}

Following things need to taken care of in the above code snippet:

  1. Here, we created a private enum Constants to contain the constant values that we will be using in our layout.
  2. numberOfRows term here might get you confused but it is basically for the number of students we have in our data-source. As for numberOfColumns, it will be a total number of the subjects available (which will be same for every student).
  3. collectionViewContentSize contains the size information for the whole CollectionView’s content. This will determine the direction as well as the extent of scrolling in the CollectionView. As we have vertical scrolling, we will be using the whole width available, but the height will be in accordance with the records for all the students, plus the height of the column’s header
  4. Our CollectionView’s content’s height is calculated as per the data it has to display, so it won’t be changing if the CollectionView’s height changes. But as we want to adapt to the changes in CollectionView‘s width, we will match the current rect’s and the new rect’s width and if they are not equal, we would invalidate our layout.

NOTE: Here we are checking the bounds change, rather than just returning true because our layout is not changing while we are scrolling. If we would have just returned true, then our CollectionView’s layout would be getting invalidated every time the user scroll, creating a performance issue.

5. The inherited initializers are overridden in order to call commonInit() method for registering the Decoration View with the layout.

*  *  * 

Step 3: Create Layout Attributes for Cell Items

Here, layoutAttributesForItem(at: IndexPath) is inherited method, which creates and returns the layout attribute for a Cell.

extension MarkSheetCollectionViewLayout {
    
    //MARK: #6
    private func indexPathsForItems(in rect: CGRect) -> [IndexPath] {
        let startItemRow = rowIndex(at: rect.minY)
        let startItemColumn = columnIndex(at: rect.minX)
        let endItemRow = rowIndex(at: rect.maxY)
        let endItemColumn = columnIndex(at: rect.maxX)
        var indexPaths = [IndexPath]()
        for rowIndex in startItemRow...endItemRow {
            for columnIndex in startItemColumn...endItemColumn {
                indexPaths.append(IndexPath(item: columnIndex, section: rowIndex))
            }
        }
        return indexPaths
    }
    
    //MARK: #7
    private func frame(forRow row: Int, column: Int) -> CGRect {
        let cellWidth = columnWidth
        var frame = CGRect.zero
        frame.origin.x = Constants.rowHeaderWidth + cellWidth * CGFloat(column)
        frame.origin.y = Constants.columnHeaderHeight + Constants.oneRowHeight * CGFloat(row)
        frame.size = CGSize(width: cellWidth, height: Constants.oneRowHeight)
        return frame
    }
    
    //MARK: #8
    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        let cellAttribute = UICollectionViewLayoutAttributes(forCellWith: indexPath)
        cellAttribute.frame = frame(forRow: indexPath.section, column: indexPath.item)
        return cellAttribute
    }
    
    //MARK: #9
    private func createLayoutAttributesForItems(in rect: CGRect) -> [UICollectionViewLayoutAttributes] {
        var layoutAttributes = [UICollectionViewLayoutAttributes]()
        let visibleItemsIndexPaths = indexPathsForItems(in: rect)
        visibleItemsIndexPaths.forEach { (indexPath) in
            guard let cellAttribute = layoutAttributesForItem(at: indexPath)  else { return }
            layoutAttributes.append(cellAttribute)
        }
        return layoutAttributes
    }
}

6. indexPathsForItems(in:CGRect) uses the rect passed to it to find all the indexPaths for the cell items that intersect with the rect. rowIndex(at:CGFloat) takes a y-axis coordinate value and returns the respective row/section/student number that is on that y-point. Same goes for columnIndex(at:CGFloat) , which takes an x-axis coordinate value and returns the respective column/item/subject number that is on that x-point. Their implementation is provided after the above-code explanation.

7. frame(forRow:Int,column:Int) uses the row and column indices to calculate the frame for the collection view cell. The calculation is straightforward, adding the column header’s height to the origin-y value and row header’s width to the origin-x value, and using the constant height and calculated column width for the cell’s size.

8. layoutAttributesForItem(at:IndexPath) is an overridden method, which is creating and returning the layout attribute object for the cell item at the specified indexPath. It also sets the frame for the cell item in the layout attribute. The CollectionView also uses this method to get the layout attributes regarding a certain item.

9. createLayoutAttributesForItems(in:CGRect) is the method that orchestrates the whole flow of creating the layout attributes for all the items that lie in the specified rect. It first fetches IndexPaths for all the items that intersect the rect. Then, calls the inherited layoutAttributesForItem(at:) method to create the layout attributes for each of the received indexPath.

Here is the implementation for rowIndex(at:CGFloat) and columnIndex(at:CGFloat). The calculation is straightforward, for example, in the column index calculation, the minimum of the calculated value and the max column value is returned as, so the column index doesn’t exceed more than the total available columns, or basically a wrong value is returned. Same is done for row index calculation.

extension MarkSheetCollectionViewLayout {
    
    private func columnIndex(at xPosition: CGFloat) -> Int {
        let index = max(0, Int((xPosition - Constants.rowHeaderWidth) / columnWidth))
        return min(index, numberOfColumns - 1)
    }
    
    private func rowIndex(at yPosition: CGFloat) -> Int {
        let index = max(0, Int((yPosition - Constants.columnHeaderHeight) / Constants.oneRowHeight))
        return min(index, numberOfRows - 1)
    }
}
*  *  * 

Step 4: Create Layout Attributes for Supplementary Views

Just like for creating layout attributes for the cell, we need to first find the IndexPath’s for all the supplementary views that intersects to that rect, and then create and return them back. Here, layoutAttributesForSupplementaryView(ofKind:String, at:IndexPath) is an overridden method, creating and returning the layout attribute for a Supplementary View.

extension MarkSheetCollectionViewLayout {

    //MARK: #10
    private func indexPathsForVisibleRows(in rect: CGRect) -> [IndexPath] {
        guard rect.minX <= Constants.rowHeaderWidth else { return [] }
        let startRowIndex = rowIndex(at: rect.minY)
        let endRowIndex = rowIndex(at: rect.maxY)
        var indexPaths = [IndexPath]()
        for i in startRowIndex...endRowIndex {
            indexPaths.append(IndexPath(item: 0, section: i))
        }
        return indexPaths
    }
    
    private func indexPathsForVisibleColumns(in rect: CGRect) -> [IndexPath] {
        guard rect.minY <= Constants.columnHeaderHeight else { return [] }
        let startColumnIndex = columnIndex(at: rect.minX)
        let endColumnIndex = columnIndex(at: rect.maxX)
        var indexPaths = [IndexPath]()
        for i in startColumnIndex...endColumnIndex {
            indexPaths.append(IndexPath(item: i, section: 0))
        }
        return indexPaths
    }

    //MARK: #11
    override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        let layoutAttributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: elementKind, with: indexPath)
        if elementKind == ColumnHeaderSupplementaryView.identifier {
            let cellWidth = columnWidth
            let xPosition = Constants.rowHeaderWidth + (cellWidth * CGFloat(indexPath.item))
            layoutAttributes.frame = CGRect(x: xPosition, y: 0, width: cellWidth, height: Constants.columnHeaderHeight)
        } else if elementKind == RowHeaderSupplementaryView.identifier {
            let yPosition = Constants.columnHeaderHeight + (Constants.oneRowHeight * CGFloat(indexPath.section))
            layoutAttributes.frame = CGRect(x: 0, y: yPosition, width: Constants.rowHeaderWidth, height: Constants.oneRowHeight)
        }
        return layoutAttributes
    }
    
    //MARK: #12
    private func createLayoutAttributesForHeaders(in rect: CGRect) -> [UICollectionViewLayoutAttributes] {
        func createLayoutAttributes(forHeaderType headerType: HeaderSupplementaryViewProtocol.Type, visibleIndexPaths: [IndexPath]) -> [UICollectionViewLayoutAttributes] {
            var layoutAttributes = [UICollectionViewLayoutAttributes]()
            visibleIndexPaths.forEach { (indexPath) in
                guard let headerAttribute = layoutAttributesForSupplementaryView(ofKind: headerType.identifier, at: indexPath)
                    else { return }
                layoutAttributes.append(headerAttribute)
            }
            return layoutAttributes
        }
    
        let columnHeaderAttributes = createLayoutAttributes(forHeaderType: ColumnHeaderSupplementaryView.self, visibleIndexPaths: indexPathsForVisibleColumns(in: rect))
        let rowHeaderAttributes = createLayoutAttributes(forHeaderType: RowHeaderSupplementaryView.self, visibleIndexPaths: indexPathsForVisibleRows(in: rect))
        return columnHeaderAttributes + rowHeaderAttributes
    }
}

10. Both the methods are pretty-much self-explanatory. Kindly note the IndexPath returned by them. This can be customized as per the need.

11. layoutAttributesForSupplementaryView(ofKind: String, at: IndexPath) is an inherited method, used for creation of the layout attributes for the supplementary view. The frame is also calculated as per the elements kind value.

NOTE: Any number of Supplementary views can be created for a single indexPath, as long as they can be differentiated using its kind property.

12. createLayoutAttributesForHeaders(in: CGRect) uses the previous defined-methods and returns the created layout-attributes for the Supplementary Views.

*  *  * 

Step 5: Create Layout Attributes for Decoration Views

Again, same approach will be used for creating the layout attributes for the decoration views. Here, layoutAttributesForDecorationView(ofKind:String,at:IndexPath) is an overridden method, creating and returning the layout attribute for a Decoration Views.

extension MarkSheetCollectionViewLayout {
    
    private func createLayoutAttributesForSeparators(in rect: CGRect) -> [UICollectionViewLayoutAttributes] {
        var layoutAttributes = [UICollectionViewLayoutAttributes]()
        let visibleIndexPaths = indexPathsForVisibleRows(in: rect)
        visibleIndexPaths.forEach { (indexPath) in
            guard let headerAttribute = layoutAttributesForDecorationView(ofKind: CollectionDecorationView.identifier, at: indexPath)
                else { return }
            layoutAttributes.append(headerAttribute)
        }
        return layoutAttributes
    }
    
    override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        guard elementKind == CollectionDecorationView.identifier  else { return nil }
        let layoutAttributes = UICollectionViewLayoutAttributes(forDecorationViewOfKind: elementKind, with: indexPath)
        let yPos = Constants.columnHeaderHeight + (Constants.oneRowHeight * CGFloat(indexPath.section))
        layoutAttributes.frame = CGRect(x: 0, y: yPos, width: collectionViewContentSize.width, height: Constants.separatorHeight)
        layoutAttributes.zIndex = 2
        return layoutAttributes
    }
}

NOTE: In the layoutAttributesForDecorationView(ofKind:String,at:IndexPath) method, we are also setting the zIndex property of the decoration views. This makes sure the decoration view does not hide behind any other CollectionView element.

*  *  * 

Step 6: Merge and Return the Layout Attributes for all the elements

Now that we have created the layout attributes for all the elements, it is time to use it.

extension MarkSheetCollectionViewLayout {
   
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var layoutAttributes = [UICollectionViewLayoutAttributes]()
        layoutAttributes.append(contentsOf: createLayoutAttributesForItems(in: rect))
        layoutAttributes.append(contentsOf: createLayoutAttributesForHeaders(in: rect))
        layoutAttributes.append(contentsOf: createLayoutAttributesForSeparators(in: rect))
        return layoutAttributes
    }
}
*  *  * 

Step 7: Using the Layout for our CollectionView

Now that we are done with the layout, it’s time to use the layout for the Collection View we added to our controller scene in the beginning.

Open Main.storyboard, select the CollectionView and set its class to your collection view layout name.

Coming back to the Controller:

Add the below code in your controller. Here, we are registering the Row and Column Header’s supplementary view to our UICollectionView object.

extension ViewController {
   
    override func viewDidLoad() {
        super.viewDidLoad() 
        registerSupplimentaryViews()
    }
   
    private func registerSupplimentaryViews() {
        func registerReusableView(ofType type: HeaderSupplementaryViewProtocol.Type) {
            let nib = UINib(nibName: type.identifier, bundle: nil)
            collectionView.register(nib, forSupplementaryViewOfKind: type.identifier, withReuseIdentifier: type.reuseIdentifier)
        }
        registerReusableView(ofType: RowHeaderCollectionReusableView.self)
        registerReusableView(ofType: ColumnHeaderCollectionReusableView.self)
    }
   
    private func subject(atColumn columnIndex: Int) -> Subject {
        return Subject.allCases[columnIndex]
    }
}

At last, it is time to create and return our CollectionView’s Cells and Supplementary Views by implementing these UICollectionViewDataSource protocol methods:

extension ViewController: UICollectionViewDataSource {
    // ....
   
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MarksCollectionViewCell.identifier, for: indexPath) as! MarksCollectionViewCell
        let userMarks = students[indexPath.section].marks[subject(atColumn: indexPath.item)] ?? 0
        cell.setup(userMarks: userMarks, outOf: Constants.maximumAllotedMarks)
        return cell
    }
   
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        let headerViewType: HeaderSupplementaryViewProtocol.Type
        let title: String
        if kind == RowHeaderSupplementaryView.identifier {
            headerViewType = RowHeaderSupplementaryView.self
            title = students[indexPath.section].name
        } else if kind == ColumnHeaderSupplementaryView.identifier {
            headerViewType = ColumnHeaderSupplementaryView.self
            title = subject(atColumn: indexPath.item).title
        } else {
            return UICollectionReusableView()
        }
        let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: headerViewType.identifier, withReuseIdentifier: headerViewType.reuseIdentifier, for: indexPath) as! HeaderSupplementaryView
        headerView.setTitle(title)
        return headerView
    }
}

Here everything is almost already discussed and easily understandable.

You can find the completed project here.

This wraps up everything. Go ahead and give it a try:

 

Share this: