Subclassing and Extending the iOS Collection View Flow Layout

From Techotopia
Jump to: navigation, search


Learn SwiftUI and take your iOS Development to the Next Level
SwiftUI Essentials – iOS 16 Edition book is now available in Print ($39.99) and eBook ($29.99) editions. Learn more...

Buy Print Preview Book


In this chapter the UICollectionViewFlowLayout class will be extended to provide custom layout behavior for the CollectionDemo application created in the previous chapter.

As previously described, whilst the collection view is responsible for displaying data elements in the form of cells, it is the layout object that controls how those cells are to be arranged and positioned on the screen. One of the most powerful features of collection views is the ability to switch out one layout object for another in order to change both the way in which cells are presented to the user, and the way in which that layout responds to user interaction.

In the event that the UICollectionViewFlowLayout class does not provide the necessary behavior for an application, therefore, it can be replaced with a custom layout object that does. By far the easiest way to achieve this is to subclass the UICollectionViewFlowLayout class and extend it to provide the desired layout behavior.


Contents


About the Example Layout Class

This chapter will work step-by-step through the process of creating a new collection view layout class by subclassing and extending UICollectionViewFlowLayout. The purpose of the new layout class will be to allow the user to move and stretch cells in the collection view by pinching and dragging cells. As such, the example will also demonstrate the use of gesture recognizers with collection views.

Begin by launching Xcode and loading the CollectionDemo project created in the previous chapter.

Subclassing the UICollectionViewFlowLayout Class

The first step is to create a new class that is itself a subclass of UICollectionViewFlowLayout. Begin, therefore, by selecting the File -> New -> File… menu option in Xcode and in the resulting panel, create a new iOS Cocoa Touch class file named MyFlowLayout that subclasses from UICollectionViewFlowLayout.


Extending the New Layout Class

The new layout class is now created and ready to be extended to add the new functionality. Since the new layout class is going to allow cells to be dragged around and resized by the user, it will need some properties to store a reference to the cell being manipulated, the scale value by which the cell is being resized and, finally, the current location of the cell on the screen. With these requirements in mind, select the MyFlowLayout.swift file and modify it as follows:

import UIKit

class MyFlowLayout: UICollectionViewFlowLayout {

    var currentCellPath: NSIndexPath?
    var currentCellCenter: CGPoint?
    var currentCellScale: CGFloat?
}

When the scale and center properties are changed, it will be necessary to invalidate the layout so that the collection view is updated and the cell redrawn at the new size and location on the screen. To ensure that this happens, two methods need to be implemented in the MyFlowLayout.swift file for the center and scale properties that invalidate the layout in addition to storing the new property values:

func setCurrentCellScale(scale: CGFloat)
{
    currentCellScale = scale
    self.invalidateLayout()
}

func setCurrentCellCenter(origin: CGPoint)
{
    currentCellCenter = origin
    self.invalidateLayout()
}

Learn SwiftUI and take your iOS Development to the Next Level
SwiftUI Essentials – iOS 16 Edition book is now available in Print ($39.99) and eBook ($29.99) editions. Learn more...

Buy Print Preview Book

Overriding the layoutAttributesForItem(at indexPath:) Method

The collection view object makes calls to a datasource delegate object to obtain cells to be displayed within the collection, passing through an index path object to identify the cell that is required. When a cell is returned by the datasource, the collection view object then calls the layout object and expects in return a set of layout attributes in the form of a UICollectionViewLayoutAttributes object for that cell indicating how and where it is to be displayed on the screen.

The method of the layout object called by the collection view will be one of either layoutAttributesForItem(at indexPath:) or layoutAttributesForElements(in rect:). The former method is passed the index path to the specific cell for which layout attributes are required. It is the job of this method to calculate these attributes based on internal logic and return the attributes object to the collection view.

The layoutAttributesForElements(in rect:) method, on the other hand, is passed a CGRect object representing a rectangular region of the device display and expects, in return, an array of attribute objects for all cells that fall within the designated region. In order to modify the behavior of the flow layout subclass, these methods need to be overridden to apply the necessary layout attribute changes to the cell items.

The first method to be implemented in this example is the layoutAttributesForItem(at indexPath:) method which should be implemented in the MyFlowLayout.swift file as follows:

override func layoutAttributesForItem(at indexPath: IndexPath) -> 
                      UICollectionViewLayoutAttributes? {
    let attributes =
        super.layoutAttributesForItem(at: indexPath)
    
    if let attributes = attributes {
        self.modifyLayoutAttributes(layoutattributes: attributes)
    }
    return attributes
}

Before the attributes for the requested cell can be modified, the method needs to know what those attributes would be for an unmodified UICollectionViewFlowLayout instance. Since this class is a subclass of UICollectionViewFlowLayout, we can obtain this information, as performed in the above method, via a call to the layoutAttributesForItem method of the superclass:

let attributes = super.layoutAttributesForItem(at: indexPath)

Having ascertained what the attributes would normally be, the method then calls a custom method named modifyLayoutAttributes and then returns the modified attributes to the collection view. It will be the task of the modifyLayoutAttributes method (which will be implemented later) to apply the resize and movement effects to the attributes of the cell over which the pinch gesture is taking place.

Overriding the layoutAttributesForElements(in rect:) Method

The layoutAttributesForElements(in rect:) method will need to perform a similar task to the previous method in terms of getting the attributes values for cells in the designated display region from the superclass, calling the modifyLayoutAttributes method and returning the results to the collection view object:

override func layoutAttributesForElements(in rect: CGRect) -> 
        [UICollectionViewLayoutAttributes]? {
    
    let allAttributesInRect =
        super.layoutAttributesForElements(in: rect)
    
    if let allAttributesInRect = allAttributesInRect {
        for cellAttributes in allAttributesInRect {
            self.modifyLayoutAttributes(layoutattributes: cellAttributes )
        }
    }
    return allAttributesInRect
}

Implementing the modifyLayoutAttributes Method

By far the most interesting method to be implemented is the modifyLayoutAttributes method. This is where the layout attributes for the cell the user is currently manipulating on the screen are modified. This method should now be implemented as outlined in the following listing:

func modifyLayoutAttributes(layoutattributes:
    UICollectionViewLayoutAttributes) {
    
    if let currentCellPath = currentCellPath,
        let currentCellScale = currentCellScale,
        let currentCellCenter = currentCellCenter {
        if layoutattributes.indexPath == currentCellPath as IndexPath {
            layoutattributes.transform3D =
                CATransform3DMakeScale(currentCellScale,
                                       currentCellScale, 1.0)
            layoutattributes.center = currentCellCenter
            layoutattributes.zIndex = 1
        }
    }
}

In completing the example application, a pinch gesture recognizer will be attached to the collection view object and configured to set the currentCellPath, currentCellScale and currentCellCenter values of the layout object in real-time as the user pinches and moves a cell. As is evident from the above code, use is made of these settings during the attribute modification process.

Since this method will be called for each cell in the collection, it is important that the attribute modifications only be applied to the cell the user is currently moving and pinching. This cell is stored in the currentCellPath property as updated by the gesture recognizer:

if layoutattributes.indexPath == currentCellPath as IndexPath 

If the cell matches that referenced by the currentCellPath property, the attributes are transformed via a call to the CATransform3DMakeScale function of the QuartzCore Framework, using the currentCellScale property value which is updated by the gesture recognizer during a pinching motion:

layoutattributes.transform3D = 
    CATransform3DMakeScale(currentCellScale, currentCellScale, 1.0)

Finally, the center location of the cell is set to the currentCellCenter property value and the zIndex property set to 1 so that the cell appears on top of overlapping collection view contents.

The implementation of a custom collection layout is now complete. All that remains is to implement the gesture recognizer in the application code so that the flow layout knows which cell is being pinched and moved, and by how much.

Adding the New Layout and Pinch Gesture Recognizer

In order to detect pinch gestures, a pinch gesture recognizer needs to be added to the collection view object. Code also needs to be added to replace the default layout object with our new custom flow layout object.

Select the MyCollectionViewController.swift file and modify the viewDidLoad method to change the layout to our new layout class and to add a pinch gesture recognizer configured to call a method named handlePinch:

override func viewDidLoad() {
    super.viewDidLoad()

    let myLayout = MyFlowLayout()

    self.collectionView?.setCollectionViewLayout(myLayout, 
			animated: true)

    let pinchRecognizer = UIPinchGestureRecognizer(target: self, 
					action: #selector(handlePinch))
    self.collectionView?.addGestureRecognizer(pinchRecognizer)

    initialize()
}

Learn SwiftUI and take your iOS Development to the Next Level
SwiftUI Essentials – iOS 16 Edition book is now available in Print ($39.99) and eBook ($29.99) editions. Learn more...

Buy Print Preview Book

Implementing the Pinch Recognizer

Remaining within the MyCollectionViewController.swift file, the last coding related task before testing the application is to write the pinch handler method, the code for which reads as follows:

@objc func handlePinch(gesture: UIPinchGestureRecognizer) {
    let layout = self.collectionView?.collectionViewLayout
        as! MyFlowLayout
    if gesture.state == UIGestureRecognizerState.began
    {
        // Get the initial location of the pinch?
        let initialPinchPoint =
            gesture.location(in: self.collectionView)
        // Convert pinch location into a specific cell
        if let pinchedCellPath =
            self.collectionView?.indexPathForItem(at: initialPinchPoint) {
        // Store the indexPath to cell
            layout.currentCellPath = pinchedCellPath as NSIndexPath
        }
    }
    else if gesture.state == UIGestureRecognizerState.changed
    {
        // Store the new center location of the selected cell
        layout.currentCellCenter =
            gesture.location(in: self.collectionView)
        // Store the scale value
        layout.setCurrentCellScale(scale: gesture.scale)
    }
    else
    {
        self.collectionView?.performBatchUpdates({
            layout.currentCellPath = nil
            layout.currentCellScale = 1.0}, completion:nil)
    }
}

The method begins by getting a reference to the layout object associated with the collection view:

let layout = self.collectionView?.collectionViewLayout as! MyFlowLayout 

Next, it checks to find out if the gesture has just started. If so, the method will need to identify the cell over which the gesture is taking place. This is achieved by identifying the initial location of the gesture and then passing that location through to the indexPathForItemAtPoint method of the collection view object. The resulting indexPath is then stored in the currentCellPath property of the layout object where it can be accessed by the modifyLayoutAttributes method previously implemented in the MyFlowLayout class:

if gesture.state == UIGestureRecognizerState.Began
{
    // Get the initial location of the pinch?
    let initialPinchPoint = gesture.locationInView(self.collectionView) 

    // Convert pinch location into a specific cell
    let pinchedCellPath = 
	 self.collectionView?.indexPathForItemAtPoint(initialPinchPoint)

    // Store the indexPath to cell
    layout.currentCellPath = pinchedCellPath
}

In the event that the gesture is in progress, the current scale and location of the gesture need to be stored in the layout object:

else if gesture.state == UIGestureRecognizerState.Changed
{
    // Store the new center location of the selected cell
    layout.currentCellCenter = 
			gesture.locationInView(self.collectionView)
    // Store the scale value
    layout.setCurrentCellScale(gesture.scale)
}

Learn SwiftUI and take your iOS Development to the Next Level
SwiftUI Essentials – iOS 16 Edition book is now available in Print ($39.99) and eBook ($29.99) editions. Learn more...

Buy Print Preview Book

Finally, if the gesture has just ended, the scale needs to be returned to 1 and the currentCellPath property reset to nil:

else
{
    self.collectionView?.performBatchUpdates({
        layout.currentCellPath = nil
        layout.currentCellScale = 1.0}, completion:nil)
}

This task is performed as a batch update so that the changes take place in a single animated update.

Avoiding Image Clipping

When the user pinches on a cell in the collection view and stretches the cell, the image contained therein will stretch with it. In order to avoid the enlarged image from being clipped by the containing cell when the gesture ends, a property on the MyCollectionViewCell class needs to be modified.

Within Xcode, select the Main.storyboard file followed by the MyCell entry in the Document Outline panel to the left of the storyboard canvas. Display the Attributes Inspector and, in the Drawing section of the panel, unset the checkbox next to Clip to Bounds.

Testing the Application

With a suitably provisioned iOS device attached to the development system, run the application. Once running, use pinching motions on the display to resize an image in a cell, noting that the cell can also be moved during the gesture. On ending the gesture, the cell will spring back to the original location and size. Figure 72 1 shows the collection view during the resizing of a cell.


Ios 11 collection view resizing.png

Figure 72-1


Summary

Whilst the UICollectionViewFlowLayout class provides considerable flexibility in terms of controlling the way in which data is presented to the user, additional functionality can be added by subclassing and extending this class. In most cases the changes simply involve overriding two methods and modifying the layout attributes within those methods to implement the required layout behavior.

This chapter has worked through the implementation of a custom layout class that extends UICollectionViewFlowLayout to allow the user to move and resize the images contained in collection view cells. The chapter also looked at the use of gesture recognizers within the context of collection views.