0

I want to build the following layout using UIKit.
enter image description here

Currently I'm using an UICollectionView in combination with a Composotional Layout. The following code produces this result:
enter image description here

Relevant Method:

private static func createCompositionalLayout() -> UICollectionViewCompositionalLayout {
    let headerItem = NSCollectionLayoutItem(
        layoutSize: NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .absolute(35)
        )
    )
    
    let horizontalGroupItem = NSCollectionLayoutItem(
        layoutSize: NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(0.95),
            heightDimension: .fractionalHeight(1.0)
        )
    )
    let horizontalMainGroup = NSCollectionLayoutGroup.horizontal(
        layoutSize: NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .absolute(100)
        ),
        subitems: [horizontalGroupItem]
    )
    horizontalMainGroup.interItemSpacing = .fixed(10)
    
    let group = NSCollectionLayoutGroup.vertical(
        layoutSize: NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .absolute(135)
        ),
        subitems: [headerItem, horizontalMainGroup])
    
    let section = NSCollectionLayoutSection(group: group)
    section.interGroupSpacing = 10
    section.orthogonalScrollingBehavior = .continuous
    
    let layout = UICollectionViewCompositionalLayout(section: section)
    
    return layout
}

So the main problem is, that the top element doesn't take up the full width of the section. Instead it's limited to the width of group. Does anyone know how to get the desired result? :-)

Full Example:

import UIKit

class ViewController: UIViewController {
    
    private let colors: [UIColor] = [.systemTeal, .systemBlue, .systemGray, .systemOrange]
    private let collectionView = UICollectionView(frame: .zero, collectionViewLayout: ViewController.createCompositionalLayout())
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(collectionView)
        
        collectionView.backgroundColor = .systemBackground
        collectionView.frame = view.bounds
        collectionView.dataSource = self
        collectionView.register(MyCollectionViewCell.self, forCellWithReuseIdentifier: MyCollectionViewCell.reuseID)
    }
    
private static func createCompositionalLayout() -> UICollectionViewCompositionalLayout {
    let headerItem = NSCollectionLayoutItem(
        layoutSize: NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .absolute(35)
        )
    )
    
    let horizontalGroupItem = NSCollectionLayoutItem(
        layoutSize: NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(0.95),
            heightDimension: .fractionalHeight(1.0)
        )
    )
    let horizontalMainGroup = NSCollectionLayoutGroup.horizontal(
        layoutSize: NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .absolute(100)
        ),
        subitems: [horizontalGroupItem]
    )
    horizontalMainGroup.interItemSpacing = .fixed(10)
    
    let group = NSCollectionLayoutGroup.vertical(
        layoutSize: NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .absolute(135)
        ),
        subitems: [headerItem, horizontalMainGroup])
    
    let section = NSCollectionLayoutSection(group: group)
    section.interGroupSpacing = 10
    section.orthogonalScrollingBehavior = .continuous
    
    let layout = UICollectionViewCompositionalLayout(section: section)
    
    return layout
}

}

extension ViewController: UICollectionViewDataSource {
    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: MyCollectionViewCell.reuseID, for: indexPath) as? MyCollectionViewCell
        cell?.backgroundColor = colors[indexPath.item]
        cell?.label.text = "\(indexPath.item)"
        
        return cell ?? UICollectionViewCell()
    }
}

//MARK: - CollectionViewCell
class MyCollectionViewCell: UICollectionViewCell {
    static let reuseID = "myCell"
    
    let label = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.addSubview(label)
        label.frame = contentView.bounds
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

finebel
  • 2,227
  • 1
  • 9
  • 20

1 Answers1

1

Currently you add HeaderItem to the group, that's why it is limited to the width of the group.

You could achieve the desired effect with a boundarySupplementaryItems for a NSCollectionLayoutSection.

To do it you need to update your HeaderItem:

 let headerItem = NSCollectionLayoutBoundarySupplementaryItem(
     layoutSize: NSCollectionLayoutSize(
         widthDimension: .fractionalWidth(1.0),
         heightDimension: .absolute(35)
     ),
     elementKind: "section-header",
     alignment: .top
  )

Add it to layout section:

section.boundarySupplementaryItems = [headerItem]

Register it:

collectionView.register(HeaderRV.self, forSupplementaryViewOfKind: "section-header", withReuseIdentifier: "HeaderRV")

Create HeaderRV class:

class HeaderRV: UICollectionReusableView {
    
    let label = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.addSubview(label)
        label.frame = self.bounds
    }
    
    required init?(coder: NSCoder) { 
        fatalError("Not happening") 
    }
}

Finally you need to add collectionView(_:viewForSupplementaryElementOfKind:at:) in your UICollectionViewDataSource:

func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
    let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "HeaderRV", for: indexPath) as! HeaderRV
    
    header.backgroundColor = .red
    header.label.text = "\(indexPath.item)"
    
    return header
}

Full code:

import UIKit

class ViewController: UIViewController {
    
    private let colors: [UIColor] = [.systemTeal, .systemBlue, .systemGray, .systemOrange]
    private let collectionView = UICollectionView(frame: .zero, collectionViewLayout: ViewController.createCompositionalLayout())
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(collectionView)
        
        collectionView.backgroundColor = .systemBackground
        collectionView.frame = CGRect(x: 5, y: 0, width: view.bounds.width - 10, height: view.bounds.height)
        collectionView.dataSource = self
        
        collectionView.register(MyCollectionViewCell.self, forCellWithReuseIdentifier: MyCollectionViewCell.reuseID)
        collectionView.register(HeaderRV.self, forSupplementaryViewOfKind: "section-header", withReuseIdentifier: "HeaderRV")
    }
    
    private static func createCompositionalLayout() -> UICollectionViewCompositionalLayout {
        let headerItem = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .absolute(50)
            ),
            elementKind: "section-header",
            alignment: .top
        )
        headerItem.contentInsets.bottom = 5
                
        let horizontalGroupItem = NSCollectionLayoutItem(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .fractionalHeight(1.0)
            )
        )
                
        let group = NSCollectionLayoutGroup.horizontal(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(0.65),
                heightDimension: .absolute(135)
            ),
            subitems: [horizontalGroupItem])
                
        let section = NSCollectionLayoutSection(group: group)
        section.interGroupSpacing = 50
        section.boundarySupplementaryItems = [headerItem]
        section.orthogonalScrollingBehavior = .continuous
        
        let layout = UICollectionViewCompositionalLayout(section: section)
        
        return layout
    }
    
}

extension ViewController: UICollectionViewDataSource {
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 4
    }
    
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "HeaderRV", for: indexPath) as! HeaderRV
        
        header.backgroundColor = .red
        header.label.text = "\(indexPath.item)"
        
        return header
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MyCollectionViewCell.reuseID, for: indexPath) as? MyCollectionViewCell
        cell?.backgroundColor = colors[indexPath.item]
        cell?.label.text = "\(indexPath.item)"
        
        return cell ?? UICollectionViewCell()
    }
}

//MARK: - CollectionViewCell
class MyCollectionViewCell: UICollectionViewCell {
    static let reuseID = "myCell"
    
    let label = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.addSubview(label)
        label.frame = contentView.bounds
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

//MARK: - CollectionReusableView
class HeaderRV: UICollectionReusableView {
    
    let label = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.addSubview(label)
        label.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height - 5)
    }
    
    required init?(coder: NSCoder) { fatalError("Not happening") }
}
Viktor Gavrilov
  • 830
  • 1
  • 8
  • 13
  • Thanks for your answer! It comes close to the desired result :-). However it’s not exactly what I was looking for (see the first gif in my question) ;-)… – finebel Aug 07 '21 at 07:07
  • @finebel I've updated the answer. I've changed collectionView.frame to add indents and changed createCompositionalLayout() - now there is an indent for header and you could see 2 items in the section at the time (you could display more items by decreasing widthDimension for group – Viktor Gavrilov Aug 07 '21 at 07:26
  • That seems reasonable. However the problem with this approach is, that we are making use of a fixed header item which doesn't line up correctly with the first or last item of the group, if we scroll over the end or the beginning. Instead it keeps it position while the item below is moved according to the drag gesture. – finebel Aug 07 '21 at 15:23