// // TOCropView.m // // Copyright 2015 Timothy Oliver. All rights reserved. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to // deal in the Software without restriction, including without limitation the // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR // IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. #import "TOCropView.h" #import "TOCropOverlayView.h" #import "TOCropScrollView.h" #define TOCROPVIEW_BACKGROUND_COLOR [UIColor colorWithWhite:0.12f alpha:1.0f] static const CGFloat kTOCropViewPadding = 14.0f; static const NSTimeInterval kTOCropTimerDuration = 0.8f; static const CGFloat kTOCropViewMinimumBoxSize = 42.0f; /* When the user taps down to resize the box, this state is used to determine where they tapped and how to manipulate the box */ typedef NS_ENUM(NSInteger, TOCropViewOverlayEdge) { TOCropViewOverlayEdgeNone, TOCropViewOverlayEdgeTopLeft, TOCropViewOverlayEdgeTop, TOCropViewOverlayEdgeTopRight, TOCropViewOverlayEdgeRight, TOCropViewOverlayEdgeBottomRight, TOCropViewOverlayEdgeBottom, TOCropViewOverlayEdgeBottomLeft, TOCropViewOverlayEdgeLeft }; @interface TOCropView () @property (nonatomic, strong, readwrite) UIImage *image; /* Views */ @property (nonatomic, strong) UIImageView *backgroundImageView; /* The main image view, placed within the scroll view */ @property (nonatomic, strong) UIView *backgroundContainerView; /* A view which contains the background image view, to separate its transforms from the scroll view. */ @property (nonatomic, strong) UIImageView *foregroundImageView; /* A copy of the background image view, placed over the dimming views */ @property (nonatomic, strong) UIView *foregroundContainerView; /* A container view that clips the foreground image view to the crop box frame */ @property (nonatomic, strong) TOCropScrollView *scrollView; /* The scroll view in charge of panning/zooming the image. */ @property (nonatomic, strong) UIView *overlayView; /* A semi-transparent grey view, overlaid on top of the background image */ @property (nonatomic, strong) UIView *translucencyView; /* A blur view that is made visible when the user isn't interacting with the crop view */ @property (nonatomic, strong) TOCropOverlayView *gridOverlayView; /* A grid view overlaid on top of the foreground image view's container. */ @property (nonatomic, strong) UIPanGestureRecognizer *gridPanGestureRecognizer; /* The gesture recognizer in charge of controlling the resizing of the crop view */ /* Crop box handling */ @property (nonatomic, assign) TOCropViewOverlayEdge tappedEdge; /* The edge region that the user tapped on, to resize the cropping region */ @property (nonatomic, assign) CGRect cropOriginFrame; /* When resizing, this is the original frame of the crop box. */ @property (nonatomic, assign) CGPoint panOriginPoint; /* The initial touch point of the pan gesture recognizer */ @property (nonatomic, assign, readwrite) CGRect cropBoxFrame; /* The frame, in relation to to this view where the grid, and crop container view are aligned */ @property (nonatomic, strong) NSTimer *resetTimer; /* The timer used to reset the view after the user stops interacting with it */ @property (nonatomic, assign) BOOL editing; /* Used to denote the active state of the user manipulating the content */ @property (nonatomic, assign, readwrite) NSInteger angle; /* Pre-screen-rotation state information */ @property (nonatomic, assign) CGPoint rotationContentOffset; @property (nonatomic, assign) CGSize rotationContentSize; @property (nonatomic, readonly) CGRect contentBounds; /* Give the current screen real-estate, the frame that the scroll view is allowed to use */ @property (nonatomic, readonly) CGSize imageSize; /* Given the current rotation of the image, the size of the image */ /* 90-degree rotation state data */ @property (nonatomic, assign) CGSize cropBoxLastEditedSize; /* When performing 90-degree rotations, remember what our last manual size was to use that as a base */ @property (nonatomic, assign) NSInteger cropBoxLastEditedAngle; /* Remember which angle we were at when we saved the editing size */ @property (nonatomic, assign) BOOL rotateAnimationInProgress; /* Disallow any input while the rotation animation is playing */ /* Reset state data */ @property (nonatomic, assign) CGSize originalCropBoxSize; /* Save the original crop box size so we can tell when the content has been edited */ @property (nonatomic, assign, readwrite) BOOL canReset; - (void)setup; /* Image layout */ - (void)layoutInitialImage; - (void)matchForegroundToBackground; /* Crop box handling */ - (TOCropViewOverlayEdge)cropEdgeForPoint:(CGPoint)point; - (void)updateCropBoxFrameWithGesturePoint:(CGPoint)point; /* Editing state */ - (void)setEditing:(BOOL)editing animated:(BOOL)animated; - (void)moveCroppedContentToCenterAnimated:(BOOL)animated; - (void)startEditing; /* Timer handling */ - (void)startResetTimer; - (void)timerTriggered; - (void)cancelResetTimer; /* Gesture Recognizers */ - (void)gridPanGestureRecognized:(UIPanGestureRecognizer *)recognizer; - (void)longPressGestureRecognized:(UILongPressGestureRecognizer *)recognizer; /* Reset state */ - (void)checkForCanReset; @end @implementation TOCropView - (instancetype)initWithImage:(UIImage *)image { if (self = [super init]) { _image = image; [self setup]; } return self; } - (void)setup { __weak typeof(self) weakSelf = self; //View properties self.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth; self.backgroundColor = TOCROPVIEW_BACKGROUND_COLOR; self.cropBoxFrame = CGRectZero; //Scroll View properties self.scrollView = [[TOCropScrollView alloc] initWithFrame:self.bounds]; self.scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; self.scrollView.alwaysBounceHorizontal = YES; self.scrollView.alwaysBounceVertical = YES; self.scrollView.showsHorizontalScrollIndicator = NO; self.scrollView.showsVerticalScrollIndicator = NO; self.scrollView.delegate = self; [self addSubview:self.scrollView]; self.scrollView.touchesBegan = ^{ [weakSelf startEditing]; }; self.scrollView.touchesEnded = ^{ [weakSelf startResetTimer]; }; //Background Image View self.backgroundImageView = [[UIImageView alloc] initWithImage:self.image]; //self.backgroundImageView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; //Background container view self.backgroundContainerView = [[UIView alloc] initWithFrame:self.backgroundImageView.frame]; [self.backgroundContainerView addSubview:self.backgroundImageView]; [self.scrollView addSubview:self.backgroundContainerView]; //Grey transparent overlay view self.overlayView = [[UIView alloc] initWithFrame:self.bounds]; self.overlayView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; self.overlayView.backgroundColor = [self.backgroundColor colorWithAlphaComponent:0.35f]; self.overlayView.hidden = NO; self.overlayView.userInteractionEnabled = NO; [self addSubview:self.overlayView]; //Translucency View if (NSClassFromString(@"UIVisualEffectView")) { self.translucencyView = [[UIVisualEffectView alloc] initWithEffect:[UIBlurEffect effectWithStyle:UIBlurEffectStyleDark]]; self.translucencyView.frame = self.bounds; } else { UIToolbar *toolbar = [[UIToolbar alloc] init]; toolbar.barStyle = UIBarStyleBlack; self.translucencyView = toolbar; self.translucencyView.frame = CGRectInset(self.bounds, -1.0f, -1.0f); } self.translucencyView.hidden = NO; self.translucencyView.userInteractionEnabled = NO; self.translucencyView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; [self addSubview:self.translucencyView]; self.foregroundContainerView = [[UIView alloc] initWithFrame:(CGRect){0,0,200,200}]; self.foregroundContainerView.clipsToBounds = YES; self.foregroundContainerView.userInteractionEnabled = NO; [self addSubview:self.foregroundContainerView]; self.gridOverlayView = [[TOCropOverlayView alloc] initWithFrame:self.foregroundContainerView.frame]; self.gridOverlayView.userInteractionEnabled = NO; self.gridOverlayView.gridHidden = YES; [self addSubview:self.gridOverlayView]; self.foregroundImageView = [[UIImageView alloc] initWithImage:self.image]; [self.foregroundContainerView addSubview:self.foregroundImageView]; self.gridPanGestureRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(gridPanGestureRecognized:)]; self.gridPanGestureRecognizer.delegate = self; [self.scrollView.panGestureRecognizer requireGestureRecognizerToFail:self.gridPanGestureRecognizer]; [self addGestureRecognizer:self.gridPanGestureRecognizer]; self.editing = NO; } #pragma mark - View Layout - - (void)didMoveToSuperview { [super didMoveToSuperview]; [self layoutInitialImage]; } - (void)layoutInitialImage { CGSize imageSize = self.imageSize; self.scrollView.contentSize = imageSize; CGRect bounds = self.contentBounds; //work out the max and min scale of the image CGFloat scale = MIN(CGRectGetWidth(bounds)/imageSize.width, CGRectGetHeight(bounds)/imageSize.height); CGSize scaledSize = (CGSize){floorf(imageSize.width * scale), floorf(imageSize.height * scale)}; self.scrollView.minimumZoomScale = scale; self.scrollView.maximumZoomScale = 15.0f; //set the fully zoomed out state initially self.scrollView.zoomScale = self.scrollView.minimumZoomScale; self.scrollView.contentSize = scaledSize; //Relayout the image in the scroll view CGRect frame = CGRectZero; frame.size = scaledSize; frame.origin.x = bounds.origin.x + floorf((CGRectGetWidth(bounds) - frame.size.width) * 0.5f); frame.origin.y = bounds.origin.y + floorf((CGRectGetHeight(bounds) - frame.size.height) * 0.5f); self.cropBoxFrame = frame; //save the current state for use with 90-degree rotations self.cropBoxLastEditedSize = self.cropBoxFrame.size; self.cropBoxLastEditedAngle = 0; //save the size for checking if we're in a resettable state self.originalCropBoxSize = self.cropBoxFrame.size; [self matchForegroundToBackground]; } - (void)prepareforRotation { self.rotationContentOffset = self.scrollView.contentOffset; self.rotationContentSize = self.scrollView.contentSize; } - (void)performRelayoutForRotation { CGRect cropFrame = self.cropBoxFrame; CGRect contentFrame = self.contentBounds; //Work out the portion of the image we were focused on CGPoint cropMidPoint = (CGPoint){CGRectGetMidX(cropFrame), CGRectGetMidY(cropFrame)}; CGPoint cropTargetPoint = (CGPoint){cropMidPoint.x + self.rotationContentOffset.x, cropMidPoint.y + self.rotationContentOffset.y}; CGFloat scale = MIN(contentFrame.size.width / cropFrame.size.width, contentFrame.size.height / cropFrame.size.height); self.scrollView.minimumZoomScale *= scale; self.scrollView.zoomScale *= scale; //Work out the centered, upscaled version of the crop rectangle cropFrame.size.width = floorf(cropFrame.size.width * scale); cropFrame.size.height = floorf(cropFrame.size.height * scale); cropFrame.origin.x = floorf(contentFrame.origin.x + ((contentFrame.size.width - cropFrame.size.width) * 0.5f)); cropFrame.origin.y = floorf(contentFrame.origin.y + ((contentFrame.size.height - cropFrame.size.height) * 0.5f)); self.cropBoxFrame = cropFrame; self.cropBoxLastEditedSize = self.cropBoxFrame.size; //work out how to line up out point of interest into the middle of the crop box cropTargetPoint.x *= scale; cropTargetPoint.y *= scale; CGPoint midPoint = {floorf(CGRectGetMidX(cropFrame)), floorf(CGRectGetMidY(cropFrame))}; CGPoint offset = CGPointZero; offset.x = floorf(-midPoint.x + cropTargetPoint.x); offset.y = floorf(-midPoint.y + cropTargetPoint.y); offset.x = MAX(-self.scrollView.contentInset.left, offset.x); offset.y = MAX(-self.scrollView.contentInset.top, offset.y); self.scrollView.contentOffset = offset; [self matchForegroundToBackground]; } - (void)matchForegroundToBackground { //We can't simply match the frames since if the images are rotated, the frame property becomes unusable self.foregroundImageView.frame = [self.backgroundContainerView.superview convertRect:self.backgroundContainerView.frame toView:self.foregroundContainerView]; } - (void)updateCropBoxFrameWithGesturePoint:(CGPoint)point { CGRect frame = self.cropBoxFrame; CGRect originFrame = self.cropOriginFrame; CGRect contentFrame = self.contentBounds; point.x = MAX(contentFrame.origin.x, point.x); point.y = MAX(contentFrame.origin.y, point.y); //The delta between where we first tapped, and where our finger is now CGFloat xDelta = ceilf(point.x - self.panOriginPoint.x); CGFloat yDelta = ceilf(point.y - self.panOriginPoint.y); //Current aspect ratio of the crop box in case we need to clamp it CGFloat aspectRatio = (originFrame.size.width / originFrame.size.height); BOOL aspectHorizontal = NO, aspectVertical = NO; switch (self.tappedEdge) { case TOCropViewOverlayEdgeLeft: if (self.aspectLockEnabled) { aspectHorizontal = YES; xDelta = MAX(xDelta, 0); CGPoint scaleOrigin = (CGPoint){CGRectGetMaxX(originFrame), CGRectGetMidY(originFrame)}; frame.size.height = frame.size.width / aspectRatio; frame.origin.y = scaleOrigin.y - (frame.size.height * 0.5f); } frame.origin.x = originFrame.origin.x + xDelta; frame.size.width = originFrame.size.width - xDelta; break; case TOCropViewOverlayEdgeRight: if (self.aspectLockEnabled) { aspectHorizontal = YES; CGPoint scaleOrigin = (CGPoint){CGRectGetMinX(originFrame), CGRectGetMidY(originFrame)}; frame.size.height = frame.size.width / aspectRatio; frame.origin.y = scaleOrigin.y - (frame.size.height * 0.5f); frame.size.width = originFrame.size.width + xDelta; frame.size.width = MIN(frame.size.width, contentFrame.size.height * aspectRatio); } else { frame.size.width = originFrame.size.width + xDelta; } break; case TOCropViewOverlayEdgeBottom: if (self.aspectLockEnabled) { aspectVertical = YES; CGPoint scaleOrigin = (CGPoint){CGRectGetMidX(originFrame), CGRectGetMinY(originFrame)}; frame.size.width = frame.size.height * aspectRatio; frame.origin.x = scaleOrigin.x - (frame.size.width * 0.5f); frame.size.height = originFrame.size.height + yDelta; frame.size.height = MIN(frame.size.height, contentFrame.size.width / aspectRatio); } else { frame.size.height = originFrame.size.height + yDelta; } break; case TOCropViewOverlayEdgeTop: if (self.aspectLockEnabled) { aspectVertical = YES; yDelta = MAX(0,yDelta); CGPoint scaleOrigin = (CGPoint){CGRectGetMidX(originFrame), CGRectGetMaxY(originFrame)}; frame.size.width = frame.size.height * aspectRatio; frame.origin.x = scaleOrigin.x - (frame.size.width * 0.5f); frame.origin.y = originFrame.origin.y + yDelta; frame.size.height = originFrame.size.height - yDelta; } else { frame.origin.y = originFrame.origin.y + yDelta; frame.size.height = originFrame.size.height - yDelta; } break; case TOCropViewOverlayEdgeTopLeft: if (self.aspectLockEnabled) { xDelta = MAX(xDelta, 0); yDelta = MAX(yDelta, 0); CGPoint distance; distance.x = 1.0f - (xDelta / CGRectGetWidth(originFrame)); distance.y = 1.0f - (yDelta / CGRectGetHeight(originFrame)); CGFloat scale = (distance.x + distance.y) * 0.5f; frame.size.width = ceilf(CGRectGetWidth(originFrame) * scale); frame.size.height = ceilf(CGRectGetHeight(originFrame) * scale); frame.origin.x = originFrame.origin.x + (CGRectGetWidth(originFrame) - frame.size.width); frame.origin.y = originFrame.origin.y + (CGRectGetHeight(originFrame) - frame.size.height); aspectVertical = YES; aspectHorizontal = YES; } else { frame.origin.x = originFrame.origin.x + xDelta; frame.size.width = originFrame.size.width - xDelta; frame.origin.y = originFrame.origin.y + yDelta; frame.size.height = originFrame.size.height - yDelta; } break; case TOCropViewOverlayEdgeTopRight: if (self.aspectLockEnabled) { xDelta = MAX(xDelta, 0); yDelta = MAX(yDelta, 0); CGPoint distance; distance.x = 1.0f - ((-xDelta) / CGRectGetWidth(originFrame)); distance.y = 1.0f - ((yDelta) / CGRectGetHeight(originFrame)); CGFloat scale = (distance.x + distance.y) * 0.5f; scale = MIN(1.0f, scale); frame.size.width = ceilf(CGRectGetWidth(originFrame) * scale); frame.size.height = ceilf(CGRectGetHeight(originFrame) * scale); frame.origin.y = CGRectGetMaxY(originFrame) - frame.size.height; aspectVertical = YES; aspectHorizontal = YES; } else { frame.size.width = originFrame.size.width + xDelta; frame.origin.y = originFrame.origin.y + yDelta; frame.size.height = originFrame.size.height - yDelta; } break; case TOCropViewOverlayEdgeBottomLeft: if (self.aspectLockEnabled) { CGPoint distance; distance.x = 1.0f - (xDelta / CGRectGetWidth(originFrame)); distance.y = 1.0f - (-yDelta / CGRectGetHeight(originFrame)); CGFloat scale = (distance.x + distance.y) * 0.5f; frame.size.width = ceilf(CGRectGetWidth(originFrame) * scale); frame.size.height = ceilf(CGRectGetHeight(originFrame) * scale); frame.origin.x = CGRectGetMaxX(originFrame) - frame.size.width; aspectVertical = YES; aspectHorizontal = YES; } else { frame.size.height = originFrame.size.height + yDelta; frame.origin.x = originFrame.origin.x + xDelta; frame.size.width = originFrame.size.width - xDelta; } break; case TOCropViewOverlayEdgeBottomRight: if (self.aspectLockEnabled) { CGPoint distance; distance.x = 1.0f - ((-1 * xDelta) / CGRectGetWidth(originFrame)); distance.y = 1.0f - ((-1 * yDelta) / CGRectGetHeight(originFrame)); CGFloat scale = (distance.x + distance.y) * 0.5f; frame.size.width = ceilf(CGRectGetWidth(originFrame) * scale); frame.size.height = ceilf(CGRectGetHeight(originFrame) * scale); aspectVertical = YES; aspectHorizontal = YES; } else { frame.size.height = originFrame.size.height + yDelta; frame.size.width = originFrame.size.width + xDelta; } break; case TOCropViewOverlayEdgeNone: break; } //Work out the limits the box may be scaled before it starts to overlap itself CGSize minSize = CGSizeZero; minSize.width = kTOCropViewMinimumBoxSize; minSize.height = kTOCropViewMinimumBoxSize; CGSize maxSize = CGSizeZero; maxSize.width = CGRectGetWidth(contentFrame); maxSize.height = CGRectGetHeight(contentFrame); //clamp the box to ensure it doesn't go beyond the bounds we've set if (self.aspectLockEnabled && aspectHorizontal) { maxSize.height = contentFrame.size.width / aspectRatio; minSize.width = kTOCropViewMinimumBoxSize * aspectRatio; } if (self.aspectLockEnabled && aspectVertical) { maxSize.width = contentFrame.size.height * aspectRatio; minSize.height = kTOCropViewMinimumBoxSize / aspectRatio; } //Clamp the minimum size frame.size.width = MAX(frame.size.width, minSize.width); frame.size.height = MAX(frame.size.height, minSize.height); //Clamp the maximum size frame.size.width = MIN(frame.size.width, maxSize.width); frame.size.height = MIN(frame.size.height, maxSize.height); frame.origin.x = MAX(frame.origin.x, CGRectGetMinX(contentFrame)); frame.origin.x = MIN(frame.origin.x, CGRectGetMaxX(contentFrame) - minSize.width); frame.origin.y = MAX(frame.origin.y, CGRectGetMinY(contentFrame)); frame.origin.y = MIN(frame.origin.y, CGRectGetMaxY(contentFrame) - minSize.height); self.cropBoxFrame = frame; [self checkForCanReset]; } - (void)resetLayoutToDefaultAnimated:(BOOL)animated { if (animated == NO || self.angle < 0) { self.angle = 0; self.foregroundImageView.transform = CGAffineTransformIdentity; self.backgroundImageView.transform = CGAffineTransformIdentity; self.scrollView.zoomScale = 1.0f; self.backgroundContainerView.frame = (CGRect){CGPointZero, self.backgroundImageView.frame.size}; self.backgroundImageView.frame = self.backgroundContainerView.frame; self.foregroundImageView.frame = self.backgroundContainerView.frame; [self layoutInitialImage]; [self checkForCanReset]; return; } [UIView animateWithDuration:0.5f delay:0.0f usingSpringWithDamping:1.0f initialSpringVelocity:0.7f options:0 animations:^{ [self layoutInitialImage]; [self checkForCanReset]; } completion:nil]; } #pragma mark - Gesture Recognizer - - (void)gridPanGestureRecognized:(UIPanGestureRecognizer *)recognizer { CGPoint point = [recognizer locationInView:self]; if (recognizer.state == UIGestureRecognizerStateBegan) { [self startEditing]; self.panOriginPoint = point; self.cropOriginFrame = self.cropBoxFrame; self.tappedEdge = [self cropEdgeForPoint:self.panOriginPoint]; } if (recognizer.state == UIGestureRecognizerStateEnded) [self startResetTimer]; [self updateCropBoxFrameWithGesturePoint:point]; } - (void)longPressGestureRecognized:(UILongPressGestureRecognizer *)recognizer { if (recognizer.state == UIGestureRecognizerStateBegan) [self.gridOverlayView setGridHidden:NO animated:YES]; if (recognizer.state == UIGestureRecognizerStateEnded) [self.gridOverlayView setGridHidden:YES animated:YES]; } - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer { if (gestureRecognizer != self.gridPanGestureRecognizer) return YES; CGPoint tapPoint = [gestureRecognizer locationInView:self]; CGRect frame = self.gridOverlayView.frame; CGRect innerFrame = CGRectInset(frame, 22.0f, 22.0f); CGRect outerFrame = CGRectInset(frame, -22.0f, -22.0f); if (CGRectContainsPoint(innerFrame, tapPoint) || !CGRectContainsPoint(outerFrame, tapPoint)) return NO; return YES; } #pragma mark - Timer - - (void)startResetTimer { if (self.resetTimer) return; self.resetTimer = [NSTimer scheduledTimerWithTimeInterval:kTOCropTimerDuration target:self selector:@selector(timerTriggered) userInfo:nil repeats:NO]; } - (void)timerTriggered { [self setEditing:NO animated:YES]; [self.resetTimer invalidate]; self.resetTimer = nil; } - (void)cancelResetTimer { [self.resetTimer invalidate]; self.resetTimer = nil; } - (TOCropViewOverlayEdge)cropEdgeForPoint:(CGPoint)point { CGRect frame = self.cropBoxFrame; //account for padding around the box frame = CGRectInset(frame, -22.0f, -22.0f); //Make sure the corners take priority CGRect topLeftRect = (CGRect){frame.origin, {44,44}}; if (CGRectContainsPoint(topLeftRect, point)) return TOCropViewOverlayEdgeTopLeft; CGRect topRightRect = topLeftRect; topRightRect.origin.x = CGRectGetMaxX(frame) - 44.0f; if (CGRectContainsPoint(topRightRect, point)) return TOCropViewOverlayEdgeTopRight; CGRect bottomLeftRect = topLeftRect; bottomLeftRect.origin.y = CGRectGetMaxY(frame) - 44.0f; if (CGRectContainsPoint(bottomLeftRect, point)) return TOCropViewOverlayEdgeBottomLeft; CGRect bottomRightRect = topRightRect; bottomRightRect.origin.y = bottomLeftRect.origin.y; if (CGRectContainsPoint(bottomRightRect, point)) return TOCropViewOverlayEdgeBottomRight; //Check for edges CGRect topRect = (CGRect){frame.origin, {CGRectGetWidth(frame), 44.0f}}; if (CGRectContainsPoint(topRect, point)) return TOCropViewOverlayEdgeTop; CGRect bottomRect = topRect; bottomRect.origin.y = CGRectGetMaxY(frame) - 44.0f; if (CGRectContainsPoint(bottomRect, point)) return TOCropViewOverlayEdgeBottom; CGRect leftRect = (CGRect){frame.origin, {44.0f, CGRectGetHeight(frame)}}; if (CGRectContainsPoint(leftRect, point)) return TOCropViewOverlayEdgeLeft; CGRect rightRect = leftRect; rightRect.origin.x = CGRectGetMaxX(frame) - 44.0f; if (CGRectContainsPoint(rightRect, point)) return TOCropViewOverlayEdgeRight; return TOCropViewOverlayEdgeNone; } #pragma mark - Scroll View Delegate - - (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView { return self.backgroundContainerView; } - (void)scrollViewDidScroll:(UIScrollView *)scrollView { [self matchForegroundToBackground]; } - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView { [self startEditing]; } - (void)scrollViewWillBeginZooming:(UIScrollView *)scrollView withView:(UIView *)view { [self startEditing]; } - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { [self startResetTimer]; } - (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(CGFloat)scale { [self startResetTimer]; } - (void)scrollViewDidZoom:(UIScrollView *)scrollView { [self checkForCanReset]; [self matchForegroundToBackground]; } - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate { if (!decelerate) [self startResetTimer]; } #pragma mark - Accessors - - (void)setCropBoxFrame:(CGRect)cropBoxFrame { if (CGRectEqualToRect(cropBoxFrame, _cropBoxFrame)) return; //Upon init, sometimes the box size is still 0, which can result in CALayer issues if (cropBoxFrame.size.width < FLT_EPSILON || cropBoxFrame.size.height < FLT_EPSILON) return; //clamp the cropping region to the inset boundaries of the screen CGRect contentFrame = self.contentBounds; CGFloat xOrigin = ceilf(contentFrame.origin.x); CGFloat xDelta = cropBoxFrame.origin.x - xOrigin; cropBoxFrame.origin.x = floorf(MAX(cropBoxFrame.origin.x, xOrigin)); if (xDelta < -FLT_EPSILON) //If we clamp the x value, ensure we compensate for the subsequent delta generated in the width (Or else, the box will keep growing) cropBoxFrame.size.width += xDelta; CGFloat yOrigin = ceilf(contentFrame.origin.y); CGFloat yDelta = cropBoxFrame.origin.y - yOrigin; cropBoxFrame.origin.y = floorf(MAX(cropBoxFrame.origin.y, yOrigin)); if (yDelta < -FLT_EPSILON) cropBoxFrame.size.height += yDelta; //given the clamped X/Y values, make sure we can't extend the crop box beyond the edge of the screen in the current state CGFloat maxWidth = (contentFrame.size.width + contentFrame.origin.x) - cropBoxFrame.origin.x; cropBoxFrame.size.width = floorf(MIN(cropBoxFrame.size.width, maxWidth)); CGFloat maxHeight = (contentFrame.size.height + contentFrame.origin.y) - cropBoxFrame.origin.y; cropBoxFrame.size.height = floorf(MIN(cropBoxFrame.size.height, maxHeight)); //Make sure we can't make the crop box too small cropBoxFrame.size.width = MAX(cropBoxFrame.size.width, kTOCropViewMinimumBoxSize); cropBoxFrame.size.height = MAX(cropBoxFrame.size.height, kTOCropViewMinimumBoxSize); _cropBoxFrame = cropBoxFrame; self.foregroundContainerView.frame = _cropBoxFrame; //set the clipping view to match the new rect self.gridOverlayView.frame = _cropBoxFrame; //set the new overlay view to match the same region //reset the scroll view insets to match the region of the new crop rect self.scrollView.contentInset = (UIEdgeInsets){CGRectGetMinY(_cropBoxFrame), CGRectGetMinX(_cropBoxFrame), CGRectGetMaxY(self.bounds) - CGRectGetMaxY(_cropBoxFrame), CGRectGetMaxX(self.bounds) - CGRectGetMaxX(_cropBoxFrame)}; //if necessary, work out the new minimum size of the scroll view so it fills the crop box CGSize imageSize = self.backgroundContainerView.bounds.size; CGFloat scale = MAX(cropBoxFrame.size.height/imageSize.height, cropBoxFrame.size.width/imageSize.width); self.scrollView.minimumZoomScale = scale; //make sure content isn't smaller than the crop box CGSize size = self.scrollView.contentSize; size.width = floorf(size.width); size.height = floorf(size.height); //self.backgroundContainerView.frame = (CGRect){CGPointZero, size}; self.scrollView.contentSize = size;\ //IMPORTANT: Force the scroll view to update its content after changing the zoom scale self.scrollView.zoomScale = self.scrollView.zoomScale; [self matchForegroundToBackground]; //re-align the background content to match } - (void)setEditing:(BOOL)editing { [self setEditing:editing animated:NO]; } - (void)setSimpleMode:(BOOL)simpleMode { [self setSimpleMode:simpleMode animated:NO]; } - (BOOL)cropBoxAspectRatioIsPortrait { CGRect cropFrame = self.cropBoxFrame; return CGRectGetWidth(cropFrame) < CGRectGetHeight(cropFrame); } - (CGRect)croppedImageFrame { CGSize imageSize = self.imageSize; CGSize contentSize = self.scrollView.contentSize; CGRect cropBoxFrame = self.cropBoxFrame; CGPoint contentOffset = self.scrollView.contentOffset; UIEdgeInsets edgeInsets = self.scrollView.contentInset; CGRect frame = CGRectZero; frame.origin.x = floorf((contentOffset.x + edgeInsets.left) * (imageSize.width / contentSize.width)); frame.origin.x = MAX(0, frame.origin.x); frame.origin.y = floorf((contentOffset.y + edgeInsets.top) * (imageSize.height / contentSize.height)); frame.origin.y = MAX(0, frame.origin.y); frame.size.width = ceilf(cropBoxFrame.size.width * (imageSize.width / contentSize.width)); frame.size.width = MIN(imageSize.width, frame.size.width); frame.size.height = ceilf(cropBoxFrame.size.height * (imageSize.height / contentSize.height)); frame.size.height = MIN(imageSize.height, frame.size.height); return frame; } - (void)setCroppingViewsHidden:(BOOL)hidden { [self setCroppingViewsHidden:hidden animated:NO]; } - (void)setCroppingViewsHidden:(BOOL)hidden animated:(BOOL)animated { if (_croppingViewsHidden == hidden) return; _croppingViewsHidden = hidden; CGFloat alpha = hidden ? 0.0f : 1.0f; if (animated == NO) { self.backgroundImageView.alpha = alpha; self.translucencyView.alpha = alpha; self.foregroundContainerView.alpha = alpha; self.gridOverlayView.alpha = alpha; return; } self.foregroundContainerView.alpha = alpha; self.backgroundImageView.alpha = alpha; [UIView animateWithDuration:0.5f animations:^{ self.translucencyView.alpha = alpha; self.gridOverlayView.alpha = alpha; }]; } - (void)setGridOverlayHidden:(BOOL)gridOverlayHidden { [self setGridOverlayHidden:_gridOverlayHidden animated:NO]; } - (void)setGridOverlayHidden:(BOOL)gridOverlayHidden animated:(BOOL)animated { _gridOverlayHidden = gridOverlayHidden; self.gridOverlayView.alpha = gridOverlayHidden ? 1.0f : 0.0f; [UIView animateWithDuration:0.4f animations:^{ self.gridOverlayView.alpha = gridOverlayHidden ? 0.0f : 1.0f; }]; } - (CGRect)imageViewFrame { CGRect frame = CGRectZero; frame.origin.x = -self.scrollView.contentOffset.x; frame.origin.y = -self.scrollView.contentOffset.y; frame.size = self.scrollView.contentSize; return frame; } #pragma mark - Editing Mode - - (void)startEditing { [self cancelResetTimer]; [self setEditing:YES animated:YES]; } - (void)setEditing:(BOOL)editing animated:(BOOL)animated { if (editing == _editing) return; _editing = editing; [self.gridOverlayView setGridHidden:!editing animated:animated]; if (editing == NO) { [self moveCroppedContentToCenterAnimated:animated]; self.cropBoxLastEditedSize = self.cropBoxFrame.size; self.cropBoxLastEditedAngle = self.angle; } if (animated == NO) { self.translucencyView.alpha = editing ? 0.0f : 1.0f; return; } [UIView animateWithDuration:editing?0.2f:0.35f animations:^{ self.translucencyView.alpha = editing ? 0.0f : 1.0f; }]; } - (void)moveCroppedContentToCenterAnimated:(BOOL)animated { if (self.simpleMode) return; CGRect contentRect = self.contentBounds; CGRect cropFrame = self.cropBoxFrame; CGPoint focusPoint = (CGPoint){CGRectGetMidX(cropFrame), CGRectGetMidY(cropFrame)}; CGPoint midPoint = (CGPoint){CGRectGetMidX(contentRect), CGRectGetMidY(contentRect)}; //The scale we need to scale up the crop box to fit full screen CGFloat scale = MIN(CGRectGetWidth(contentRect)/CGRectGetWidth(cropFrame), CGRectGetHeight(contentRect)/CGRectGetHeight(cropFrame)); cropFrame.size.width = floorf(cropFrame.size.width * scale); cropFrame.size.height = floorf(cropFrame.size.height * scale); cropFrame.origin.x = contentRect.origin.x + floorf((contentRect.size.width - cropFrame.size.width) * 0.5f); cropFrame.origin.y = contentRect.origin.y + floorf((contentRect.size.height - cropFrame.size.height) * 0.5f); //Work out the point on the scroll content that the focusPoint is aiming at CGPoint contentTargetPoint = CGPointZero; contentTargetPoint.x = ((focusPoint.x + self.scrollView.contentOffset.x) * scale); contentTargetPoint.y = ((focusPoint.y + self.scrollView.contentOffset.y) * scale); //Work out where the crop box is focusing, so we can re-align to center that point __block CGPoint offset = CGPointZero; offset.x = -midPoint.x + contentTargetPoint.x; offset.y = -midPoint.y + contentTargetPoint.y; //clamp the content so it doesn't create any seams around the grid offset.x = MAX(-cropFrame.origin.x, offset.x); offset.y = MAX(-cropFrame.origin.y, offset.y); __weak typeof(self) weakSelf = self; void (^translateBlock)() = ^{ typeof(self) strongSelf = weakSelf; strongSelf.scrollView.zoomScale *= scale; offset.x = MIN(-CGRectGetMaxX(cropFrame)+strongSelf.scrollView.contentSize.width, offset.x); offset.y = MIN(-CGRectGetMaxY(cropFrame)+strongSelf.scrollView.contentSize.height, offset.y); strongSelf.scrollView.contentOffset = offset; strongSelf.cropBoxFrame = cropFrame; }; if (!animated) { translateBlock(); return; } [UIView animateWithDuration:0.5f delay:0.0f usingSpringWithDamping:1.0f initialSpringVelocity:1.0f options:0 animations:translateBlock completion:nil]; } - (void)setSimpleMode:(BOOL)simpleMode animated:(BOOL)animated { if (simpleMode == _simpleMode) return; _simpleMode = simpleMode; self.editing = NO; if (animated == NO) { self.translucencyView.alpha = simpleMode ? 0.0f : 1.0f; return; } [UIView animateWithDuration:0.35f animations:^{ self.translucencyView.alpha = simpleMode ? 0.0f : 1.0f; }]; } - (void)setAspectLockEnabledWithAspectRatio:(CGSize)aspectRatio animated:(BOOL)animated { if (aspectRatio.width < FLT_EPSILON && aspectRatio.height < FLT_EPSILON) aspectRatio = (CGSize){self.imageSize.width, self.imageSize.height}; CGRect boundsFrame = self.contentBounds; CGRect cropBoxFrame = self.cropBoxFrame; CGPoint offset = self.scrollView.contentOffset; BOOL cropBoxIsPortrait = NO; if ((NSInteger)aspectRatio.width == 1 && (NSInteger)aspectRatio.height == 1) cropBoxIsPortrait = self.image.size.width > self.image.size.height; else cropBoxIsPortrait = aspectRatio.width < aspectRatio.height; BOOL zoomOut = NO; if (cropBoxIsPortrait) { CGFloat newWidth = cropBoxFrame.size.height * (aspectRatio.width/aspectRatio.height); CGFloat delta = cropBoxFrame.size.width - newWidth; cropBoxFrame.size.width = newWidth; offset.x += (delta * 0.5f); CGFloat boundsWidth = CGRectGetWidth(boundsFrame); if (newWidth > boundsWidth) { CGFloat scale = boundsWidth / newWidth; cropBoxFrame.size.height *= scale; cropBoxFrame.size.width = boundsWidth; zoomOut = YES; } } else { CGFloat newHeight = cropBoxFrame.size.width * (aspectRatio.height/aspectRatio.width); CGFloat delta = cropBoxFrame.size.height - newHeight; cropBoxFrame.size.height = newHeight; offset.y += (delta * 0.5f); CGFloat boundsHeight = CGRectGetHeight(boundsFrame); if (newHeight > boundsHeight) { CGFloat scale = boundsHeight / newHeight; cropBoxFrame.size.width *= scale; cropBoxFrame.size.height = boundsHeight; zoomOut = YES; } } self.aspectLockEnabled = YES; if (animated == NO) { self.scrollView.contentOffset = offset; self.cropBoxFrame = cropBoxFrame; if (zoomOut) self.scrollView.zoomScale = self.scrollView.minimumZoomScale; [self moveCroppedContentToCenterAnimated:NO]; [self checkForCanReset]; return; } [UIView animateWithDuration:0.5f delay:0.0 usingSpringWithDamping:1.0f initialSpringVelocity:0.7f options:0 animations:^{ self.scrollView.contentOffset = offset; self.cropBoxFrame = cropBoxFrame; [self checkForCanReset]; if (zoomOut) self.scrollView.zoomScale = self.scrollView.minimumZoomScale; [self moveCroppedContentToCenterAnimated:NO]; } completion:nil]; } - (void)rotateImageNinetyDegreesAnimated:(BOOL)animated { //Only allow one rotation animation at a time if (self.rotateAnimationInProgress) return; //Cancel any pending resizing timers if (self.resetTimer) { [self cancelResetTimer]; [self.gridOverlayView setGridHidden:YES]; [self moveCroppedContentToCenterAnimated:NO]; self.cropBoxLastEditedAngle = self.angle; self.cropBoxLastEditedSize = self.cropBoxFrame.size; } //Work out the new angle, and wrap around once we exceed 360s NSInteger newAngle = self.angle; newAngle -= 90; if (newAngle <= -360) newAngle = 0; self.angle = newAngle; //Convert the new angle to radians CGFloat angleInRadians = 0.0f; switch (newAngle) { case -90: angleInRadians = M_PI_2; break; case -180: angleInRadians = M_PI; break; case -270: angleInRadians = (M_PI + M_PI_2); break; default: angleInRadians = (M_PI * 2); break; } // Set up the transformation matrix for the rotation CGAffineTransform rotation = CGAffineTransformRotate(CGAffineTransformIdentity, -angleInRadians); //Work out how much we'll need to scale everything to fit to the new rotation CGRect contentBounds = self.contentBounds; CGRect cropBoxFrame = self.cropBoxFrame; CGFloat scale = MIN(contentBounds.size.width / cropBoxFrame.size.height, contentBounds.size.height / cropBoxFrame.size.width); //Work out which section of the image we're currently focusing at CGPoint cropMidPoint = (CGPoint){CGRectGetMidX(cropBoxFrame), CGRectGetMidY(cropBoxFrame)}; CGPoint cropTargetPoint = (CGPoint){cropMidPoint.x + self.scrollView.contentOffset.x, cropMidPoint.y + self.scrollView.contentOffset.y}; //Work out the dimensions of the crop box when rotated CGRect newCropFrame = CGRectZero; if (self.angle == self.cropBoxLastEditedAngle || self.angle == ((self.cropBoxLastEditedAngle - 180) % 360)) { newCropFrame.size = self.cropBoxLastEditedSize; } else { newCropFrame.size = (CGSize){floorf(self.cropBoxLastEditedSize.height * scale), floorf(self.cropBoxLastEditedSize.width * scale)}; //update last edited size self.cropBoxLastEditedSize = cropBoxFrame.size; } newCropFrame.origin.x = floorf((CGRectGetWidth(self.bounds) - newCropFrame.size.width) * 0.5f); newCropFrame.origin.y = floorf((CGRectGetHeight(self.bounds) - newCropFrame.size.height) * 0.5f); //If we're animated, generate a snapshot view that we'll animate in place of the real view UIView *snapshotView = nil; if (animated) { snapshotView = [self.foregroundContainerView snapshotViewAfterScreenUpdates:NO]; self.rotateAnimationInProgress = YES; } //Re-adjust the scrolling dimensions of the scroll view to match the new size self.scrollView.minimumZoomScale *= scale; self.scrollView.zoomScale *= scale; //Rotate the background image view, inside its container view self.backgroundImageView.transform = rotation; //Flip the width/height of the container view so it matches the rotated image view's size CGSize containerSize = self.backgroundContainerView.frame.size; self.backgroundContainerView.frame = (CGRect){CGPointZero, {containerSize.height, containerSize.width}}; self.backgroundImageView.frame = (CGRect){CGPointZero, self.backgroundImageView.frame.size}; //Rotate the foreground image view to match self.foregroundContainerView.transform = CGAffineTransformIdentity; self.foregroundImageView.transform = rotation; //Flip the content size of the scroll view to match the rotated bounds self.scrollView.contentSize = self.backgroundContainerView.frame.size; //assign the new crop box frame and re-adjust the content to fill it self.cropBoxFrame = newCropFrame; [self moveCroppedContentToCenterAnimated:NO]; newCropFrame = self.cropBoxFrame; //work out how to line up out point of interest into the middle of the crop box cropTargetPoint.x *= scale; cropTargetPoint.y *= scale; //swap the target dimensions to match a -90 degree rotation CGFloat swap = cropTargetPoint.x; cropTargetPoint.x = cropTargetPoint.y; cropTargetPoint.y = self.scrollView.contentSize.height - swap; //reapply the translated scroll offset to the scroll view CGPoint midPoint = {CGRectGetMidX(newCropFrame), CGRectGetMidY(newCropFrame)}; CGPoint offset = CGPointZero; offset.x = floorf(-midPoint.x + cropTargetPoint.x); offset.y = floorf(-midPoint.y + cropTargetPoint.y); offset.x = MAX(-self.scrollView.contentInset.left, offset.x); offset.y = MAX(-self.scrollView.contentInset.top, offset.y); //if the scroll view's new scale is 1 and the new offset is equal to the old, will not trigger the delegate 'scrollViewDidScroll:' //so we should call the method manually to update the foregroundImageView's frame if (offset.x == self.scrollView.contentOffset.x && offset.y == self.scrollView.contentOffset.y && scale == 1) { [self matchForegroundToBackground]; } self.scrollView.contentOffset = offset; //If we're animated, play an animation of the snapshot view rotating, //then fade it out over the live content if (animated) { snapshotView.center = self.scrollView.center; [self addSubview:snapshotView]; self.backgroundContainerView.hidden = YES; self.foregroundContainerView.hidden = YES; self.translucencyView.hidden = YES; self.gridOverlayView.hidden = YES; [UIView animateWithDuration:0.45f delay:0.0f usingSpringWithDamping:1.0f initialSpringVelocity:0.8f options:0 animations:^{ CGAffineTransform transform = CGAffineTransformRotate(CGAffineTransformIdentity, -M_PI_2); transform = CGAffineTransformScale(transform, scale, scale); snapshotView.transform = transform; } completion:^(BOOL complete) { self.backgroundContainerView.hidden = NO; self.foregroundContainerView.hidden = NO; self.translucencyView.hidden = NO; self.gridOverlayView.hidden = NO; self.backgroundContainerView.alpha = 0.0f; self.gridOverlayView.alpha = 0.0f; self.translucencyView.alpha = 1.0f; [UIView animateWithDuration:0.45f animations:^{ snapshotView.alpha = 0.0f; self.backgroundContainerView.alpha = 1.0f; self.gridOverlayView.alpha = 1.0f; } completion:^(BOOL complete) { self.rotateAnimationInProgress = NO; [snapshotView removeFromSuperview]; }]; }]; } [self checkForCanReset]; } #pragma mark - Resettable State - - (void)checkForCanReset { BOOL canReset = NO; if (self.angle != 0) { //Image has been rotated canReset = YES; } else if (self.scrollView.zoomScale > self.scrollView.minimumZoomScale + FLT_EPSILON) { //image has been zoomed in canReset = YES; } else if ((NSInteger)floorf(self.cropBoxFrame.size.width) != (NSInteger)floorf(self.originalCropBoxSize.width) || (NSInteger)floorf(self.cropBoxFrame.size.height) != (NSInteger)floorf(self.originalCropBoxSize.height)) { //crop box has been changed canReset = YES; } if (canReset && self.canReset == NO) { self.canReset = YES; if ([self.delegate respondsToSelector:@selector(cropViewDidBecomeResettable:)]) [self.delegate cropViewDidBecomeResettable:self]; } else if (!canReset && self.canReset) { self.canReset = NO; if ([self.delegate respondsToSelector:@selector(cropViewDidBecomeNonResettable:)]) [self.delegate cropViewDidBecomeNonResettable:self]; } } #pragma mark - Convienience Methods - - (CGRect)contentBounds { CGRect contentRect = CGRectZero; contentRect.origin.x = kTOCropViewPadding + self.cropRegionInsets.left; contentRect.origin.y = kTOCropViewPadding + self.cropRegionInsets.top; contentRect.size.width = CGRectGetWidth(self.bounds) - ((kTOCropViewPadding * 2) + self.cropRegionInsets.left + self.cropRegionInsets.right); contentRect.size.height = CGRectGetHeight(self.bounds) - ((kTOCropViewPadding * 2) + self.cropRegionInsets.top + self.cropRegionInsets.bottom); return contentRect; } - (CGSize)imageSize { if (self.angle == -90 || self.angle == -270) return (CGSize){self.image.size.height, self.image.size.width}; return (CGSize){self.image.size.width, self.image.size.height}; } @end