VinnyD
VinnyD

Reputation: 3550

Max/Min Scale of Pinch Zoom in UIPinchGestureRecognizer - iPhone iOS

How would I be able to limit the scale of the UIPinchGestureRecognizer to a min and max level? The scale property below seems to be relative to the last known scale (the delta from last state) and I can't figure out how to set a limit to the size/heigh of the object being zoomed.

-(void)scale:(id)sender {

[self.view bringSubviewToFront:[(UIPinchGestureRecognizer*)sender view]];

if([(UIPinchGestureRecognizer*)sender state] == UIGestureRecognizerStateEnded) {
    lastScale = 1.0;
    return;
}

CGFloat pinchscale = [(UIPinchGestureRecognizer*)sender scale];
CGFloat scale = 1.0 - (lastScale - pinchscale);
CGAffineTransform currentTransform = [(UIPinchGestureRecognizer*)sender view].transform;
CGAffineTransform holderTransform = holderView.transform;
CGAffineTransform newTransform = CGAffineTransformScale(currentTransform, scale, scale);
[[(UIPinchGestureRecognizer*)sender view] setTransform:newTransform];

lastScale = [(UIPinchGestureRecognizer*)sender scale];

}

Upvotes: 44

Views: 44807

Answers (12)

Fattie
Fattie

Reputation: 12621

It's really this simple ...

  1. the step each time is scale minus one

  2. reset the scale to one each time.

  3. only act on .changed

It's that easy.

@objc func pinched(_ g: UIPinchGestureRecognizer) {
    
    if g.state != .changed { return }

    let scaleStep = g.scale - 1.0
    g.scale = 1
       
    sizeOfYourObject += (scaleStep * 500)
}

That's the whole thing.


Do note that it is a HUGE amount of work to write a complete, typical, pinch system. Merely one major issue, for example, is that you (generally) don't want the image "sliding off the edge of the universe" if that makes sense, and there is a ridiculous amount of code in getting that right, and many ways to address it. Really sophisticated finger control does not even use the "scale factor" supplied by apple, you deal with the individual finger movements. But the correct and only code for using .scale is just the two lines of code above.

Upvotes: 0

Paul Ollivier
Paul Ollivier

Reputation: 379

I took @Paul Solt solution - that is great btw, and adapted it to Swift, for those interested

@objc func pinchUpdated(recognizer: UIPinchGestureRecognizer) {
        
        if recognizer.state == .began {
            // Reset the last scale, necessary if there are multiple objects with different scales
            lastScale = recognizer.scale
        }

        if recognizer.state == .began || recognizer.state == .changed {

            let currentScale = recognizer.view!.layer.value(forKeyPath: "transform.scale") as! CGFloat

            // Constants to adjust the max/min values of zoom
            let maxScale: CGFloat = 4.0
            let ninScale: CGFloat = 0.9

            var newScale: CGFloat = 1 -  (lastScale - recognizer.scale)
            newScale = min(newScale, maxScale / currentScale)
            newScale = max(newScale, ninScale / currentScale)
            recognizer.view!.transform = recognizer.view!.transform.scaledBy(x: newScale, y: newScale)

            lastScale = recognizer.scale // Store the previous scale factor for the next pinch gesture call
        }
    }

Upvotes: -1

Fu Jiantao
Fu Jiantao

Reputation: 104

Method 1

gestureRecognizer.scale start with 1.0 at the beginning of pinch (gestureRecognizer.state == .began), and gestureRecognizer.scale in later state (.changed or .end) is always based on that, for example, if the view size is view_size at the beginning of pinch (might not be the same with the original size orig_view_size), gestureRecognizer.scale always starts with 1.0, and if it becomes 2.0 later, it's size will be 2 * view_size, so the scale always based on that when the pinch starts.

And we can get the scale at the beginning of pinch (gestureRecognizer.state == .began) lastScale = self.imageView.frame.width/self.imageView.bounds.size.width, so the scale of the original image now should be lastScale * gestureRecognizer.scale

  • lastScale: The scale of last round of Pinch, a round of Pinch is from state.start to state.end, and the scale is based on the original view size.

  • gestureRecognizer.scale: current scale, based on the view size after last round of Pinch.

  • currentScale: current scale, based on the orignial view size.

  • newScale: new scale, based on the orignial view size. newScale = lastScale * gestureRecognizer.scale, and you can limit the scale of the view by comparing the limitation with newScale.

```

var lastScale:CGFloat = 1.0

@objc func handlePinch(_ gestureRecognizer: UIPinchGestureRecognizer) {
        var newScale = gestureRecognizer.scale
        if gestureRecognizer.state == .began {
            lastScale = self.imageView.frame.width/self.imageView.bounds.size.width
        }
        newScale = newScale * lastScale

        if newScale < minScale {
            newScale = minScale
        } else if newScale > maxScale {
            newScale = maxScale
        }

        let currentScale = self.imageView.frame.width/self.imageView.bounds.size.width
        self.imageView.transform = CGAffineTransform(scaleX: newScale, y: newScale)
        print("last Scale: \(lastScale), current scale: \(currentScale), new scale: \(newScale), gestureRecognizer.scale: \(gestureRecognizer.scale)")
}

```

Method 2

gestureRecognizer.scale start with 1.0 on each Pinch notification, this require you reset gestureRecognizer.scale = 1 in the code in the end of each notification handler, so now gestureRecognizer.scale is based on the view size of last Pinch notification, NOT based on the view size at the beginning of pinch. This is the most important difference with method 1. And since we don't rely on the scale of last round, we don't need lastScale anymore.

  • currentScale: current scale, based on the orignial view size.

  • gestureRecognizer.scale: new scale, based on the view size of last Pinch (not the last round), the scale value based on the orignial view size will be currentScale * gestureRecognizer.scale

And we use transform.scaledBy now, which use the scale based on view size of last Pinch (not the last round).

```

@objc func handlePinch(_ gestureRecognizer: UIPinchGestureRecognizer) {
        let currentScale = self.imageView.frame.width/self.imageView.bounds.size.width
        var newScale = gestureRecognizer.scale
        if currentScale * gestureRecognizer.scale < minScale {
            newScale = minScale / currentScale
        } else if currentScale * gestureRecognizer.scale > maxScale {
            newScale = maxScale / currentScale
        }
        self.imageView.transform = self.imageView.transform.scaledBy(x: newScale, y: newScale)

        print("current scale: \(currentScale), new scale: \(newScale)")

        gestureRecognizer.scale = 1
}

```

Upvotes: 1

Roman Solodyashkin
Roman Solodyashkin

Reputation: 829

- (void)pinchToZoom:(UIPinchGestureRecognizer*)gesture
{
    switch (gesture.state)
    {
        case UIGestureRecognizerStateBegan:
        {
            lastScale = gesture.scale;
        }break;
        case UIGestureRecognizerStateChanged:
        {   
            const CGFloat zoomSensitivity = 5;
            const CGFloat zoomMin = 1;
            const CGFloat zoomMax = 16;

            CGFloat objectScale = gesture.view.contentScaleFactor;
            CGFloat zoomDiff = lastScale - gesture.scale;
            CGFloat zoomDirty = objectScale - zoomDiff * zoomSensivity;
            CGFloat zoomTo = fmaxf(zoomMin, fminf(zoomDirty, zoomMax));

            // step round if needed (neutralize elusive changes)
            zoomTo = (NSInteger)(zoomTo * 10) * 0.1;

            if ( objectScale != zoomTo )
                gesture.view.contentScaleFactor = zoomTo;

            lastScale = gesture.scale;
        }break;
        default:
            break;
    }
}

Upvotes: 0

Arvind Kumar
Arvind Kumar

Reputation: 2411

- (void)handlePinch:(UIPinchGestureRecognizer *)recognizer{

    //recognizer.scale=1;

    CGFloat pinchScale = recognizer.scale;
    pinchScale = round(pinchScale * 1000) / 1000.0;
    NSLog(@"%lf",pinchScale);

if (pinchScale < 1)

 {

 currentLabel.font = [UIFont fontWithName:currentLabel.font.fontName size:

(currentLabel.font.pointSize - pinchScale)];

   recognizer.view.transform = CGAffineTransformScale(recognizer.view.transform, recognizer.scale, recognizer.scale);

 [currentLabel sizeToFit];

  recognizer.scale=1;
    }
  else
    {
        currentLabel.font = [UIFont fontWithName:currentLabel.font.fontName size:(currentLabel.font.pointSize + pinchScale)];

         recognizer.view.transform = CGAffineTransformScale(recognizer.view.transform, recognizer.scale, recognizer.scale);

         [currentLabel sizeToFit];

        recognizer.scale=1;
    }
    //currentLabel.adjustsFontSizeToFitWidth = YES;

   // [currentLabel sizeToFit];
    NSLog(@"Font :%@",label.font);
}

Upvotes: 1

damian
damian

Reputation: 3674

The problem with most of the other answers is that they are trying to deal with scale as a linear value, when in fact it is non-linear due to the way UIPinchGestureRecognizer calculates its scale property based on the touch distance. When this isn't taken into account, the user must use more or less pinch distance to 'undo' the scaling applied by a previous pinch gesture.

Consider: suppose transform.scale = 1.0 and I place my fingers 6cm apart on the screen, then pinch inwards to 3cm apart - the resulting gestureRecognizer.scale is 0.5, and 0.5-1.0 is -0.5, so transform.scale will become 1.0+(-0.5) = 0.5. Now, I lift my fingers, place them back down 3cm apart and pinch outwards to 6cm. The resulting gestureRecognizer.scale will be 2.0, and 2.0-1.0 is 1.0, so transform.scale will become 0.5+1.0 = 1.5. Not what I wanted to happen.

The fix is to calculate the delta pinch scale as a proportion of its previous value. I place my fingers down 6cm apart, and pinch inwards to 3cm, so gestureRecognizer.scale is 0.5. 0.5/1.0 is 0.5, so my new transform.scale is 1.0*0.5 = 0.5. Next, I place my fingers down 3cm apart, and pinch outwards to 6cm. gestureRecognizer.scale is then 2.0, and 2.0/1.0 is 2.0, so my new transform.scale is 0.5*2.0 = 1.0, which is exactly what I wanted to happen.

Here it is in code:

in -(void)viewDidLoad:

self.zoomGestureCurrentZoom = 1.0f;

in -(void)onZoomGesture:(UIPinchGestureRecognizer*)gestureRecognizer:

if ( gestureRecognizer.state == UIGestureRecognizerStateBegan )
{
    self.zoomGestureLastScale = gestureRecognizer.scale;
}
else if ( gestureRecognizer.state == UIGestureRecognizerStateChanged )
{
    // we have to jump through some hoops to clamp the scale in a way that makes the UX intuitive
    float scaleDeltaFactor = gestureRecognizer.scale/self.zoomGestureLastScale;
    float currentZoom = self.zoomGestureCurrentZoom;
    float newZoom = currentZoom * scaleDeltaFactor;
    // clamp
    float kMaxZoom = 4.0f;
    float kMinZoom = 0.5f;
    newZoom = MAX(kMinZoom,MIN(newZoom,kMaxZoom));    
    self.view.transform = CGAffineTransformScale([[gestureRecognizer view] transform], newZoom, newZoom);

    // store for next time
    self.zoomGestureCurrentZoom = newZoom;
    self.zoomGestureLastScale = gestureRecognizer.scale;
}

Upvotes: 4

Kevin_TA
Kevin_TA

Reputation: 4675

Other approaches mentioned here did not work for me, but taking a couple things from previous answers and (in my opinion) simplifying things, I've got this to work for me. effectiveScale is an ivar set to 1.0 in viewDidLoad.

-(void)zoomScale:(UIPinchGestureRecognizer *)recognizer
{
    if([recognizer state] == UIGestureRecognizerStateEnded) {
        // Reset last scale
        lastScale = 1.0;
        return;
    }

    if ([recognizer state] == UIGestureRecognizerStateBegan ||
    [recognizer state] == UIGestureRecognizerStateChanged) {

        CGFloat pinchscale = [recognizer scale];
        CGFloat scaleDiff = pinchscale - lastScale;

        if (scaleDiff < 0)
            scaleDiff *= 2; // speed up zoom-out
        else
            scaleDiff *= 0.7; // slow down zoom-in

        effectiveScale += scaleDiff;
        // Limit scale between 1 and 2
        effectiveScale = effectiveScale < 1 ? 1 : effectiveScale;
        effectiveScale = effectiveScale > 2 ? 2 : effectiveScale;

        // Handle transform in separate method using new effectiveScale    
        [self makeAndApplyAffineTransform];
        lastScale = pinchscale;
    }
}

Upvotes: 1

Shagun
Shagun

Reputation: 21

Thanks, really useful code snippet above clamping to a minimum and maximum scale.

I found that when I flipped the view first using:

CGAffineTransformScale(gestureRecognizer.view.transform, -1.0, 1.0); 

it would cause a flicker when scaling the view.

Let me know what you think but the solution for me was to update the code sample above, and if the view has been flipped (flag set via property) then invert the scale value:

if ([gestureRecognizer state] == UIGestureRecognizerStateBegan || [gestureRecognizer     state] == UIGestureRecognizerStateChanged)
{
    CGFloat currentScale = [[[gestureRecognizer view].layer valueForKeyPath:@"transform.scale"] floatValue];

    if(self.isFlipped) // (inverting)
    {
        currentScale *= -1;
    }

    CGFloat newScale = 1 -  (self.lastScale - [gestureRecognizer scale]);

    newScale = MIN(newScale, self.maximumScaleFactor / currentScale);
    newScale = MAX(newScale, self.minimumScaleFactor / currentScale);

    CGAffineTransform transform = CGAffineTransformScale([[gestureRecognizer view] transform], newScale, newScale);
    gestureRecognizer.view.transform = transform;

    self.lastScale = [gestureRecognizer scale];  // Store the previous scale factor for the next pinch gesture call

Upvotes: 2

unsynchronized
unsynchronized

Reputation: 4938

I took some info gleaned from Paul Solt and Anoime's answers, and added that to an existing category I have made for UIViewController to allow making any UIView draggable, to now make it pinchable using gestures and transforms.

Note: this dirties the tag property of the view you are making draggable/pinchable. So if you needed the tag for something else, you can consider placing that value in the NSMutableDictionary being used by this technique. That's available as [self dictForView:theView]

Implementing in your project:

You can make any subview within the view controllers "view" draggable or pinchable (or both) place a single line of code in your viewDidLoad (for example:)

[self makeView:mySubView draggable:YES pinchable:YES minPinchScale:0.75 maxPinchScale:1.0];

turn it off in viewDidUnload (releases guestures & dictionary):

[self makeView:mySubView draggable:NO pinchable:NO minPinchScale:1.0 maxPinchScale:1.0];

DragAndPinchScale.h file

#import <UIKit/UIKit.h>

@interface UIViewController (DragAndPinchScale)

-(void) makeView:(UIView*)aView 
       draggable:(BOOL)draggable 
       pinchable:(BOOL)pinchable 
   minPinchScale:(CGFloat)minPinchScale
   maxPinchScale:(CGFloat)maxPinchScale;


-(NSMutableDictionary *) dictForView:(UIView *)theView;
-(NSMutableDictionary *) dictForViewGuestures:(UIGestureRecognizer *)guesture;

@end

DragAndPinchScale.m file

#import "DragAndPinchScale.h"

@implementation UIViewController (DragAndPinchScale)


-(NSMutableDictionary *) dictForView:(UIView *)theView{
    NSMutableDictionary *dict = (NSMutableDictionary*) (void*) theView.tag;
    if (!dict) {
        dict = [[NSMutableDictionary dictionary ] retain];
        theView.tag = (NSInteger) (void *) dict;
    }

    return dict;

}


-(NSMutableDictionary *) dictForViewGuestures:(UIGestureRecognizer *)guesture {
    return [self dictForView:guesture.view];
}


- (IBAction)fingersDidPinchInPinchableView:(UIPinchGestureRecognizer *)fingers {
    NSMutableDictionary *dict = [self dictForViewGuestures:fingers];
    UIView *viewToZoom = fingers.view;
    CGFloat lastScale;
    if([fingers state] == UIGestureRecognizerStateBegan) {
        // Reset the last scale, necessary if there are multiple objects with different scales
        lastScale = [fingers scale];
    } else {
        lastScale = [[dict objectForKey:@"lastScale"] floatValue];
    }

    if ([fingers state] == UIGestureRecognizerStateBegan || 
        [fingers state] == UIGestureRecognizerStateChanged) {

        CGFloat currentScale = [[[fingers view].layer valueForKeyPath:@"transform.scale"] floatValue];

        // limits to adjust the max/min values of zoom
        CGFloat maxScale = [[dict objectForKey:@"maxScale"] floatValue];
        CGFloat minScale = [[dict objectForKey:@"minScale"] floatValue];

        CGFloat newScale = 1 -  (lastScale - [fingers scale]); 
        newScale = MIN(newScale, maxScale / currentScale);   
        newScale = MAX(newScale, minScale / currentScale);
        CGAffineTransform transform = CGAffineTransformScale([[fingers view] transform], newScale, newScale);
        viewToZoom.transform = transform;

        lastScale = [fingers scale];  // Store the previous scale factor for the next pinch gesture call  
    }

    [dict setObject:[NSNumber numberWithFloat:lastScale] 
             forKey:@"lastScale"];

}

- (void)fingerDidMoveInDraggableView:(UIPanGestureRecognizer *)finger {
    NSMutableDictionary *dict = [self dictForViewGuestures:finger];
    UIView *viewToDrag =  finger.view;
    if (finger.state == UIGestureRecognizerStateBegan) {

        [dict setObject:[NSValue valueWithCGPoint:viewToDrag.frame.origin] 
                 forKey:@"startDragOffset"];

        [dict setObject:[NSValue valueWithCGPoint:[finger locationInView:self.view]] 
                 forKey:@"startDragLocation"];


    }
    else if (finger.state == UIGestureRecognizerStateChanged) {

        NSMutableDictionary *dict = (NSMutableDictionary*) (void*) viewToDrag.tag;

        CGPoint stopLocation = [finger locationInView:self.view];
        CGPoint startDragLocation = [[dict valueForKey:@"startDragLocation"] CGPointValue];
        CGPoint startDragOffset = [[dict valueForKey:@"startDragOffset"] CGPointValue];
        CGFloat dx = stopLocation.x - startDragLocation.x;
        CGFloat dy = stopLocation.y - startDragLocation.y;
        //   CGFloat distance = sqrt(dx*dx + dy*dy );
        CGRect dragFrame = viewToDrag.frame;


        CGSize selfViewSize = self.view.frame.size;
        if (!UIDeviceOrientationIsPortrait(self.interfaceOrientation)) {
            selfViewSize = CGSizeMake(selfViewSize.height,selfViewSize.width);
        }

        selfViewSize.width  -= dragFrame.size.width;
        selfViewSize.height -= dragFrame.size.height;

        dragFrame.origin.x = MIN(selfViewSize.width, MAX(0,startDragOffset.x+dx));
        dragFrame.origin.y = MIN(selfViewSize.height,MAX(0,startDragOffset.y+dy));

        viewToDrag.frame = dragFrame;
    }
    else if (finger.state == UIGestureRecognizerStateEnded) {

        [dict removeObjectForKey:@"startDragLocation"];
        [dict removeObjectForKey:@"startDragOffset"];
    }
}

-(void) makeView:(UIView*)aView 
       draggable:(BOOL)draggable 
       pinchable:(BOOL)pinchable 
   minPinchScale:(CGFloat)minPinchScale
   maxPinchScale:(CGFloat)maxPinchScale{
    NSMutableDictionary *dict = (NSMutableDictionary*) (void*) aView.tag;

    if (!(pinchable || draggable)) {

        if (dict){ 
            [dict release];
            aView.tag = 0;
        }
        return;
    }

    if (dict) {

        UIPanGestureRecognizer *pan =[dict objectForKey:@"UIPanGestureRecognizer"];
        if(pan){
            if ([aView.gestureRecognizers indexOfObject:pan]!=NSNotFound) {
                [aView removeGestureRecognizer:pan];
            }
            [dict removeObjectForKey:@"UIPanGestureRecognizer"];
        }

        UIPinchGestureRecognizer *pinch =[dict objectForKey:@"UIPinchGestureRecognizer"];
        if(pinch){
            if ([aView.gestureRecognizers indexOfObject:pinch]!=NSNotFound) {
                [aView removeGestureRecognizer:pinch];
            }
            [dict removeObjectForKey:@"UIPinchGestureRecognizer"];
        }

        [dict removeObjectForKey:@"startDragLocation"];
        [dict removeObjectForKey:@"startDragOffset"];
        [dict removeObjectForKey:@"lastScale"];
        [dict removeObjectForKey:@"minScale"];
        [dict removeObjectForKey:@"maxScale"];
    }


    if (draggable) {

        UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(fingerDidMoveInDraggableView:)];
        pan.minimumNumberOfTouches = 1;  
        pan.maximumNumberOfTouches = 1;  
        [aView addGestureRecognizer:pan];
        [pan release];

        dict = [self dictForViewGuestures:pan];
        [dict setObject:pan forKey:@"UIPanGestureRecognizer"];

    }

    if (pinchable) {


        CGAffineTransform initialTramsform = CGAffineTransformMakeScale(1.0, 1.0);
        aView.transform = initialTramsform;


        UIPinchGestureRecognizer *pinch = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(fingersDidPinchInPinchableView:)];
        [aView addGestureRecognizer:pinch];
        [pinch release];
        dict = [self dictForViewGuestures:pinch];
        [dict setObject:pinch forKey:@"UIPinchGestureRecognizer"];
        [dict setObject:[NSNumber numberWithFloat:minPinchScale] forKey:@"minScale"];
        [dict setObject:[NSNumber numberWithFloat:maxPinchScale] forKey:@"maxScale"];


    }

}

@end

Upvotes: 4

Paul Solt
Paul Solt

Reputation: 8395

Here is the solution that I figured out after using Anomie's answer as a starting point.

- (void)handlePinchGesture:(UIPinchGestureRecognizer *)gestureRecognizer {

    if([gestureRecognizer state] == UIGestureRecognizerStateBegan) {
        // Reset the last scale, necessary if there are multiple objects with different scales
        lastScale = [gestureRecognizer scale];
    }

    if ([gestureRecognizer state] == UIGestureRecognizerStateBegan || 
        [gestureRecognizer state] == UIGestureRecognizerStateChanged) {

        CGFloat currentScale = [[[gestureRecognizer view].layer valueForKeyPath:@"transform.scale"] floatValue];

        // Constants to adjust the max/min values of zoom
        const CGFloat kMaxScale = 2.0;
        const CGFloat kMinScale = 1.0;

        CGFloat newScale = 1 -  (lastScale - [gestureRecognizer scale]); 
        newScale = MIN(newScale, kMaxScale / currentScale);   
        newScale = MAX(newScale, kMinScale / currentScale);
        CGAffineTransform transform = CGAffineTransformScale([[gestureRecognizer view] transform], newScale, newScale);
        [gestureRecognizer view].transform = transform;

        lastScale = [gestureRecognizer scale];  // Store the previous scale factor for the next pinch gesture call  
    }
}

Upvotes: 110

Anomie
Anomie

Reputation: 94794

There isn't a way to limit the scale on a UIPinchGestureRecognizer. To limit the height in your code, you should be able to do something like this:

CGFloat scale = 1.0 - (lastScale - pinchscale);
CGRect bounds = [(UIPinchGestureRecognizer*)sender view].bounds;
scale = MIN(scale, maximumHeight / CGRectGetHeight(bounds));
scale = MAX(scale, minimumHeight / CGRectGetHeight(bounds));

To limit width, change 'Height' to 'Width' in the last two lines.

Upvotes: 18

micmcg
micmcg

Reputation: 2390

Can you use a scroll view instead? Then you could use scrollView.minimumZoomScale and scrollView.maximumZoomScale

Upvotes: -2

Related Questions