Subclassing and Extending the iOS 7 Collection View Flow Layout

From Techotopia
Revision as of 14:42, 5 May 2016 by Neil (Talk | contribs) (Text replacement - "<table border="0" cellspacing="0" width="100%">" to "<table border="0" cellspacing="0">")

Jump to: navigation, search
PreviousTable of ContentsNext
An iOS 7 Storyboard-based Collection View TutorialDrawing iOS 7 2D Graphics with Core Graphics


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, the final chapter on the subject of collection views in iOS 7, 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 Cocoa Touch Objective-C class 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.h file and modify it as follows:

#import <UIKit/UIKit.h>

@interface MyFlowLayout : UICollectionViewFlowLayout
@property (strong, nonatomic) NSIndexPath *currentCellPath;
@property (nonatomic) CGPoint currentCellCenter;
@property (nonatomic) CGFloat currentCellScale;
@end

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, setter methods need to be implemented in the MyFlowLayout.m file for the center and scale properties that invalidate the layout in addition to storing the new property values:

#import "MyFlowLayout.h"

@implementation MyFlowLayout

-(void)setCurrentCellScale:(CGFloat)scale;
{
    _currentCellScale = scale;
    [self invalidateLayout];
}

- (void)setCurrentCellCenter:(CGPoint)origin
{
    _currentCellCenter = origin;
    [self invalidateLayout];
}
@end

Implementing the layoutAttributesForItemAtIndexPath: 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 layoutAttributesForItemAtIndexPath: or layoutAttributesForElementsInRect:. 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 layoutAttributesForElementsInRect 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 layoutAttributesForItemAtIndexPath method which should be implemented in the MyFlowLayout.m file as follows:

-(UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath
{
    // Get the current attributes for the item at the indexPath
    UICollectionViewLayoutAttributes *attributes = 
         [super layoutAttributesForItemAtIndexPath:indexPath];

    // Modify them to match the pinch values
    [self modifyLayoutAttributes:attributes];

    // return them to collection view
    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 layoutAttributesForItemAtIndexPath method of the superclass:

UICollectionViewLayoutAttributes *attributes = 
      [super layoutAttributesForItemAtIndexPath: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.

Implementing the layoutAttributesForElementsInRect: Method

The layoutAttributesForElementsInRect 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:

-(NSArray*)layoutAttributesForElementsInRect:(CGRect)rect
{
    // Get all the attributes for the elements in the specified frame
    NSArray *allAttributesInRect = [super 
         layoutAttributesForElementsInRect:rect];

    for (UICollectionViewLayoutAttributes *cellAttributes in allAttributesInRect)
    {
        // Modify the attributes for the cells in the frame rect
        [self modifyLayoutAttributes:cellAttributes];
    }
    return allAttributesInRect;
}

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 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:

-(void)modifyLayoutAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes
{
    // If the indexPath matches the one we have stored
    if ([layoutAttributes.indexPath isEqual:_currentCellPath])
    {
        // Assign the new layout attributes
        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 isEqual:_currentCellPath])

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. This, in turn, will require that the MyFlowLayout.h file be imported into the MyCollectionViewController.h file as follows:

#import <UIKit/UIKit.h>
#import "MyCollectionViewCell.h"
#import "MySupplementaryView.h"
#import "MyFlowLayout.h"

@interface MyCollectionViewController : UICollectionViewController
<UICollectionViewDataSource, UICollectionViewDelegate>
@property (strong, nonatomic) NSMutableArray *carImages;
@end

Next, select the MyCollectionViewController.m 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:

- (void)viewDidLoad
{
    [super viewDidLoad];

    MyFlowLayout *myLayout = [[MyFlowLayout alloc]init];

    [self.collectionView 
       setCollectionViewLayout:myLayout animated:YES];

    UIGestureRecognizer *pinchRecognizer = 
         [[UIPinchGestureRecognizer alloc] 
            initWithTarget:self 
            action:@selector(handlePinch:)];

    [self.collectionView addGestureRecognizer:pinchRecognizer];

    _carImages = [@[@"chevy_small.jpg",
                    @"mini_small.jpg",
                    @"rover_small.jpg",
                    @"smart_small.jpg",
                    @"highlander_small.jpg",
                    @"venza_small.jpg",
                    @"volvo_small.jpg",
                    @"vw_small.jpg",
                    @"ford_small.jpg",
                    @"nissan_small.jpg",
                    @"honda_small.jpg",
                    @"jeep_small.jpg"] mutableCopy];
}

Implementing the Pinch Recognizer

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

- (IBAction)handlePinch:(UIPinchGestureRecognizer *)sender {

    // Get a reference to the flow layout

    MyFlowLayout *layout = 
          (MyFlowLayout *)self.collectionView.collectionViewLayout;

    // If this is the start of the gesture
    if (sender.state == UIGestureRecognizerStateBegan)
    {
        // Get the initial location of the pinch?
        CGPoint initialPinchPoint = 
             [sender locationInView:self.collectionView];

        //Convert pinch location into a specific cell
        NSIndexPath *pinchedCellPath = 
             [self.collectionView indexPathForItemAtPoint:initialPinchPoint];

        // Store the indexPath to cell
        layout.currentCellPath = pinchedCellPath;
    }
    else if (sender.state == UIGestureRecognizerStateChanged)
    {
        // Store the new center location of the selected cell
        layout.currentCellCenter = 
              [sender locationInView:self.collectionView];
        // Store the scale value
        layout.currentCellScale = sender.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:

MyFlowLayout *layout = 
          (MyFlowLayout *)self.collectionView.collectionViewLayout;

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 (sender.state == UIGestureRecognizerStateBegan)
{
    // Get the initial location of the pinch?
    CGPoint initialPinchPoint = 
         [sender locationInView:self.collectionView];

    //Convert pinch location into a specific cell
    NSIndexPath *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 (sender.state == UIGestureRecognizerStateChanged)
{
    // Store the new center location of the selected cell
    layout.currentCellCenter = [sender 
            locationInView:self.collectionView];
    // Store the scale value
    layout.currentCellScale = sender.scale;
}

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 and select the My Collection View Cell 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 Subviews.

Testing the Application

With a suitably provisioned iPhone 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 50-1 shows the collection view during the resizing of a cell.


An iOS 7 CollectionView with custom layout behavior

Figure 50-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.


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



PreviousTable of ContentsNext
An iOS 7 Storyboard-based Collection View TutorialDrawing iOS 7 2D Graphics with Core Graphics