Adding pinch zoom to a UICollectionView

Benjohn picture Benjohn · Jan 3, 2014 · Viewed 15.6k times · Source

Intro

I'm going to describe the effect I want to achieve, and then I'll give detail on how I'm currently trying to implement this and what's wrong with its behaviour as it stands. I'll also mention another approach I've looked at but couldn't make work at all.

The most relevant code is inline at the bottom of the question for quick access. You can Download a zip of the source or get the project as a Mercurial Repository at BitBucket. The project now incorporates the fixes from the answer below. If you want the broken version initially provided, it is tagged with "initial-buggy-version"

The project is a minimal-ish proof of concept / spike to evaluate whether the effect is viable, so it's fairly light and simple!

Desired Effect

The App will display a large number of discrete rows of information that form a vertical table. The table will be vertically scrollable by the user. This is standard behaviour with a UITableView, and you can use a UICollectionView too. However, the App must also support pinch scaling. When you pinch zoom on the table, all of the lines should squash together. As you stretch, all of the lines should pull apart.

In my proof of concept the individual cells are not resized, they are just repositioned closer together or further apart. This is intentional: I don't believe it's critical to validating the idea's feasibility.

Here're screen grabs showing how the current App looks zoomed out and zoomed in:

Zoomed in image Zoomed out image

Current Implementation

I'm using a UICollectionView with a custom UICollectionViewLayout subclass. The layout positions the UICollectionViewCells in a nice wobbly sine wave down the middle of the screen. Each UICollectionViewCell is just a container for a UILabel holding the indexPath row.

The UICollectionViewLayout subclass has a parameter to set the vertical spacing between each cell it describes to the UICollectionView and adjusting this allows the table to be squashed or stretched vertically as desired.

My UICollectionViewController subclass has a UIPinchGestureRecognizer. When the recogniser detects scale changes, the vertical cell spacing in the UICollectionView's layout is changed accordingly.

Without further consideration, scaling would occur from the top of the content, rather than about the centre of the touch gesture. The UICollectionView's contentOffset property is adjusted during pinching to provide this feature.

The gesture recogniser also needs to accommodate drags that occur while pinching. This is also handled by changing the UICollectionView's contentOffset. Some additional code allows for the centre point of the touch gesture changing about as fingers are added to / removed from the gesture.

Note that UICollectionView, being a UIScrollView subclass, has its own UIPanGestureRecognizer which interacts with the UIPinchGestureRecogniser added by me. I am not sure if this is causing a problem or not.

I have added code to disable the UICollectionView's built in scrolling during my pinch gesture, but this doesn't seem to make much difference. I tried to use gestureRecognizer:shouldRequireFailureOfGestureRecognizer: to make my UIPinchGestureRecognizer fail the built in UIPanGestureRecognizer, but this instead seemed to stop my pinch recognizer working at all. I don't know if this is me being stupid, or a bug in iOS.

As mentioned before, current the UICollectionViewCells are not resized. They are just repositioned. This is intentional. I don't think it's important for validating this concept.

What Works

The working bits work quite well. You can drag the table up and down. During a drag you can add a finger and start a pinch, then release a finger and continue the drag, then add and pinch, etc. It's all pretty smooth. On an original iPhone 5 it smoothly supports pinch and pan with > 200 views on screen.

What doesn't work 1

If you try to pinch in and out when the top or the bottom of the view is on screen, it all goes a bit mad.

  • On scrolls, the view is allowed to drag so that it is pulled beyond the visible content (which I want, as it's standard behaviour for a list of data on iOS).
  • On scale changes, however, the view is snapped back so that the content is clamped in to the screen (I don't want this to happen).

These two fight with each other during the pinch gesture, which makes the content violently flicker up and down (which I definitely don't want!).

What doesn't work 2

The UICollectionView's default scrolling has deceleration if you let go while scrolling, and also smoothly bounces the content back when you scroll outside of it. These aren't handled at all currently.

  • If you release the pinch gesture while scrolling, it just stops.
  • If you scroll beyond the content with the pinch gesture and then release, it stays where it is and doesn't bounce back. When you then begin a scroll again it jumps the content back.

Things I've tried but couldn't get to work

UICollectionView, being a UIScrollView should have a built in UIPinchGestureRecogniser if it's set up correctly to support zooming. I wondered if I could harness this instead of having my own UIPinchGestureRecogniser. I tried to set this up by setting min and max scales, and adding my controller's pinch handler. However, I don't really understand what I should be returning from my implementation of viewForZoomingInScrollView:, so I'm just creating a dummy view with [[UIView alloc] initWithFrame: [[self collectionView] bounds]]. It makes the scroll view "collapse" to a single line, which isn't what I'm after!

Finally (Before the code)

This is a long question, so thank you for reading it. Thank you even more if you can help with an answer. I'm sorry if a lot of what I've said or added is irrelevant!

Code for the view controller

//  STViewController.m
#import "STViewController.h"
#import "STDataColumnsCollectionViewLayout.h"
#import "STCollectionViewLabelCell.h"

@interface STViewController () <UIGestureRecognizerDelegate>
@property (nonatomic, assign) CGFloat pinchStartVerticalPeriod;
@property (nonatomic, assign) CGFloat pinchNormalisedVerticalPosition;
@property (nonatomic, assign) NSInteger pinchTouchCount;
-(void) handlePinch: (UIPinchGestureRecognizer *) pinchRecogniser;
@end

@implementation STViewController

-(void) viewDidLoad
{
  [[self collectionView] registerClass: [STCollectionViewLabelCell class] forCellWithReuseIdentifier: [STCollectionViewLabelCell className]];

  UICollectionView *const collectionView = [self collectionView];
  [collectionView setAllowsSelection: NO];

  [_pinchRecogniser addTarget: self action: @selector(handlePinch:)];
  [_pinchRecogniser setDelegate: self];
  [_pinchRecogniser setCancelsTouchesInView:YES];
  [[self view] addGestureRecognizer: _pinchRecogniser];
}

#pragma mark -

-(NSInteger) collectionView: (UICollectionView *)collectionView numberOfItemsInSection: (NSInteger)section
{
  return 800;
}

-(UICollectionViewCell*) collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
  STCollectionViewLabelCell *const cell = [[self collectionView] dequeueReusableCellWithReuseIdentifier: [STCollectionViewLabelCell className] forIndexPath: indexPath];
  [[cell label] setText: [NSString stringWithFormat: @"%d", [indexPath row]]];
  return cell;
}

#pragma mark -

-(BOOL) gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
  return YES;
}

#pragma mark -

-(void) handlePinch: (UIPinchGestureRecognizer *) pinchRecogniser
{
  UICollectionView *const collectionView = [self collectionView];
  STDataColumnsCollectionViewLayout *const layout = (STDataColumnsCollectionViewLayout *)[self collectionViewLayout];

  if(([pinchRecogniser state] == UIGestureRecognizerStateBegan) || ([pinchRecogniser numberOfTouches] != _pinchTouchCount))
  {
    const CGFloat normalisedY = [pinchRecogniser locationInView: collectionView].y / [layout collectionViewContentSize].height;
    _pinchNormalisedVerticalPosition = normalisedY;
    _pinchTouchCount = [pinchRecogniser numberOfTouches];
  }

  switch ([pinchRecogniser state])
  {
    case UIGestureRecognizerStateBegan:
    {
      NSLog(@"Began");
      _pinchStartVerticalPeriod = [layout verticalPeriod];
      [collectionView setScrollEnabled: NO];
      break;
    }

    case UIGestureRecognizerStateChanged:
    {
      NSLog(@"Changed");
      STDataColumnsCollectionViewLayout *const layout = (STDataColumnsCollectionViewLayout *)[self collectionViewLayout];
      const CGFloat newVerticalPeriod = _pinchStartVerticalPeriod * [pinchRecogniser scale];
      [layout setVerticalPeriod: newVerticalPeriod];
      [[self collectionViewLayout] invalidateLayout];

      const CGPoint dragCenter = [pinchRecogniser locationInView: [collectionView superview]];
      const CGFloat currentY = _pinchNormalisedVerticalPosition * [layout collectionViewContentSize].height;
      [collectionView setContentOffset: CGPointMake(0, currentY - dragCenter.y) animated: NO];
    }

    case UIGestureRecognizerStateEnded:
    case UIGestureRecognizerStateCancelled:
    {
      [collectionView setScrollEnabled: YES];
    }

    default:
      break;
  }
}

@end

Answer

Benjohn picture Benjohn · Jan 8, 2014

The good bit – how to make it work

Some very minor tweaks to the above code have resolved What Doesn't Work 1 & What Doesn't Work 2 in the question.

I have added the following lines in to the viewDidLoad method of my UICollectionViewController:

[collectionView setMinimumZoomScale: 0.25];
[collectionView setMaximumZoomScale: 4];

I've also updated the example project so that instead of text labels, the view is made of little circles. As you zoom in and out, these are resized. Here's what it looks like now (zoomed out and zoomed in):

Image zoomed out Image zoomed in

During a zoom the views for the circles are not redrawn, but just interpolated from their pre-zoom size. The redraw is postponed until the zoom finishes. Here's a capture of how that looks after a zoom in of several times:

During zoom

It would be great to have the redrawing during zoom happen in a background thread so that the artefacts are less noticeable, but that's well out of the scope of this question and I've not worked on it yet either.

You can find the entire project, with fixes, on Bit Bucket so you can grab the files there.

The Bad Part – I don't know why it works

I was hoping that with this question answered, I'd have lots of new certainty about UIScrollView zooming. I don't.

From what I've read about UIScrollView, this "fix" should not have made any difference and it should have already worked in the first place anyway.

A UIScrollView isn't supposed to enable scrolling until you give it a delegate that implements viewForZoomingInScrollView:, which I've not done.