Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ x.y.z Release Notes (yyyy-MM-dd)
## Fixed

- The Swift `CropViewController` framework failing to build under Xcode 26's module verifier.
- Setting `angle` to ±360 or beyond hung the app in an infinite loop, and direction changes such as 90° to -90° were silently ignored.
- The `gridOverlayHidden` property setter had no effect, and its animated variant ignored its animation flag.
- Content offset math when rotating 90° with a previously edited crop state, which could shift the view away from the focal region and leave blank space inside the crop box.
- The reported crop rectangle could overhang the image bounds by a point, exporting a black hairline column on opaque images.
- Zero-sized images and aspect ratios with a zero component no longer produce broken layout geometry.
- `rotateImageNinetyDegreesAnimated:clockwise:completion:` never called its completion handler when `animated` was NO, or when the call was dropped because a rotation was already playing. The latter could leave the toolbar's rotation buttons permanently disabled.
- The crop view was briefly assigned the frame for the opposite layout at the start of a device rotation, so the rotation animation interpolated from the wrong geometry (iOS 25 and below).

3.1.2 Release Notes (2026-04-07)

Expand Down
2 changes: 1 addition & 1 deletion Objective-C/TOCropViewController/TOCropViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ - (void)_willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOri
self.toolbar.alpha = 0.0f;

[self.cropView prepareforRotation];
self.cropView.frame = [self frameForCropViewWithVerticalLayout:!UIInterfaceOrientationIsPortrait(toInterfaceOrientation)];
self.cropView.frame = [self frameForCropViewWithVerticalLayout:UIInterfaceOrientationIsPortrait(toInterfaceOrientation)];
self.cropView.simpleRenderMode = YES;
self.cropView.internalLayoutDisabled = YES;
}
Expand Down
4 changes: 3 additions & 1 deletion Objective-C/TOCropViewController/Views/TOCropView.h
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, readonly) BOOL cropBoxAspectRatioIsPortrait;

/**
The rotation angle of the crop view (Will always be negative as it rotates in a counter-clockwise direction)
The rotation angle of the crop view, in multiples of 90 degrees. Counter-clockwise
rotations are negative and clockwise ones positive, so the value is in the
range (-360, 360). Values that aren't a multiple of 90 are treated as 0.
*/
@property (nonatomic, assign) NSInteger angle;

Expand Down
120 changes: 88 additions & 32 deletions Objective-C/TOCropViewController/Views/TOCropView.m
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ @interface TOCropView () <UIScrollViewDelegate, UIGestureRecognizerDelegate>
@property (nonatomic, assign) NSInteger cropBoxLastEditedAngle; /* Remember which angle we were at when we saved the editing size */
@property (nonatomic, assign) CGFloat cropBoxLastEditedZoomScale; /* Remember the zoom size when we last edited */
@property (nonatomic, assign) CGFloat cropBoxLastEditedMinZoomScale; /* Remember the minimum size when we last edited. */
@property (nonatomic, assign) CGFloat cropBoxLastEditedMaxZoomScale; /* Remember the zoom ceiling when we last edited. */
@property (nonatomic, assign) CGFloat baseMaximumZoomScale; /* The absolute zoom ceiling for the current layout; the scroll view's own maximum is always derived from this */
@property (nonatomic, assign) BOOL rotateAnimationInProgress; /* Disallow any input while the rotation animation is playing */

/* Reset state data */
Expand Down Expand Up @@ -267,6 +269,10 @@ - (void)performInitialSetup {

- (void)layoutInitialImage {
CGSize imageSize = self.imageSize;
// A zero-sized image would produce NaN geometry below, which crashes CALayer
if (imageSize.width < FLT_EPSILON || imageSize.height < FLT_EPSILON) {
return;
}
self.scrollView.contentSize = imageSize;

CGRect bounds = self.contentBounds;
Expand Down Expand Up @@ -295,7 +301,8 @@ - (void)layoutInitialImage {

// Configure the scroll view
self.scrollView.minimumZoomScale = scale;
self.scrollView.maximumZoomScale = scale * self.maximumZoomScale;
self.baseMaximumZoomScale = scale * self.maximumZoomScale;
[self updateScrollViewMaximumZoomScale];

// Set the crop box to the size we calculated and align in the middle of the screen
CGRect frame = CGRectZero;
Expand Down Expand Up @@ -341,6 +348,8 @@ - (void)performRelayoutForRotation {

CGFloat scale = MIN(contentFrame.size.width / cropFrame.size.width, contentFrame.size.height / cropFrame.size.height);
self.scrollView.minimumZoomScale *= scale;
self.baseMaximumZoomScale *= scale;
[self updateScrollViewMaximumZoomScale];
self.scrollView.zoomScale *= scale;

// Work out the centered, upscaled version of the crop rectangle
Expand Down Expand Up @@ -378,8 +387,8 @@ - (void)performRelayoutForRotation {

// Nor undershoot the bottom right corner
CGPoint maximumOffset = CGPointZero;
maximumOffset.x = (self.bounds.size.width - self.scrollView.contentInset.right) + self.scrollView.contentSize.width;
maximumOffset.y = (self.bounds.size.height - self.scrollView.contentInset.bottom) + self.scrollView.contentSize.height;
maximumOffset.x = self.scrollView.contentSize.width - (self.bounds.size.width - self.scrollView.contentInset.right);
maximumOffset.y = self.scrollView.contentSize.height - (self.bounds.size.height - self.scrollView.contentInset.bottom);
offset.x = MIN(offset.x, maximumOffset.x);
offset.y = MIN(offset.y, maximumOffset.y);
self.scrollView.contentOffset = offset;
Expand Down Expand Up @@ -752,7 +761,10 @@ - (void)updateToImageCropFrame:(CGRect)imageCropframe {
CGRect bounds = self.contentBounds;
CGFloat scale = MIN(bounds.size.width / scaledCropSize.width, bounds.size.height / scaledCropSize.height);

// Zoom into the scroll view to the appropriate size
// Zoom into the scroll view to the appropriate size, transiently raising the
// ceiling if the restored crop requires more zoom than is normally allowed
// (the next layout-driven update re-derives the ceiling from the base value)
self.scrollView.maximumZoomScale = MAX(self.scrollView.maximumZoomScale, self.scrollView.minimumZoomScale * scale);
self.scrollView.zoomScale = self.scrollView.minimumZoomScale * scale;

CGSize contentSize = self.scrollView.contentSize;
Expand Down Expand Up @@ -923,6 +935,7 @@ - (void)scrollViewDidZoom:(UIScrollView *)scrollView {
if (scrollView.isTracking) {
self.cropBoxLastEditedZoomScale = scrollView.zoomScale;
self.cropBoxLastEditedMinZoomScale = scrollView.minimumZoomScale;
self.cropBoxLastEditedMaxZoomScale = self.baseMaximumZoomScale;
}

[self matchForegroundToBackground];
Expand Down Expand Up @@ -1000,6 +1013,7 @@ - (void)setCropBoxFrame:(CGRect)cropBoxFrame {
CGSize imageSize = self.backgroundContainerView.bounds.size;
CGFloat scale = MAX(cropBoxFrame.size.height / imageSize.height, cropBoxFrame.size.width / imageSize.width);
self.scrollView.minimumZoomScale = scale;
[self updateScrollViewMaximumZoomScale];

// make sure content isn't smaller than the crop box
CGSize size = self.scrollView.contentSize;
Expand Down Expand Up @@ -1036,24 +1050,25 @@ - (CGRect)imageCropFrame {

CGRect frame = CGRectZero;

// Calculate the normalized origin
// Calculate the normalized origin, clamped inside the image so the size
// subtraction below can never go negative (eg, during rubber-band overscroll)
frame.origin.x = floorf((floorf(contentOffset.x) + edgeInsets.left) * (imageSize.width / contentSize.width));
frame.origin.x = MAX(0, frame.origin.x);
frame.origin.x = MAX(0, MIN(frame.origin.x, imageSize.width));

frame.origin.y = floorf((floorf(contentOffset.y) + edgeInsets.top) * (imageSize.height / contentSize.height));
frame.origin.y = MAX(0, frame.origin.y);
frame.origin.y = MAX(0, MIN(frame.origin.y, imageSize.height));

// Calculate the normalized width
// Calculate the normalized width, clamped so the rect never extends past the image
frame.size.width = ceilf(cropBoxFrame.size.width * scale);
frame.size.width = MIN(imageSize.width, frame.size.width);
frame.size.width = MIN(imageSize.width - frame.origin.x, frame.size.width);

// Calculate normalized height
if (floor(cropBoxFrame.size.width) == floor(cropBoxFrame.size.height)) {
frame.size.height = frame.size.width;
} else {
frame.size.height = ceilf(cropBoxFrame.size.height * scale);
}
frame.size.height = MIN(imageSize.height, frame.size.height);
frame.size.height = MIN(imageSize.height - frame.origin.y, frame.size.height);

return frame;
}
Expand Down Expand Up @@ -1138,13 +1153,18 @@ - (void)setTranslucencyAlwaysHidden:(BOOL)translucencyAlwaysHidden {
}

- (void)setGridOverlayHidden:(BOOL)gridOverlayHidden {
[self setGridOverlayHidden:_gridOverlayHidden animated:NO];
[self setGridOverlayHidden:gridOverlayHidden animated:NO];
}

- (void)setGridOverlayHidden:(BOOL)gridOverlayHidden animated:(BOOL)animated {
_gridOverlayHidden = gridOverlayHidden;
self.gridOverlayView.alpha = gridOverlayHidden ? 1.0f : 0.0f;

if (!animated) {
self.gridOverlayView.alpha = gridOverlayHidden ? 0.0f : 1.0f;
return;
}

self.gridOverlayView.alpha = gridOverlayHidden ? 1.0f : 0.0f;
[UIView animateWithDuration:0.4f
animations:^{
self.gridOverlayView.alpha = gridOverlayHidden ? 0.0f : 1.0f;
Expand Down Expand Up @@ -1176,27 +1196,34 @@ - (void)setCanBeReset:(BOOL)canReset {
}

- (void)setAngle:(NSInteger)angle {
// The initial layout would not have been performed yet.
// Save the value and it will be applied when it has
// Only multiples of 90 degrees are supported
NSInteger newAngle = angle;
if (angle % 90 != 0) {
newAngle = 0;
}

// Normalize to the (-360, 360) range the rotation logic produces
// (eg, 360 wraps back around to 0), preserving direction
newAngle %= 360;

// The initial layout would not have been performed yet.
// Save the value and it will be applied when it has
if (!self.initialSetupPerformed) {
self.restoreAngle = newAngle;
return;
}

// Negative values are allowed, so rotate clockwise or counter clockwise depending
// on direction
if (newAngle >= 0) {
while (labs(self.angle) != labs(newAngle)) {
[self rotateImageNinetyDegreesAnimated:NO clockwise:YES completion:nil];
}
} else {
while (-labs(self.angle) != -labs(newAngle)) {
[self rotateImageNinetyDegreesAnimated:NO clockwise:NO completion:nil];
// on direction. Compare the signed values since +90 and -90 are distinct states.
const BOOL clockwise = (newAngle >= 0);
while (self.angle != newAngle) {
const NSInteger previousAngle = self.angle;
[self rotateImageNinetyDegreesAnimated:NO clockwise:clockwise completion:nil];

// Bail out if no rotation was performed (eg, an animated rotation is
// still in flight) rather than spinning forever on the main thread
if (self.angle == previousAngle) {
break;
}
}
}
Expand Down Expand Up @@ -1373,8 +1400,8 @@ - (void)setAspectRatio:(CGSize)aspectRatio animated:(BOOL)animated {

BOOL zoomOut = NO;

// Passing in an empty size will revert back to the image aspect ratio
if (aspectRatio.width < FLT_EPSILON && aspectRatio.height < FLT_EPSILON) {
// Passing in an empty size (or one with a zero component) will revert back to the image aspect ratio
if (aspectRatio.width < FLT_EPSILON || aspectRatio.height < FLT_EPSILON) {
aspectRatio = (CGSize){self.imageSize.width, self.imageSize.height};
zoomOut = YES; // Prevent from steadily zooming in when cycling between alternate aspectRatios and original
}
Expand Down Expand Up @@ -1481,9 +1508,15 @@ - (void)rotateImageNinetyDegreesAnimated:(BOOL)animated completion:(void (^)(BOO
}

- (void)rotateImageNinetyDegreesAnimated:(BOOL)animated clockwise:(BOOL)clockwise completion:(void (^)(BOOL completed))completionHandler {
// Only allow one rotation animation at a time
if (self.rotateAnimationInProgress)
// Only allow one rotation animation at a time. Report the dropped call rather
// than swallowing the handler, or callers that gate UI on it (such as the
// toolbar's own rotation buttons) would stay disabled forever.
if (self.rotateAnimationInProgress) {
if (completionHandler) {
completionHandler(NO);
}
return;
}

// Cancel any pending resizing timers
if (self.resetTimer) {
Expand Down Expand Up @@ -1541,17 +1574,23 @@ - (void)rotateImageNinetyDegreesAnimated:(BOOL)animated clockwise:(BOOL)clockwis
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
const CGFloat oldZoomScale = self.scrollView.zoomScale;
CGRect newCropFrame = CGRectZero;
if (labs(self.angle) == labs(self.cropBoxLastEditedAngle) || (labs(self.angle) * -1) == ((labs(self.cropBoxLastEditedAngle) - 180) % 360)) {
newCropFrame.size = self.cropBoxLastEditedSize;

// Restore the full zoom state, ceiling first so the saved zoom isn't clamped
self.baseMaximumZoomScale = self.cropBoxLastEditedMaxZoomScale;
self.scrollView.minimumZoomScale = self.cropBoxLastEditedMinZoomScale;
[self updateScrollViewMaximumZoomScale];
self.scrollView.zoomScale = self.cropBoxLastEditedZoomScale;
} else {
newCropFrame.size = (CGSize){floorf(self.cropBoxFrame.size.height * scale), floorf(self.cropBoxFrame.size.width * scale)};

// Re-adjust the scrolling dimensions of the scroll view to match the new size
self.scrollView.minimumZoomScale *= scale;
self.baseMaximumZoomScale *= scale;
[self updateScrollViewMaximumZoomScale];
self.scrollView.zoomScale *= scale;
}

Expand Down Expand Up @@ -1585,9 +1624,13 @@ - (void)rotateImageNinetyDegreesAnimated:(BOOL)animated clockwise:(BOOL)clockwis
[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;
// work out how to line up out point of interest into the middle of the crop box.
// Use the zoom delta that was actually applied (including any adjustment made while
// re-centering above), which differs from the geometric scale when a previously
// edited crop state was restored
const CGFloat appliedScale = (oldZoomScale > FLT_EPSILON) ? (self.scrollView.zoomScale / oldZoomScale) : scale;
cropTargetPoint.x *= appliedScale;
cropTargetPoint.y *= appliedScale;

// swap the target dimensions to match a 90 degree rotation (clockwise or counterclockwise)
CGFloat swap = cropTargetPoint.x;
Expand All @@ -1606,12 +1649,12 @@ - (void)rotateImageNinetyDegreesAnimated:(BOOL)animated clockwise:(BOOL)clockwis
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);
offset.x = MIN(self.scrollView.contentSize.width - (newCropFrame.size.width - self.scrollView.contentInset.right), offset.x);
offset.y = MIN(self.scrollView.contentSize.height - (newCropFrame.size.height - self.scrollView.contentInset.bottom), offset.y);
offset.x = MIN(self.scrollView.contentSize.width - CGRectGetMaxX(newCropFrame), offset.x);
offset.y = MIN(self.scrollView.contentSize.height - CGRectGetMaxY(newCropFrame), 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) {
if (offset.x == self.scrollView.contentOffset.x && offset.y == self.scrollView.contentOffset.y && fabs(appliedScale - 1.0) < FLT_EPSILON) {
[self matchForegroundToBackground];
}
self.scrollView.contentOffset = offset;
Expand Down Expand Up @@ -1677,12 +1720,25 @@ - (void)rotateImageNinetyDegreesAnimated:(BOOL)animated clockwise:(BOOL)clockwis
}

[self checkForCanReset];

// The un-animated path has already finished by this point, so there's no
// animation completion block to defer the handler to
if (!animated && completionHandler) {
completionHandler(YES);
}
}

- (void)captureStateForImageRotation {
self.cropBoxLastEditedSize = self.cropBoxFrame.size;
self.cropBoxLastEditedZoomScale = self.scrollView.zoomScale;
self.cropBoxLastEditedMinZoomScale = self.scrollView.minimumZoomScale;
self.cropBoxLastEditedMaxZoomScale = self.baseMaximumZoomScale;
}

// The scroll view's zoom ceiling normally sits at the layout's absolute base value,
// but must never fall below the minimum or UIScrollView clamps the zoom beneath it
- (void)updateScrollViewMaximumZoomScale {
self.scrollView.maximumZoomScale = MAX(self.scrollView.minimumZoomScale, self.baseMaximumZoomScale);
}

#pragma mark - Resettable State -
Expand Down
Loading
Loading