UICollectionView Around the Head: Changing Views on the Fly

Hello, Habr! I present to you the translation of the article " UICollectionView Tutorial: Changing presentation on the fly ".

In this article, we will consider the use of various ways of displaying elements, as well as their reuse and dynamic change. Here we will not discuss the basics of working with collections and autolayout.

As a result, we get an example:


When developing mobile applications, there are often situations when the table view is not enough and you need to show a list of elements more interesting and unique. In addition, the ability to change the way elements are displayed can become a “chip” in your application.

All of the above features are quite simple to implement using the UICollectionView and various implementations of the UICollectionViewDelegateFlowLayout protocol.

Full project code.

What we need first of all for implementation:

  • class FruitsViewController: UICollectionViewController.
  • Fruit Data Model

    structFruit{
        let name: Stringlet icon: UIImage
    }
  • class FruitCollectionViewCell: UICollectionViewCell

Cell with UIImageView and UILabel for displaying fruits


We will create a cell in a separate file with xib for reuse.

By design, we see that there are 2 possible cell options - with text below and text to the right of the image.



There may be completely different types of cells, in which case you need to create 2 separate classes and use the desired one. In our case, there is no such need and 1 cell with UIStackView is enough.



Steps for creating an interface for a cell:

  1. Add UIView
  2. Inside it add UIStackView (horizontal)
  3. Next, add UIImageView and UILabel to UIStackView.
  4. For UILabel, set the values ​​of Content Compression Resistance Priority = 1000 for horizontal and vertical.
  5. Add 1: 1 for UIImageView Aspect Ratio and change the priority to 750.

This is necessary for correct display in horizontal mode.

Next, we write the logic for displaying our cell in both horizontal and vertical mode.

The main criterion for horizontal display will be the size of the cell itself. Those. if there is enough space - display the horizontal mode. If not, vertical. We assume that there is enough space - this is when the width is 2 times greater than the height, since the image should be square.

Cell Code:

classFruitCollectionViewCell: UICollectionViewCell{    
    staticlet reuseID = String(describing: FruitCollectionViewCell.self)
    staticlet nib = UINib(nibName: String(describing: FruitCollectionViewCell.self), bundle: nil)
    @IBOutletprivateweakvar stackView: UIStackView!@IBOutletprivateweakvar ibImageView: UIImageView!@IBOutletprivateweakvar ibLabel: UILabel!overridefuncawakeFromNib() {
        super.awakeFromNib()
        backgroundColor = .white
        clipsToBounds = true
        layer.cornerRadius = 4
        ibLabel.font = UIFont.systemFont(ofSize: 18)
    }
    overridefunclayoutSubviews() {
        super.layoutSubviews()
        updateContentStyle()
    }
    funcupdate(title: String, image: UIImage) {
        ibImageView.image = image
        ibLabel.text = title
    }
    privatefuncupdateContentStyle() {
        let isHorizontalStyle = bounds.width > 2 * bounds.height
        let oldAxis = stackView.axis
        let newAxis: NSLayoutConstraint.Axis = isHorizontalStyle ? .horizontal : .vertical
        guard oldAxis != newAxis else { return }
        stackView.axis = newAxis
        stackView.spacing = isHorizontalStyle ? 16 : 4
        ibLabel.textAlignment = isHorizontalStyle ? .left : .center
        let fontTransform: CGAffineTransform = isHorizontalStyle ? .identity : CGAffineTransform(scaleX: 0.8, y: 0.8)
        UIView.animate(withDuration: 0.3) {
            self.ibLabel.transform = fontTransform
            self.layoutIfNeeded()
        }
    }
}

Let's move on to the main part - to the controller and the logic for displaying and switching cell types.

For all possible display states, create an enum PresentationStyle.
We also add a button to switch between states in the navigation bar.

classFruitsViewController: UICollectionViewController{
    privateenumPresentationStyle: String, CaseIterable{
        case table
        case defaultGrid
        case customGrid
        var buttonImage: UIImage {
            switchself {
            case .table: return  imageLiteral(resourceName: "table")
            case .defaultGrid: return  imageLiteral(resourceName: "default_grid")
            case .customGrid: return  imageLiteral(resourceName: "custom_grid")
            }
        }
    }
    privatevar selectedStyle: PresentationStyle = .table {
        didSet { updatePresentationStyle() }
    }
    privatevar datasource: [Fruit] = FruitsProvider.get()
    overridefuncviewDidLoad() {
        super.viewDidLoad()
        self.collectionView.register(FruitCollectionViewCell.nib,
                                      forCellWithReuseIdentifier: FruitCollectionViewCell.reuseID)
        collectionView.contentInset = .zero
        updatePresentationStyle()
        navigationItem.rightBarButtonItem = UIBarButtonItem(image: selectedStyle.buttonImage, style: .plain, target: self, action: #selector(changeContentLayout))
    }
    privatefuncupdatePresentationStyle() {
        navigationItem.rightBarButtonItem?.image = selectedStyle.buttonImage
    }
    @objcprivatefuncchangeContentLayout() {
        let allCases = PresentationStyle.allCases
        guardlet index = allCases.firstIndex(of: selectedStyle) else { return }
        let nextIndex = (index + 1) % allCases.count
        selectedStyle = allCases[nextIndex]
    }
}
// MARK: UICollectionViewDataSource & UICollectionViewDelegateextensionFruitsViewController{
    overridefunccollectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return datasource.count
    }
    overridefunccollectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guardlet cell = collectionView.dequeueReusableCell(withReuseIdentifier: FruitCollectionViewCell.reuseID,
                                                            for: indexPath) as? FruitCollectionViewCellelse {
            fatalError("Wrong cell")
        }
        let fruit = datasource[indexPath.item]
        cell.update(title: fruit.name, image: fruit.icon)
        return cell
    }
}

Everything regarding the method of displaying elements in a collection is described in the UICollectionViewDelegateFlowLayout protocol. Therefore, in order to remove any implementations from the controller and create independent reusable elements, we will create a separate implementation of this protocol for each type of display.

However, there are 2 nuances:

  1. This protocol also describes the cell selection method (didSelectItemAt :)
  2. Some methods and logic are the same for all N mapping methods (in our case, N = 3).

Therefore, we will create the CollectionViewSelectableItemDelegate protocol , extend the standard UICollectionViewDelegateFlowLayout protocol , in which we define the closure of the cell selection and, if necessary, any additional properties and methods (for example, returning the cell type if different types are used for representations). This will solve the first problem.

protocolCollectionViewSelectableItemDelegate: class, UICollectionViewDelegateFlowLayout{
    var didSelectItem: ((_ indexPath: IndexPath) -> Void)? { getset }
}

To solve the second problem - with duplication of logic, we will create a base class with all the common logic:

classDefaultCollectionViewDelegate: NSObject, CollectionViewSelectableItemDelegate{
    var didSelectItem: ((_ indexPath: IndexPath) -> Void)?
    let sectionInsets = UIEdgeInsets(top: 16.0, left: 16.0, bottom: 20.0, right: 16.0)
    funccollectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        didSelectItem?(indexPath)
    }
    funccollectionView(_ collectionView: UICollectionView, didHighlightItemAt indexPath: IndexPath) {
        let cell = collectionView.cellForItem(at: indexPath)
        cell?.backgroundColor = UIColor.clear
    }
    funccollectionView(_ collectionView: UICollectionView, didUnhighlightItemAt indexPath: IndexPath) {
        let cell = collectionView.cellForItem(at: indexPath)
        cell?.backgroundColor = UIColor.white
    }
}

In our case, the general logic is to call a closure when selecting a cell, as well as changing the background of the cell when switching to the highlighted state .

Next, we describe 3 implementations of the representations: tabular, 3 elements in each row and a combination of the first two methods.

Tabular :

classTabledContentCollectionViewDelegate: DefaultCollectionViewDelegate{
    // MARK: - UICollectionViewDelegateFlowLayoutfunccollectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        sizeForItemAt indexPath: IndexPath) -> CGSize {
        let paddingSpace = sectionInsets.left + sectionInsets.rightlet widthPerItem = collectionView.bounds.width - paddingSpace
        returnCGSize(width: widthPerItem, height: 112)
    }
    funccollectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        insetForSectionAt section: Int) -> UIEdgeInsets {
        return sectionInsets
    }
    funccollectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return10
    }
}

3 elements in each row:

classDefaultGriddedContentCollectionViewDelegate: DefaultCollectionViewDelegate{
    privatelet itemsPerRow: CGFloat = 3privatelet minimumItemSpacing: CGFloat = 8// MARK: - UICollectionViewDelegateFlowLayoutfunccollectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        sizeForItemAt indexPath: IndexPath) -> CGSize {
        let paddingSpace = sectionInsets.left + sectionInsets.right + minimumItemSpacing * (itemsPerRow - 1)
        let availableWidth = collectionView.bounds.width - paddingSpace
        let widthPerItem = availableWidth / itemsPerRow
        returnCGSize(width: widthPerItem, height: widthPerItem)
    }
    funccollectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        insetForSectionAt section: Int) -> UIEdgeInsets {
        return sectionInsets
    }
    funccollectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return20
    }
    funccollectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        return minimumItemSpacing
    }
}

Combination of tabular and 3x in a row.

classCustomGriddedContentCollectionViewDelegate: DefaultCollectionViewDelegate{
    privatelet itemsPerRow: CGFloat = 3privatelet minimumItemSpacing: CGFloat = 8// MARK: - UICollectionViewDelegateFlowLayoutfunccollectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        sizeForItemAt indexPath: IndexPath) -> CGSize {
        let itemSize: CGSizeif indexPath.item % 4 == 0 {
            let itemWidth = collectionView.bounds.width - (sectionInsets.left + sectionInsets.right)
            itemSize = CGSize(width: itemWidth, height: 112)
        } else {
            let paddingSpace = sectionInsets.left + sectionInsets.right + minimumItemSpacing * (itemsPerRow - 1)
            let availableWidth = collectionView.bounds.width - paddingSpace
            let widthPerItem = availableWidth / itemsPerRow
            itemSize = CGSize(width: widthPerItem, height: widthPerItem)
        }
        return itemSize
    }
    funccollectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        insetForSectionAt section: Int) -> UIEdgeInsets {
        return sectionInsets
    }
    funccollectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return20
    }
    funccollectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        return minimumItemSpacing
    }
}

The last step is to add the view data to the controller and set the desired delegate to the collection.

An important point: since the delegate of the collection is weak , you must have a strong link in the controller to the view object.

In the controller, create a dictionary of all available views regarding the type:

privatevar styleDelegates: [PresentationStyle: CollectionViewSelectableItemDelegate] = {
        let result: [PresentationStyle: CollectionViewSelectableItemDelegate] = [
            .table: TabledContentCollectionViewDelegate(),
            .defaultGrid: DefaultGriddedContentCollectionViewDelegate(),
            .customGrid: CustomGriddedContentCollectionViewDelegate(),
        ]
        result.values.forEach {
            $0.didSelectItem = { _inprint("Item selected")
            }
        }
        return result
    }()

And in the updatePresentationStyle () method, add an animated change to the collection delegate:

  collectionView.delegate = styleDelegates[selectedStyle]
        collectionView.performBatchUpdates({
            collectionView.reloadData()
        }, completion: nil)

That's all that is needed for our elements to move animatedly from one view to another :)


Thus, we can now display elements on any screen in any way we like, dynamically switch between displays, and most importantly, the code is independent, reusable and scalable.

Full project code.

Also popular now: