Custom UICollectionViewLayout Tutorial With Parallax

Introduced in iOS6, UICollectionView is a first-class choice for advanced customization and animation. Learn more in this UICollectionViewLayout tutorial. By .

Leave a rating/review
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Step 3: Adopting the CustomLayout

Before building and running the project you need to:

  • Make the collection view adopt the CustomLayout class.
  • Make the JungleCupCollectionViewController support the custom supplementary views.

Open Main.storyboard and select the Collection View Flow Layout in the Jungle Cup Collection View Controller Scene as shown below:

UICollectionViewLayout tutorial

Next, open the Identity Inspector and change the Custom Class to CustomLayout as shown below:

UICollectionViewLayout tutorial

Next, open JungleCupCollectionViewController.swift.

Add the computed property customLayout to avoid verbose code duplication.

Your code should look like the following:

var customLayout: CustomLayout? {
  return collectionView?.collectionViewLayout as? CustomLayout
}

Next, replace setUpCollectionViewLayout() with the following:

 
  private func setupCollectionViewLayout() {
    guard let collectionView = collectionView,
      let customLayout = customLayout else {
        return
    }
    // 1
    collectionView.register(
        UINib(nibName: "HeaderView", bundle: nil),
        forSupplementaryViewOfKind: CustomLayout.Element.header.kind,
        withReuseIdentifier: CustomLayout.Element.header.id
    )
    collectionView.register(
        UINib(nibName: "MenuView", bundle: nil),
        forSupplementaryViewOfKind: CustomLayout.Element.menu.kind,
        withReuseIdentifier: CustomLayout.Element.menu.id
    )
    
    // 2
    customLayout.settings.itemSize = CGSize(width: collectionView.frame.width, height: 200)
    customLayout.settings.headerSize = CGSize(width: collectionView.frame.width, height: 300)
    customLayout.settings.menuSize = CGSize(width: collectionView.frame.width, height: 70)
    customLayout.settings.sectionsHeaderSize = CGSize(width: collectionView.frame.width, height: 50)
    customLayout.settings.sectionsFooterSize = CGSize(width: collectionView.frame.width, height: 50)
    customLayout.settings.isHeaderStretchy = true
    customLayout.settings.isAlphaOnHeaderActive = true
    customLayout.settings.headerOverlayMaxAlphaValue = CGFloat(0)
    customLayout.settings.isMenuSticky = true
    customLayout.settings.isSectionHeadersSticky = true
    customLayout.settings.isParallaxOnCellsEnabled = true
    customLayout.settings.maxParallaxOffset = 60
    customLayout.settings.minimumInteritemSpacing = 0
    customLayout.settings.minimumLineSpacing = 3
}

Here's what the code above does:

  1. First, register the custom classes used for the stretchy header and the custom menu. These are UICollectionReusableView subclasses already implemented in the starter project.
  2. Finally, set sizes, behaviours and spacings of the CustomLayout settings.

Before you build an run the app, add the following two case options to viewForSupplementaryElementOfKind(_:viewForSupplementaryElementOfKind:at:) to handle custom supplementary view types:

case CustomLayout.Element.header.kind:
  let topHeaderView = collectionView.dequeueReusableSupplementaryView(
    ofKind: kind,
    withReuseIdentifier: CustomLayout.Element.header.id,
    for: indexPath)
  return topHeaderView
      
case CustomLayout.Element.menu.kind:
  let menuView = collectionView.dequeueReusableSupplementaryView(
    ofKind: kind,
    withReuseIdentifier: CustomLayout.Element.menu.id,
    for: indexPath)
  if let menuView = menuView as? MenuView {
    menuView.delegate = self
  }
  return menuView

Well done! It was a long journey, but you're almost done.

Build and run the project! You should see something similar to the following:

UICollectionViewLayout tutorial

The UICollectionView from the starter project now has some extra features:

  • At the top there's a big header showing the Jungle Cup's logo.
  • Below that, there's a menu with four buttons, one for each team. If you tap a button, the collection view reloads with the corresponding team.

You've already done a good job, but you can do better. It’s time to go for some nice visual effects to dress up your UICollectionView.

Adding Stretchy, Sticky and Parallax Effects

In the final section of this UICollectionViewLayout tutorial, you're going to add the following visual effects:

  1. Make the header stretchy and bouncy.
  2. Add a sticky effect to the menu and the section headers.
  3. Implement a smooth parallax effect to make the user interface more engaging.
Note: If you’re not familiar with CGATransform, you can check out this tutorial before continuing. The following part of the UICollectionViewLayout tutorial implies a basic knowledge of affine transforms.

Affine Transforms

The Core Graphics CGAffineTransform API is the best way to apply visual effects to the elements of a UICollectionView.

Affine transforms are quite useful for a variety of reasons:

  1. They let you create complex visual effects like translation, scaling and rotation, or a combination of the three, in very few lines of code.
  2. They interoperate in a flawless way with UIKit components and AutoLayout.
  3. They help you keep performance optimal even in complicated scenarios.

The math behind affine transforms is really cool. However, explaining how matrices work behind the scenes of CGATransform is out of scope for this UICollectionViewLayout tutorial.

If you’re interested in this topic, you can find more details in Apple’s Core Graphic Framework Documentation.

Transforming Visible Attributes

Open CustomLayout.swift and update layoutAttributesForElements(in:) to the following:

override public func layoutAttributesForElements(
  in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {

    guard let collectionView = collectionView else {
      return nil
    }
    visibleLayoutAttributes.removeAll(keepingCapacity: true)
    // 1
    let halfHeight = collectionViewHeight * 0.5
    let halfCellHeight = cellHeight * 0.5
    // 2
    for (type, elementInfos) in cache {
      for (indexPath, attributes) in elementInfos {
        // 3
        attributes.parallax = .identity
        attributes.transform = .identity
        // 4
        updateSupplementaryViews(
          type,
          attributes: attributes,
          collectionView: collectionView,
          indexPath: indexPath)
        if attributes.frame.intersects(rect) {
          // 5
          if type == .cell,
            settings.isParallaxOnCellsEnabled {
              updateCells(attributes, halfHeight: halfHeight, halfCellHeight: halfCellHeight)
          }
          visibleLayoutAttributes.append(attributes)
        }
      }
    }
    return visibleLayoutAttributes
}

Here's a step-by-step explanation of what's happening above:

  1. You store some useful values to avoid calculating them in the loop.
  2. This is the same loop as the previous version of this method. You iterate on all the cached attributes.
  3. Reset to the default value parallax transform and the element attributes transform.
  4. For the moment, you simply call a method to update the different kind of supplementary views. You'll implement it after this code block.
  5. Check whether the current attributes belong to a cell. If the parallax effect is activated in the layout settings, call a method to update its attributes. Just as above, you'll implement this method after this code block.

Next, it's time to implement the two methods called in the above loop:

  • updateSupplementaryViews(_:attributes:collectionView:indexPath:)
  • updateCells(_:halfHeight:halfCellHeight:)

Add the following:

private func updateSupplementaryViews(_ type: Element,
                                      attributes: CustomLayoutAttributes, 
                                      collectionView: UICollectionView,
                                      indexPath: IndexPath) {
    // 1
    if type == .sectionHeader,
      settings.isSectionHeadersSticky {
        let upperLimit = 
           CGFloat(collectionView.numberOfItems(inSection: indexPath.section))
           * (cellHeight + settings.minimumLineSpacing)
        let menuOffset = settings.isMenuSticky ? menuSize.height : 0
        attributes.transform =  CGAffineTransform(
          translationX: 0,
          y: min(upperLimit,
          max(0, contentOffset.y - attributes.initialOrigin.y + menuOffset)))
    }
    // 2
    else if type == .header,
      settings.isHeaderStretchy {
        let updatedHeight = min(
          collectionView.frame.height,
          max(headerSize.height, headerSize.height - contentOffset.y))
        let scaleFactor = updatedHeight / headerSize.height
        let delta = (updatedHeight - headerSize.height) / 2
        let scale = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor)
        let translation = CGAffineTransform(
          translationX: 0,
          y: min(contentOffset.y, headerSize.height) + delta)
        attributes.transform = scale.concatenating(translation)
        if settings.isAlphaOnHeaderActive {
          attributes.headerOverlayAlpha = min(
            settings.headerOverlayMaxAlphaValue,
            contentOffset.y / headerSize.height)
        }
    }
    // 3
    else if type == .menu,
      settings.isMenuSticky {
        attributes.transform = CGAffineTransform(
          translationX: 0,
          y: max(attributes.initialOrigin.y, contentOffset.y) - headerSize.height)
    }
  }

Taking each numbered comment in turn:

  1. Test whether the current element is a section header. Then, if the sticky behaviour is activated in the layout settings, compute the transform. Finally assign the calculated value to the attributes' transform property.
  2. Same routine as above, but this time check whether the element is the top header. If the stretchy effect is activated, perform the transform calculations.
  3. Same routine again. This time perform transform calculations for the sticky menu.

Now it's time to transform the collection view cells:

  
private func updateCells(_ attributes: CustomLayoutAttributes,
                         halfHeight: CGFloat,
                         halfCellHeight: CGFloat) {
  // 1
  let cellDistanceFromCenter = attributes.center.y - contentOffset.y - halfHeight
    
  // 2
  let parallaxOffset = -(settings.maxParallaxOffset * cellDistanceFromCenter)
    / (halfHeight + halfCellHeight)
  // 3 
  let boundedParallaxOffset = min(
    max(-settings.maxParallaxOffset, parallaxOffset),
    settings.maxParallaxOffset)
  // 4
  attributes.parallax = CGAffineTransform(translationX: 0, y: boundedParallaxOffset)
}

Here's the play-by-play:

  1. Calculate the distance of the cell from the center of the collection view.
  2. Map proportionally the cell's distance from the center on the maximum parallax value (set in the layout settings)
  3. Bound the parallaxOffset to avoid visual glitches.
  4. Create a CAAffineTransform translation with the computed parallax value. Finally, assign the translation to the cell's attributes transform property.

To achieve the parallax effect on the PlayerCell, the image's frame should have top and bottom negative insets. In the starter project these constraints are set for you. You can check them in the Constraint inspector (see below).

UICollectionViewLayout tutorial

Before building, you have to fix one final detail. Open JungleCupCollectionViewController.swift. Inside setupCollectionViewLayout() change the following value:

customLayout.settings.headerOverlayMaxAlphaValue = CGFloat(0)

to the following:

customLayout.settings.headerOverlayMaxAlphaValue = CGFloat(0.6)

This value represents the maximum opacity value the layout can assign to the black overlay on the headerView.

Build and run the project to appreciate all the visual effects. Let it scroll! Let it scroll! Let it scroll! :]

UICollectionViewLayout tutorial

Contributors

Darren Ferguson

Tech Editor

Chris Belanger

Editor

Andy Obusek

Final Pass Editor and Team Lead

Over 300 content creators. Join our team.