Swift - Paging UICollectionView by cell while keeping the cell horizontally centered

9.7k Views Asked by At

First, not sure if the title is correct or offers the best description but I'm not sure what else to use.

So, i'm working on an app and I reached a section where I got stuck while implementing the UI. Basically, I have a VC (image below) that can segue to its self based on the info I get from a JSON file.

enter image description here

The thing is I need to have a carousel-like menu in the upper side with an undefined number of cells (again, depends on what I get from the JSON file). I decided to go for a UICollectionView for this and I managed to implement the basics without any problem.

But here is the part where I got stuck:

  1. Since the selected cell must ALWAYS be centered, when the first and the last cell gets selected, I need to have an empty space between the cell and the safe area (see the image above).
  2. The scroll needs to be paged. Normally this wouldn't be a problem if the UICollectionView cell would have a width almost equal to the one of the screen but the requirement is to be able to scroll one element at a time (see second screen above).

I tried finding something similar but maybe I'm not looking for the right thing because all I could find was Paging UICollectionView by cells, not screen

Also, to be honest I've never seen an app / UICollectionView with this behaviour.

I posted parts of the code below but it's not really gonna help much since it's just standard UICollectionView methods.

Any suggestions?

class PreSignupDataVC : UIViewController, UICollectionViewDelegateFlowLayout, UICollectionViewDataSource, UIPickerViewDelegate, UIPickerViewDataSource

@IBOutlet weak var cvQuestions: UICollectionView!

var questionCell : PreSignupDataQuestionCellVC!
var screenData : Array<PreSignupScreenData> = Array<PreSignupScreenData>()
var pvDataSource : [String] = []
var numberOfComponents : Int = 0
var numberOfRowsInComponent : Int = 0
var currentScreen : Int = 1
var selectedType : Int?
var selectedCell : Int = 0
var initialLastCellInsetPoint : CGFloat = 0.0

override func viewDidLoad()
{
    super.viewDidLoad()

    print("PreSignupDataVC > viewDidLoad")

    initialLastCellInsetPoint = (self.view.frame.width - 170)/2
    screenData = DataSingleton.sharedInstance.returnPreSignUpUIArray()[selectedType!].screenData
    numberOfComponents = screenData[currentScreen - 1].controls[0].numberOfComponents!
    numberOfRowsInComponent = screenData[currentScreen - 1].controls[0].controlDataSource.count
    pvDataSource = screenData[currentScreen - 1].controls[0].controlDataSource

    cvQuestions.register(UINib(nibName: "PreSignupDataQuestionCell",
                               bundle: nil),
                         forCellWithReuseIdentifier: "PreSignupDataQuestionCellVC")
}

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int
{
    print("PreSignupDataVC > collectionView > numberOfItemsInSection")

    return screenData[currentScreen - 1].controls.count
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
{
    print("PreSignupDataVC > collectionView > cellForItemAt")

    questionCell = (cvQuestions.dequeueReusableCell(withReuseIdentifier: "PreSignupDataQuestionCellVC",
                                                    for: indexPath) as? PreSignupDataQuestionCellVC)!
    questionCell.vQuestionCellCellContainer.layer.cornerRadius = 8.0
    questionCell.lblQuestion.text = screenData[currentScreen - 1].controls[indexPath.row].cellTitle
    questionCell.ivQuestionCellImage.image = UIImage(named: screenData[currentScreen - 1].controls[indexPath.row].cellUnselectedIcon!)

    return questionCell
}

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
{
    print("PreSignupDataVC > collectionView > didSelectItemAt")

    numberOfComponents = screenData[currentScreen - 1].controls[indexPath.row].numberOfComponents!
    numberOfRowsInComponent = screenData[currentScreen - 1].controls[indexPath.row].controlDataSource.count
    pvDataSource = screenData[currentScreen - 1].controls[indexPath.row].controlDataSource
    selectedCell = indexPath.row

    pvData.reloadAllComponents()
}

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets
{
    print("PreSignupDataVC > collectionView > insetForSectionAt")

    return UIEdgeInsets(top: 0.0, left: initialLastCellInsetPoint, bottom: 00.0, right: initialLastCellInsetPoint)
}
2

There are 2 best solutions below

7
Linh Quang Pham On BEST ANSWER

You can use insetForSectionAtIndex to add spacing at first and last cell

UPDATE
You can use scroll view.
First: add leading and trailing to scroll view:
enter image description here

Second: scrollView.clipsToBounds = false

Third: add view to scroll view

func setupScrollView() {
    DispatchQueue.main.async {
        self.scrollView.layoutIfNeeded() // in order to get correct frame

        let numberItem = 6
        let spaceBetweenView: CGFloat = 20
        let firstAndLastSpace: CGFloat = spaceBetweenView / 2            
        let width = self.scrollView.frame.size.width
        let height = self.scrollView.frame.size.height
        let numberOfView: CGFloat = CGFloat(numberItem)
        let scrollViewContentSizeWidth = numberOfView * width

        self.scrollView.contentSize = CGSize(width: scrollViewContentSizeWidth, height: height)

        for index in 0..<numberItem {
            let xCoordinate = (CGFloat(index) * (width)) + firstAndLastSpace
            let viewFrame = CGRect(x: xCoordinate, y: 0, width: width - spaceBetweenView, height: height)
            let view = UIView()
            view.backgroundColor = .red

            view.frame = viewFrame
            self.scrollView.addSubview(view)
        }
    }
}
0
Imanou Petit On

#1. Using UICollectionViewCompositionalLayout (requires iOS 13)

Since iOS 13, you can use UICollectionViewCompositionalLayout and set NSCollectionLayoutSection's orthogonalScrollingBehavior property to .groupPagingCentered in order to have a centered horizontal carousel-like layout.

The following Swift 5.1 sample code shows a possible implementation of UICollectionViewCompositionalLayout in order to have the layout you want:

CollectionView.swift

import UIKit

class CollectionView: UICollectionView {

    override var safeAreaInsets: UIEdgeInsets {
        return UIEdgeInsets(top: super.safeAreaInsets.top, left: 0, bottom: super.safeAreaInsets.bottom, right: 0)
    }

}

ViewController.swift

import UIKit

class ViewController: UIViewController {

    var collectionView: CollectionView!
    var dataSource: UICollectionViewDiffableDataSource<Int, Int>!

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.title = "Collection view"

        // Compositional layout

        let layout = UICollectionViewCompositionalLayout(sectionProvider: {
            (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            item.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)

            let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalHeight(1), heightDimension: .fractionalHeight(1))
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

            let section = NSCollectionLayoutSection(group: group)
            section.orthogonalScrollingBehavior = UICollectionLayoutSectionOrthogonalScrollingBehavior.groupPagingCentered

            return section
        })

        // Set collection view

        collectionView = CollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.backgroundColor = .systemGroupedBackground
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.register(Cell.self, forCellWithReuseIdentifier: "Cell")

        // View layout

        view.addSubview(collectionView)

        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.heightAnchor.constraint(equalToConstant: 160).isActive = true
        collectionView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true

        // Collection view diffable data source

        dataSource = UICollectionViewDiffableDataSource<Int, Int>(collectionView: collectionView, cellProvider: {
            (collectionView: UICollectionView, indexPath: IndexPath, identifier: Int) -> UICollectionViewCell? in

            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! Cell
            return cell
        })

        var snapshot = NSDiffableDataSourceSnapshot<Int, Int>()
        snapshot.appendSections([0])
        snapshot.appendItems(Array(0 ..< 5))
        dataSource.apply(snapshot, animatingDifferences: false)
    }

}

Cell.swift

import UIKit

class Cell: UICollectionViewCell {

    override init(frame: CGRect) {
        super.init(frame: frame)

        contentView.backgroundColor = .orange
    }

    required init?(coder: NSCoder) {
        fatalError("not implemnted")
    }

}

#2. Using UICollectionViewFlowLayout

If you're targeting a lower version of iOS than iOS 13, you can subclass UICollectionViewFlowLayout, compute the horizontal insets in prepare() and implement targetContentOffset(forProposedContentOffset:withScrollingVelocity:) in order to force the cell to be centered after user scroll.

The following Swift 5.1 sample code shows how to implement the subclass of UICollectionViewFlowLayout:

ViewController.swift

import UIKit

class ViewController: UIViewController {

    let flowLayout = PaggedFlowLayout()

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.title = "Collection view"

        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
        collectionView.backgroundColor = .systemGroupedBackground
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.decelerationRate = .fast
        collectionView.dataSource = self
        collectionView.contentInsetAdjustmentBehavior = .never
        collectionView.register(Cell.self, forCellWithReuseIdentifier: "Cell")

        view.addSubview(collectionView)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.heightAnchor.constraint(equalToConstant: 160).isActive = true
        collectionView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
    }

}
extension ViewController: UICollectionViewDataSource {

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 9
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! Cell
        return cell
    }

}

PaggedFlowLayout.swift

import UIKit

class PaggedFlowLayout: UICollectionViewFlowLayout {

    override init() {
        super.init()

        scrollDirection = .horizontal
        minimumLineSpacing = 5
        minimumInteritemSpacing = 0
        sectionInset = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func prepare() {
        super.prepare()

        guard let collectionView = collectionView else { fatalError() }

        // itemSize
        let itemHeight = collectionView.bounds.height - sectionInset.top - sectionInset.bottom
        itemSize = CGSize(width: itemHeight, height: itemHeight)

        // horizontal insets
        let horizontalInsets = (collectionView.bounds.width - itemSize.width) / 2
        sectionInset.left = horizontalInsets
        sectionInset.right = horizontalInsets
    }

    /*
     Add some snapping behaviour to center the cell after scrolling.
     Source: https://stackoverflow.com/a/14291208/1966109
     */
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
        guard let collectionView = collectionView else { return .zero }

        var proposedContentOffset = proposedContentOffset
        var offsetAdjustment = CGFloat.greatestFiniteMagnitude
        let horizontalCenter = proposedContentOffset.x + collectionView.bounds.size.width / 2
        let targetRect = CGRect(x: proposedContentOffset.x, y: 0, width: collectionView.bounds.size.width, height: collectionView.bounds.size.height)

        guard let layoutAttributesArray = super.layoutAttributesForElements(in: targetRect) else { return .zero }

        for layoutAttributes in layoutAttributesArray {
            let itemHorizontalCenter = layoutAttributes.center.x
            if abs(itemHorizontalCenter - horizontalCenter) < abs(offsetAdjustment) {
                offsetAdjustment = itemHorizontalCenter - horizontalCenter
            }
        }

        var nextOffset = proposedContentOffset.x + offsetAdjustment
        let snapStep = itemSize.width + minimumLineSpacing

        func isValidOffset(_ offset: CGFloat) -> Bool {
            let minContentOffset = -collectionView.contentInset.left
            let maxContentOffset = collectionView.contentInset.left + collectionView.contentSize.width - itemSize.width
            return offset >= minContentOffset && offset <= maxContentOffset
        }

        repeat {
            proposedContentOffset.x = nextOffset
            let deltaX = proposedContentOffset.x - collectionView.contentOffset.x
            let velX = velocity.x

            if deltaX.sign.rawValue * velX.sign.rawValue != -1 {
                break
            }

            nextOffset += CGFloat(velocity.x.sign.rawValue) * snapStep
        } while isValidOffset(nextOffset)

        return proposedContentOffset
    }

}

Cell.swift

import UIKit

class Cell: UICollectionViewCell {

    override init(frame: CGRect) {
        super.init(frame: frame)

        contentView.backgroundColor = .orange
    }

    required init?(coder: NSCoder) {
        fatalError("not implemnted")
    }

}

Display on iPhone 11 Pro Max:

enter image description here