From 41fdc6d0832903e196fca214f4bcce9b43a7b94e Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Sun, 12 Jul 2026 00:35:03 +0900 Subject: [PATCH 1/9] Prevent double Done-button taps from delivering the crop twice The re-entry guard only disabled the text button, which doesn't exist on iOS 26 (and is hidden in icon-only layouts), leaving the visible icon button able to fire a second didCrop callback. --- Objective-C/TOCropViewController/TOCropViewController.m | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Objective-C/TOCropViewController/TOCropViewController.m b/Objective-C/TOCropViewController/TOCropViewController.m index 1b4aa545..2eb1d890 100644 --- a/Objective-C/TOCropViewController/TOCropViewController.m +++ b/Objective-C/TOCropViewController/TOCropViewController.m @@ -979,7 +979,10 @@ - (void)doneButtonTapped { return; } else { - self.toolbar.doneTextButton.enabled = false; + // Disable both variants; only the icon button exists on iOS 26, + // and it's also the visible one in landscape/icon-only mode + self.toolbar.doneTextButton.enabled = NO; + self.toolbar.doneIconButton.enabled = NO; } BOOL isCallbackOrDelegateHandled = NO; From f109e97e7d26c9457b80ad91cfe2657e9477acf0 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Sun, 12 Jul 2026 02:13:14 +0900 Subject: [PATCH 2/9] Preserve HDR when cropping, and unify the crop category's name Cropping an HDR photo silently tone-mapped it to SDR because the default renderer format doesn't support high dynamic range. When the source reports HDR, an HDR-capable format is now requested (iOS 17+, guarded so unsupported environments keep the existing behavior). The category implementation also now matches its declared name (TOCropRotate), and the header doc correctly describes the crop rect as point space. --- .../Categories/UIImage+CropRotate.h | 2 +- .../Categories/UIImage+CropRotate.m | 18 +++++++++++++++++- .../TOCropViewControllerTests.m | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Objective-C/TOCropViewController/Categories/UIImage+CropRotate.h b/Objective-C/TOCropViewController/Categories/UIImage+CropRotate.h index 7408754d..dc1639c9 100644 --- a/Objective-C/TOCropViewController/Categories/UIImage+CropRotate.h +++ b/Objective-C/TOCropViewController/Categories/UIImage+CropRotate.h @@ -27,7 +27,7 @@ NS_ASSUME_NONNULL_BEGIN @interface UIImage (TOCropRotate) /// Crops a portion of an existing image object and returns it as a new image -/// @param frame The region inside the image (In image pixel space) to crop +/// @param frame The region inside the image to crop (in the image's point space, ie image.size) /// @param angle If any, the angle the image is rotated at as well /// @param circular Whether the resulting image is returned as a square or a circle - (nonnull UIImage *)croppedImageWithFrame:(CGRect)frame diff --git a/Objective-C/TOCropViewController/Categories/UIImage+CropRotate.m b/Objective-C/TOCropViewController/Categories/UIImage+CropRotate.m index 89e7896e..8343c359 100644 --- a/Objective-C/TOCropViewController/Categories/UIImage+CropRotate.m +++ b/Objective-C/TOCropViewController/Categories/UIImage+CropRotate.m @@ -22,7 +22,7 @@ #import "UIImage+CropRotate.h" -@implementation UIImage (CropRotate) +@implementation UIImage (TOCropRotate) - (BOOL)hasAlpha { CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(self.CGImage); @@ -32,6 +32,22 @@ - (BOOL)hasAlpha { - (UIImage *)croppedImageWithFrame:(CGRect)frame angle:(NSInteger)angle circularClip:(BOOL)circular { UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat new]; + +#if defined(__IPHONE_17_0) + // When the source image is HDR, request an HDR-capable rendering format so its + // highlights aren't silently tone-mapped down to SDR. Falls back to the default + // format wherever HDR rendering isn't supported. + if (@available(iOS 17.0, *)) { + if (self.isHighDynamicRange) { + UITraitCollection *hdrTraits = [UITraitCollection traitCollectionWithImageDynamicRange:UIImageDynamicRangeHigh]; + UIGraphicsImageRendererFormat *hdrFormat = [UIGraphicsImageRendererFormat formatForTraitCollection:hdrTraits]; + if (hdrFormat.supportsHighDynamicRange) { + format = hdrFormat; + } + } + } +#endif + format.opaque = !self.hasAlpha && !circular; format.scale = self.scale; diff --git a/Objective-C/TOCropViewControllerTests/TOCropViewControllerTests.m b/Objective-C/TOCropViewControllerTests/TOCropViewControllerTests.m index bf84cd1a..73732721 100644 --- a/Objective-C/TOCropViewControllerTests/TOCropViewControllerTests.m +++ b/Objective-C/TOCropViewControllerTests/TOCropViewControllerTests.m @@ -12,6 +12,7 @@ #import "TOCropScrollView.h" #import "TOCropViewController.h" +#import "UIImage+CropRotate.h" // Expose private state so tests can arm the reset timer, simulate an in-flight // rotation, and drive the scroll view's touch hooks @@ -164,6 +165,20 @@ - (void)testResetTimerIsArmedWhenAnIdleTouchIsInterrupted { XCTAssertNotNil(cropView.resetTimer, @"an interrupted idle touch must arm the reset timer"); } +- (void)testCroppedImageWithFrame { + UIImage *image = [self testImageWithSize:(CGSize){40, 20}]; + + UIImage *cropped = [image croppedImageWithFrame:(CGRect){10, 5, 20, 10} angle:0 circularClip:NO]; + XCTAssertEqualWithAccuracy(cropped.size.width, 20.0, FLT_EPSILON); + XCTAssertEqualWithAccuracy(cropped.size.height, 10.0, FLT_EPSILON); + XCTAssertEqual(cropped.imageOrientation, UIImageOrientationUp); + + // A 90-degree rotation swaps the axes the crop frame is expressed in + UIImage *rotated = [image croppedImageWithFrame:(CGRect){0, 0, 20, 40} angle:90 circularClip:NO]; + XCTAssertEqualWithAccuracy(rotated.size.width, 20.0, FLT_EPSILON); + XCTAssertEqualWithAccuracy(rotated.size.height, 40.0, FLT_EPSILON); +} + - (void)testCropViewIsReleasedWithPendingResetTimer { __weak TOCropView *weakCropView = nil; @autoreleasepool { From 8f9f04984f2bdff894f1be3498f05a4b786e4811 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Sun, 12 Jul 2026 02:33:44 +0900 Subject: [PATCH 3/9] Fix write-only properties, restore-angle parameter, and layout minor bugs - doneButtonTitle, cancelButtonTitle, showOnlyIcons, doneButtonColor, cancelButtonColor and resetButtonHidden had forwarding setters but auto-synthesized getters reading a never-written ivar, so they always read back as their defaults - presentAnimatedFromParentViewController: tested self.angle instead of its angle parameter (typo dating to 2016), silently dropping the angle when restoring a rotation-only edit with an empty toImageFrame - The rotation heuristic now derives the target layout from the aspect of the destination size instead of a width delta, fixing spurious landscape layouts on Mac Catalyst window resizes and iPad split view - The aspect-ratio popover now anchors to the clamp button through a proper coordinate-space conversion (the button moved inside the glass container on iOS 26) - TOCropViewControllerAspectRatioPreset overrode isEqual: without hash; equal presets now hash identically --- .../TOCropViewControllerAspectRatioPreset.m | 4 +++ .../TOCropViewController.m | 33 +++++++++++++++++-- .../Views/TOCropToolbar.m | 3 +- .../TOCropViewControllerTests.m | 25 ++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/Objective-C/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m b/Objective-C/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m index 09bd7898..9608b57a 100644 --- a/Objective-C/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m +++ b/Objective-C/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m @@ -66,6 +66,10 @@ - (BOOL)isEqual:(id)object { return CGSizeEqualToSize(self.size, other.size) && [self.title isEqualToString:other.title]; } +- (NSUInteger)hash { + return @(self.size.width).hash ^ @(self.size.height).hash ^ self.title.hash; +} + + (NSArray *)portraitPresets { TOCropViewControllerAspectRatioPreset *object = [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeZero title:@"Original"]; NSBundle *resourceBundle = TO_CROP_VIEW_RESOURCE_BUNDLE_FOR_OBJECT(object); diff --git a/Objective-C/TOCropViewController/TOCropViewController.m b/Objective-C/TOCropViewController/TOCropViewController.m index 2eb1d890..dd7d6f49 100644 --- a/Objective-C/TOCropViewController/TOCropViewController.m +++ b/Objective-C/TOCropViewController/TOCropViewController.m @@ -614,11 +614,14 @@ - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id size.height) { orientation = UIInterfaceOrientationLandscapeLeft; } +#endif #else // On visionOS, this method is called on presentation with size=(0,0), // which would set orientation incorrectly causing views to be misplaced. @@ -755,7 +758,7 @@ - (void)presentAnimatedFromParentViewController:(UIViewController *)viewControll self.transitionController.fromView = fromView; self.prepareForTransitionHandler = setup; - if (self.angle != 0 || !CGRectIsEmpty(toFrame)) { + if (angle != 0 || !CGRectIsEmpty(toFrame)) { self.angle = angle; self.imageCropFrame = toFrame; } @@ -1075,22 +1078,42 @@ - (void)setDoneButtonTitle:(NSString *)title { self.toolbar.doneTextButtonTitle = title; } +- (NSString *)doneButtonTitle { + return self.toolbar.doneTextButtonTitle; +} + - (void)setCancelButtonTitle:(NSString *)title { self.toolbar.cancelTextButtonTitle = title; } +- (NSString *)cancelButtonTitle { + return self.toolbar.cancelTextButtonTitle; +} + - (void)setShowOnlyIcons:(BOOL)showOnlyIcons { self.toolbar.showOnlyIcons = showOnlyIcons; } +- (BOOL)showOnlyIcons { + return self.toolbar.showOnlyIcons; +} + - (void)setDoneButtonColor:(UIColor *)color { self.toolbar.doneButtonColor = color; } +- (UIColor *)doneButtonColor { + return self.toolbar.doneButtonColor; +} + - (void)setCancelButtonColor:(UIColor *)color { self.toolbar.cancelButtonColor = color; } +- (UIColor *)cancelButtonColor { + return self.toolbar.cancelButtonColor; +} + - (TOCropView *)cropView { // Lazily create the crop view in case we try and access it before presentation, but // don't add it until our parent view controller view has loaded at the right time @@ -1160,6 +1183,10 @@ - (void)setResetButtonHidden:(BOOL)resetButtonHidden { self.toolbar.resetButtonHidden = resetButtonHidden; } +- (BOOL)resetButtonHidden { + return self.toolbar.resetButtonHidden; +} + - (BOOL)rotateButtonsHidden { return self.toolbar.rotateCounterclockwiseButtonHidden && self.toolbar.rotateClockwiseButtonHidden; } diff --git a/Objective-C/TOCropViewController/Views/TOCropToolbar.m b/Objective-C/TOCropViewController/Views/TOCropToolbar.m index 65b18e30..7b50bc26 100644 --- a/Objective-C/TOCropViewController/Views/TOCropToolbar.m +++ b/Objective-C/TOCropViewController/Views/TOCropToolbar.m @@ -430,7 +430,8 @@ - (void)buttonTapped:(id)button { } - (CGRect)clampButtonFrame { - return self.clampButton.frame; + // Convert into the toolbar's space; on iOS 26 the button lives inside the glass container + return [self convertRect:self.clampButton.bounds fromView:self.clampButton]; } - (void)setReverseContentLayout:(BOOL)reverseContentLayout { diff --git a/Objective-C/TOCropViewControllerTests/TOCropViewControllerTests.m b/Objective-C/TOCropViewControllerTests/TOCropViewControllerTests.m index 73732721..3996cf24 100644 --- a/Objective-C/TOCropViewControllerTests/TOCropViewControllerTests.m +++ b/Objective-C/TOCropViewControllerTests/TOCropViewControllerTests.m @@ -189,6 +189,31 @@ - (void)testCropViewIsReleasedWithPendingResetTimer { XCTAssertNil(weakCropView); } +- (void)testControllerPropertiesReadBack { + TOCropViewController *controller = [[TOCropViewController alloc] initWithImage:[self testImageWithSize:(CGSize){40, 20}]]; + controller.doneButtonTitle = @"Save"; + XCTAssertEqualObjects(controller.doneButtonTitle, @"Save"); + controller.cancelButtonTitle = @"Back"; + XCTAssertEqualObjects(controller.cancelButtonTitle, @"Back"); + controller.showOnlyIcons = YES; + XCTAssertTrue(controller.showOnlyIcons); + controller.doneButtonColor = UIColor.systemPinkColor; + XCTAssertEqualObjects(controller.doneButtonColor, UIColor.systemPinkColor); + controller.cancelButtonColor = UIColor.systemTealColor; + XCTAssertEqualObjects(controller.cancelButtonColor, UIColor.systemTealColor); + controller.resetButtonHidden = YES; + XCTAssertTrue(controller.resetButtonHidden); +} + +- (void)testAspectRatioPresetEqualityAndHashing { + TOCropViewControllerAspectRatioPreset *first = [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:(CGSize){16, 9} title:@"16:9"]; + TOCropViewControllerAspectRatioPreset *second = [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:(CGSize){16, 9} title:@"16:9"]; + XCTAssertEqualObjects(first, second); + XCTAssertEqual(first.hash, second.hash); + NSSet *presets = [NSSet setWithArray:@[first, second]]; + XCTAssertEqual(presets.count, 1u); +} + - (void)testViewControllerInstance { // Create a basic image UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(10, 10)]; From f0c38df7e794828f99e5afcf5a644c0724141bda Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Sun, 12 Jul 2026 03:09:42 +0900 Subject: [PATCH 4/9] Remove compatibility branches for OS versions below the iOS 12 floor UIVisualEffectView has existed since iOS 8 and systemFontOfSize:weight: since 8.2, so their fallbacks were unreachable (and the font guard was needlessly degrading iOS 12 to a regular-weight Done button). --- .../TOCropViewController/Views/TOCropToolbar.m | 6 +----- Objective-C/TOCropViewController/Views/TOCropView.m | 13 +++---------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/Objective-C/TOCropViewController/Views/TOCropToolbar.m b/Objective-C/TOCropViewController/Views/TOCropToolbar.m index 7b50bc26..684e308f 100644 --- a/Objective-C/TOCropViewController/Views/TOCropToolbar.m +++ b/Objective-C/TOCropViewController/Views/TOCropToolbar.m @@ -108,11 +108,7 @@ - (void)setup { [_doneTextButton setTitle:_doneTextButtonTitle ? _doneTextButtonTitle : NSLocalizedStringFromTableInBundle(@"Done", @"TOCropViewControllerLocalizable", resourceBundle, nil) forState:UIControlStateNormal]; [_doneTextButton setTitleColor:[UIColor colorWithRed:1.0f green:0.8f blue:0.0f alpha:1.0f] forState:UIControlStateNormal]; - if (@available(iOS 13.0, *)) { - [_doneTextButton.titleLabel setFont:[UIFont systemFontOfSize:17.0f weight:UIFontWeightMedium]]; - } else { - [_doneTextButton.titleLabel setFont:[UIFont systemFontOfSize:17.0f]]; - } + [_doneTextButton.titleLabel setFont:[UIFont systemFontOfSize:17.0f weight:UIFontWeightMedium]]; [_doneTextButton addTarget:self action:@selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside]; [_doneTextButton sizeToFit]; [self addSubview:_doneTextButton]; diff --git a/Objective-C/TOCropViewController/Views/TOCropView.m b/Objective-C/TOCropViewController/Views/TOCropView.m index 0e9df79e..b5b2245e 100644 --- a/Objective-C/TOCropViewController/Views/TOCropView.m +++ b/Objective-C/TOCropViewController/Views/TOCropView.m @@ -186,16 +186,9 @@ - (void)setup { [self addSubview:self.overlayView]; // Translucency View - if (NSClassFromString(@"UIVisualEffectView")) { - self.translucencyEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleDark]; - self.translucencyView = [[UIVisualEffectView alloc] initWithEffect:self.translucencyEffect]; - 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.translucencyEffect = [UIBlurEffect effectWithStyle:UIBlurEffectStyleDark]; + self.translucencyView = [[UIVisualEffectView alloc] initWithEffect:self.translucencyEffect]; + self.translucencyView.frame = self.bounds; self.translucencyView.hidden = self.translucencyAlwaysHidden; self.translucencyView.userInteractionEnabled = NO; self.translucencyView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; From 1c3de69c0e2779eacf145b2a64671fd0de77209a Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Sun, 12 Jul 2026 03:09:42 +0900 Subject: [PATCH 5/9] Lay out and hide grid lines correctly when toggled at runtime Recreated grid lines sat at zero-size with full alpha until the next crop-box move; they now inherit the current grid visibility and are laid out immediately. Also guards the line-thickness division against a zero displayScale before the view joins a window. --- .../Views/TOCropOverlayView.m | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Objective-C/TOCropViewController/Views/TOCropOverlayView.m b/Objective-C/TOCropViewController/Views/TOCropOverlayView.m index 8202c614..b678b4b7 100644 --- a/Objective-C/TOCropViewController/Views/TOCropOverlayView.m +++ b/Objective-C/TOCropViewController/Views/TOCropOverlayView.m @@ -135,7 +135,8 @@ - (void)layoutLines { } // grid lines - horizontal - CGFloat thickness = 1.0f / self.traitCollection.displayScale; + // (displayScale can be 0 before the view joins a window) + CGFloat thickness = 1.0f / MAX(1.0f, self.traitCollection.displayScale); NSInteger numberOfLines = self.horizontalGridLines.count; CGFloat padding = (CGRectGetHeight(self.bounds) - (thickness * numberOfLines)) / (numberOfLines + 1); for (NSInteger i = 0; i < numberOfLines; i++) { @@ -199,7 +200,12 @@ - (void)setDisplayHorizontalGridLines:(BOOL)displayHorizontalGridLines { } else { self.horizontalGridLines = @[]; } - [self setNeedsDisplay]; + + // Match the current grid visibility and lay the new lines out immediately + for (UIView *lineView in self.horizontalGridLines) { + lineView.alpha = self.gridHidden ? 0.0f : 1.0f; + } + [self layoutLines]; } - (void)setDisplayVerticalGridLines:(BOOL)displayVerticalGridLines { @@ -214,7 +220,12 @@ - (void)setDisplayVerticalGridLines:(BOOL)displayVerticalGridLines { } else { self.verticalGridLines = @[]; } - [self setNeedsDisplay]; + + // Match the current grid visibility and lay the new lines out immediately + for (UIView *lineView in self.verticalGridLines) { + lineView.alpha = self.gridHidden ? 0.0f : 1.0f; + } + [self layoutLines]; } - (void)setGridHidden:(BOOL)gridHidden { From c87a567d8a5968c87ff5d4d2fc30344f3d70f643 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Mon, 27 Jul 2026 02:25:47 +0900 Subject: [PATCH 6/9] Address review findings in the property and cropping-output fixes - Done buttons re-enable after the callbacks are delivered, so hosts that keep the controller on screen (eg retry-on-upload-failure) can commit again, via a single setDoneButtonsEnabled: rather than poking both buttons at each site - The resize heuristic uses >= so a square size agrees with -verticalLayout - Grid-line setters reuse setGridHidden:animated: instead of duplicating it - Aspect ratio preset hashes mix their components rather than XOR-ing them, so symmetric sizes such as 16:9 and 9:16 no longer collide, and the never written/read width/height properties are gone - Dropped the always-true iOS 9 blur-effect check left behind by the iOS 12 floor sweep, and the misspelled -setRotateCounterClockwiseButtonHidden:, which hadn't matched its property (or been called) since 2016 --- .../TOCropViewControllerAspectRatioPreset.m | 9 +- .../TOCropViewController.m | 23 ++++- .../Views/TOCropOverlayView.m | 12 +-- .../Views/TOCropToolbar.m | 2 +- .../TOCropViewController/Views/TOCropView.m | 13 +-- .../TOCropViewControllerTests.m | 98 +++++++++++++++++-- 6 files changed, 122 insertions(+), 35 deletions(-) diff --git a/Objective-C/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m b/Objective-C/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m index 9608b57a..e0a54e0b 100644 --- a/Objective-C/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m +++ b/Objective-C/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m @@ -29,8 +29,6 @@ @interface TOCropViewControllerAspectRatioPreset () -@property (nonatomic, assign, readwrite) CGFloat width; -@property (nonatomic, assign, readwrite) CGFloat height; @property (nonatomic, strong, readwrite) NSString *title; @end @@ -67,7 +65,12 @@ - (BOOL)isEqual:(id)object { } - (NSUInteger)hash { - return @(self.size.width).hash ^ @(self.size.height).hash ^ self.title.hash; + // Mix rather than XOR so symmetric sizes (eg 1:1, or 16:9 vs 9:16) don't collide + NSUInteger hash = 17; + hash = (hash * 31) + @(self.size.width).hash; + hash = (hash * 31) + @(self.size.height).hash; + hash = (hash * 31) + self.title.hash; + return hash; } + (NSArray *)portraitPresets { diff --git a/Objective-C/TOCropViewController/TOCropViewController.m b/Objective-C/TOCropViewController/TOCropViewController.m index dd7d6f49..2042c63d 100644 --- a/Objective-C/TOCropViewController/TOCropViewController.m +++ b/Objective-C/TOCropViewController/TOCropViewController.m @@ -618,7 +618,8 @@ - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id size.height) { + // >= so a square size agrees with -verticalLayout (width < height) + if (size.width >= size.height) { orientation = UIInterfaceOrientationLandscapeLeft; } #endif @@ -984,11 +985,11 @@ - (void)doneButtonTapped { } else { // Disable both variants; only the icon button exists on iOS 26, // and it's also the visible one in landscape/icon-only mode - self.toolbar.doneTextButton.enabled = NO; - self.toolbar.doneIconButton.enabled = NO; + [self setDoneButtonsEnabled:NO]; } BOOL isCallbackOrDelegateHandled = NO; + BOOL willRestoreDoneButtonsAfterCallback = NO; // If the delegate/block that only supplies crop data is provided, call it if ([self.delegate respondsToSelector:@selector(cropViewController:didCropImageToRect:angle:)]) { @@ -1021,9 +1022,13 @@ - (void)doneButtonTapped { if (isCircularImageCallbackAvailable) { self.onDidCropToCircleImage(image, cropFrame, angle); } + + // Let hosts that keep the controller on screen commit again + [self setDoneButtonsEnabled:YES]; }); isCallbackOrDelegateHandled = YES; + willRestoreDoneButtonsAfterCallback = YES; } // If the delegate/block that requires the specific cropped image is provided, call it else if (isDidCropToImageDelegateAvailable || isDidCropToImageCallbackAvailable) { @@ -1043,16 +1048,28 @@ - (void)doneButtonTapped { if (isDidCropToImageCallbackAvailable) { self.onDidCropToRect(image, cropFrame, angle); } + + // Let hosts that keep the controller on screen commit again + [self setDoneButtonsEnabled:YES]; }); isCallbackOrDelegateHandled = YES; + willRestoreDoneButtonsAfterCallback = YES; } if (!isCallbackOrDelegateHandled) { [self.presentingViewController dismissViewControllerAnimated:YES completion:nil]; + } else if (!willRestoreDoneButtonsAfterCallback) { + // Only the synchronous crop-data callbacks ran; re-enable immediately + [self setDoneButtonsEnabled:YES]; } } +- (void)setDoneButtonsEnabled:(BOOL)enabled { + self.toolbar.doneTextButton.enabled = enabled; + self.toolbar.doneIconButton.enabled = enabled; +} + - (void)commitCurrentCrop { [self doneButtonTapped]; } diff --git a/Objective-C/TOCropViewController/Views/TOCropOverlayView.m b/Objective-C/TOCropViewController/Views/TOCropOverlayView.m index b678b4b7..1cc16f99 100644 --- a/Objective-C/TOCropViewController/Views/TOCropOverlayView.m +++ b/Objective-C/TOCropViewController/Views/TOCropOverlayView.m @@ -201,10 +201,8 @@ - (void)setDisplayHorizontalGridLines:(BOOL)displayHorizontalGridLines { self.horizontalGridLines = @[]; } - // Match the current grid visibility and lay the new lines out immediately - for (UIView *lineView in self.horizontalGridLines) { - lineView.alpha = self.gridHidden ? 0.0f : 1.0f; - } + // Re-apply the current visibility to the rebuilt lines and lay them out + [self setGridHidden:_gridHidden animated:NO]; [self layoutLines]; } @@ -221,10 +219,8 @@ - (void)setDisplayVerticalGridLines:(BOOL)displayVerticalGridLines { self.verticalGridLines = @[]; } - // Match the current grid visibility and lay the new lines out immediately - for (UIView *lineView in self.verticalGridLines) { - lineView.alpha = self.gridHidden ? 0.0f : 1.0f; - } + // Re-apply the current visibility to the rebuilt lines and lay them out + [self setGridHidden:_gridHidden animated:NO]; [self layoutLines]; } diff --git a/Objective-C/TOCropViewController/Views/TOCropToolbar.m b/Objective-C/TOCropViewController/Views/TOCropToolbar.m index 684e308f..80d4a0b2 100644 --- a/Objective-C/TOCropViewController/Views/TOCropToolbar.m +++ b/Objective-C/TOCropViewController/Views/TOCropToolbar.m @@ -458,7 +458,7 @@ - (void)setClampButtonGlowing:(BOOL)clampButtonGlowing { self.clampButton.tintColor = [UIColor whiteColor]; } -- (void)setRotateCounterClockwiseButtonHidden:(BOOL)rotateButtonHidden { +- (void)setRotateCounterclockwiseButtonHidden:(BOOL)rotateButtonHidden { if (_rotateCounterclockwiseButtonHidden == rotateButtonHidden) return; diff --git a/Objective-C/TOCropViewController/Views/TOCropView.m b/Objective-C/TOCropViewController/Views/TOCropView.m index b5b2245e..fe730d02 100644 --- a/Objective-C/TOCropViewController/Views/TOCropView.m +++ b/Objective-C/TOCropViewController/Views/TOCropView.m @@ -99,9 +99,6 @@ @interface TOCropView () @property (nonatomic, assign) CGPoint originalContentOffset; /* Save the original content offset so we can tell if it's been scrolled. */ @property (nonatomic, assign, readwrite) BOOL canBeReset; -/* In iOS 9, a new dynamic blur effect became available. */ -@property (nonatomic, assign) BOOL dynamicBlurEffect; - /* If restoring to a previous crop setting, these properties hang onto the values until the view is configured for the first time. */ @property (nonatomic, assign) NSInteger restoreAngle; @@ -149,10 +146,6 @@ - (void)setup { self.cropViewPadding = kTOCropViewPadding; self.maximumZoomScale = kTOMaximumZoomScale; - /* Dynamic animation blurring is only possible on iOS 9, however since the API was available on iOS 8, - we'll need to manually check the system version to ensure that it's available. */ - self.dynamicBlurEffect = ([[[UIDevice currentDevice] systemVersion] compare:@"9.0" options:NSNumericSearch] != NSOrderedAscending); - // Scroll View properties self.scrollView = [[TOCropScrollView alloc] initWithFrame:self.bounds]; self.scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; @@ -742,11 +735,7 @@ - (void)resetLayoutToDefaultAnimated:(BOOL)animated { } - (void)toggleTranslucencyViewVisible:(BOOL)visible { - if (self.dynamicBlurEffect == NO) { - self.translucencyView.alpha = visible ? 1.0f : 0.0f; - } else { - [(UIVisualEffectView *)self.translucencyView setEffect:visible ? self.translucencyEffect : nil]; - } + [(UIVisualEffectView *)self.translucencyView setEffect:visible ? self.translucencyEffect : nil]; } - (void)updateToImageCropFrame:(CGRect)imageCropframe { diff --git a/Objective-C/TOCropViewControllerTests/TOCropViewControllerTests.m b/Objective-C/TOCropViewControllerTests/TOCropViewControllerTests.m index 3996cf24..827a9895 100644 --- a/Objective-C/TOCropViewControllerTests/TOCropViewControllerTests.m +++ b/Objective-C/TOCropViewControllerTests/TOCropViewControllerTests.m @@ -215,16 +215,98 @@ - (void)testAspectRatioPresetEqualityAndHashing { } - (void)testViewControllerInstance { - // Create a basic image - UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(10, 10)]; - UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *_Nonnull context) { - [context fillRect:CGRectMake(0, 0, 10, 10)]; - }]; - - // Perform test - TOCropViewController *controller = [[TOCropViewController alloc] initWithImage:image]; + TOCropViewController *controller = [[TOCropViewController alloc] initWithImage:[self testImageWithSize:(CGSize){10, 10}]]; UIView *view = controller.view; XCTAssertNotNil(view); } +- (void)testRotationCompletionFiresWhenNotAnimated { + TOCropView *cropView = [self cropViewWithImageSize:(CGSize){40, 20}]; + __block NSInteger callCount = 0; + __block BOOL reportedCompleted = NO; + [cropView rotateImageNinetyDegreesAnimated:NO + clockwise:YES + completion:^(BOOL completed) { + callCount++; + reportedCompleted = completed; + }]; + XCTAssertEqual(cropView.angle, 90); + XCTAssertEqual(callCount, 1); + XCTAssertTrue(reportedCompleted); +} + +- (void)testRotationCompletionFiresWhenRotationIsRejected { + TOCropView *cropView = [self cropViewWithImageSize:(CGSize){40, 20}]; + cropView.rotateAnimationInProgress = YES; + + __block NSInteger callCount = 0; + __block BOOL reportedCompleted = YES; + [cropView rotateImageNinetyDegreesAnimated:YES + clockwise:YES + completion:^(BOOL completed) { + callCount++; + reportedCompleted = completed; + }]; + cropView.rotateAnimationInProgress = NO; + + // The rotation was dropped, but the handler must still run so callers gating + // UI on it (such as the toolbar's rotation buttons) don't stay disabled + XCTAssertEqual(cropView.angle, 0); + XCTAssertEqual(callCount, 1); + XCTAssertFalse(reportedCompleted); +} + +- (void)testCircularCropViewHasNoGridOverlay { + TOCropView *cropView = [[TOCropView alloc] initWithCroppingStyle:TOCropViewCroppingStyleCircular + image:[self testImageWithSize:(CGSize){40, 20}]]; + cropView.frame = (CGRect){0, 0, 320, 480}; + XCTAssertNoThrow([cropView performInitialSetup]); + + // Circular cropping has no rectangular grid, so the property is nullable + XCTAssertNil(cropView.gridOverlayView); + XCTAssertNoThrow([cropView setGridOverlayHidden:NO animated:NO]); +} + +- (void)testHidingRotationButtonsRelaysOutTheToolbar { + // Buttons flow in the order [counterclockwise, reset, clamp, clockwise], so hiding + // the counterclockwise button must slide the reset button into the leading slot. + // If the property invalidates layout, an implicit and a forced layout agree. + TOCropToolbar *toolbar = [[TOCropToolbar alloc] initWithFrame:(CGRect){0, 0, 375, 44}]; + [toolbar layoutIfNeeded]; + + toolbar.rotateCounterclockwiseButtonHidden = YES; + [toolbar layoutIfNeeded]; + CGRect implicitFrame = toolbar.resetButton.frame; + + [toolbar setNeedsLayout]; + [toolbar layoutIfNeeded]; + XCTAssertTrue(CGRectEqualToRect(implicitFrame, toolbar.resetButton.frame)); + + // And the same for its clockwise counterpart + TOCropToolbar *other = [[TOCropToolbar alloc] initWithFrame:(CGRect){0, 0, 375, 44}]; + [other layoutIfNeeded]; + + other.rotateClockwiseButtonHidden = YES; + [other layoutIfNeeded]; + CGRect otherImplicitFrame = other.clampButton.frame; + + [other setNeedsLayout]; + [other layoutIfNeeded]; + XCTAssertTrue(CGRectEqualToRect(otherImplicitFrame, other.clampButton.frame)); +} + +- (void)testDoneAndCancelButtonsRemainReachable { + TOCropToolbar *toolbar = [[TOCropToolbar alloc] initWithFrame:(CGRect){0, 0, 375, 44}]; + + // On iOS 26 the text buttons are never built, so turning this off must not be + // honoured, or the toolbar would be left with no way to commit or cancel + toolbar.showOnlyIcons = NO; + [toolbar layoutIfNeeded]; + + XCTAssertTrue(toolbar.doneTextButton != nil || toolbar.doneIconButton.hidden == NO); + XCTAssertTrue(toolbar.cancelTextButton != nil || toolbar.cancelIconButton.hidden == NO); + XCTAssertNotNil(toolbar.visibleCancelButton); + XCTAssertFalse(CGRectIsEmpty(toolbar.doneButtonFrame)); +} + @end From 049d6c41ad45c39bce1623dfca8ecc1944ac7d40 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Mon, 27 Jul 2026 02:25:47 +0900 Subject: [PATCH 7/9] Cover the Swift wrapper's property forwarding Exercises the ~20 properties CropViewController forwards to the underlying TOCropViewController, which is what the write-only property fixes above restored. Kept separate from the delegate-bridge tests because it depends on those fixes rather than on the bridge. --- .../CropViewControllerTests.swift | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/Swift/CropViewControllerTests/CropViewControllerTests.swift b/Swift/CropViewControllerTests/CropViewControllerTests.swift index ae391fda..bb786271 100644 --- a/Swift/CropViewControllerTests/CropViewControllerTests.swift +++ b/Swift/CropViewControllerTests/CropViewControllerTests.swift @@ -189,6 +189,77 @@ final class CropViewControllerTests: XCTestCase { // MARK: - Property Forwarding - + func testValuesForwardToTheUnderlyingController() { + let controller = makeController() + let inner = controller.toCropViewController! + + controller.aspectRatioPreset = CGSize(width: 16, height: 9) + XCTAssertTrue(controller.aspectRatioPreset == CGSize(width: 16, height: 9)) + XCTAssertTrue(inner.aspectRatioPreset == CGSize(width: 16, height: 9)) + + controller.aspectRatioLockEnabled = true + XCTAssertTrue(inner.aspectRatioLockEnabled) + + controller.aspectRatioLockDimensionSwapEnabled = true + XCTAssertTrue(inner.aspectRatioLockDimensionSwapEnabled) + + controller.resetAspectRatioEnabled = false + XCTAssertFalse(inner.resetAspectRatioEnabled) + + controller.toolbarPosition = .top + XCTAssertEqual(inner.toolbarPosition, .top) + + controller.rotateClockwiseButtonHidden = true + XCTAssertTrue(inner.rotateClockwiseButtonHidden) + + controller.rotateButtonsHidden = true + XCTAssertTrue(inner.rotateButtonsHidden) + + controller.resetButtonHidden = true + XCTAssertTrue(inner.resetButtonHidden) + + controller.doneButtonHidden = true + XCTAssertTrue(inner.doneButtonHidden) + + controller.cancelButtonHidden = true + XCTAssertTrue(inner.cancelButtonHidden) + + controller.doneButtonTitle = "Save" + XCTAssertEqual(inner.doneButtonTitle, "Save") + + controller.cancelButtonTitle = "Back" + XCTAssertEqual(inner.cancelButtonTitle, "Back") + + controller.doneButtonColor = .systemPink + XCTAssertEqual(inner.doneButtonColor, .systemPink) + + controller.cancelButtonColor = .systemTeal + XCTAssertEqual(inner.cancelButtonColor, .systemTeal) + + controller.minimumAspectRatio = 0.5 + XCTAssertEqual(inner.minimumAspectRatio, 0.5) + + controller.hidesNavigationBar = false + XCTAssertFalse(inner.hidesNavigationBar) + + controller.showActivitySheetOnDone = true + XCTAssertTrue(inner.showActivitySheetOnDone) + + controller.showCancelConfirmationDialog = true + XCTAssertTrue(inner.showCancelConfirmationDialog) + + controller.reverseContentLayout = true + XCTAssertTrue(inner.reverseContentLayout) + + controller.aspectRatioPickerButtonHidden = true + XCTAssertTrue(inner.aspectRatioPickerButtonHidden) + + // Not asserted against the value that was set: the toolbar is permanently + // icon-only from iOS 26 on, so this only has to agree with what it wraps + controller.showOnlyIcons = true + XCTAssertEqual(controller.showOnlyIcons, inner.showOnlyIcons) + } + func testTitleForwardsToTheUnderlyingController() { let controller = makeController() XCTAssertNil(controller.titleLabel) From 2784fd8e4ff84ebef52c1f10712f2db114de4f2e Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Tue, 28 Jul 2026 00:40:15 +0900 Subject: [PATCH 8/9] Note the property and cropping-output fixes in the changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6ab4c3f..de23cc18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,14 @@ x.y.z Release Notes (yyyy-MM-dd) - The private display-corner-radius lookup on iOS 26 now fails gracefully if the key ever disappears. - Setting `showOnlyIcons` to false on iOS 26 hid the icon buttons without creating any text ones, leaving no way to commit or cancel the crop. The property is now ignored where the toolbar is icon-only. - On iOS 26, hiding every toolbar action button left an empty glass capsule stranded on screen. +- The Done button could be tapped twice in icon-only layouts (including all of iOS 26), delivering the crop callbacks twice. +- Cropping an HDR image silently tone-mapped it down to SDR (now preserved on iOS 17 and above). +- `doneButtonTitle`, `cancelButtonTitle`, `showOnlyIcons`, `doneButtonColor`, `cancelButtonColor` and `resetButtonHidden` always read back as their default values. +- The `angle` parameter of `presentAnimatedFromParentViewController` was ignored when no target crop frame was supplied. +- The aspect ratio popover anchored to the wrong position on iPad on iOS 26. +- Mac Catalyst window resizes and iPad split view changes could briefly animate the toolbar into the wrong layout. +- Toggling `displayHorizontalGridLines`/`displayVerticalGridLines` at runtime now lays out and hides the new grid lines correctly. +- Setting `rotateCounterclockwiseButtonHidden` didn't lay the toolbar out again, so the hidden button stayed on screen until something else triggered a layout pass. Its setter had been misspelled, and therefore dead, since 2016. 3.1.2 Release Notes (2026-04-07) From bd5b980661eb2f318725746e8a39d57db32f8e08 Mon Sep 17 00:00:00 2001 From: Tim Oliver Date: Mon, 27 Jul 2026 02:26:06 +0900 Subject: [PATCH 9/9] Annotate the properties that really can be nil as nullable TOCropView.gridOverlayView is nil for circular cropping, which returns from -setup before building it, and TOCropToolbar's doneTextButton/cancelTextButton are nil on iOS 26, where only the icon buttons are created. All three were declared nonnull, which hands Swift a nil in a non-optional: the added test assertion traps at runtime without this change. NOTE: this is source-breaking for Swift callers that access these directly, which is arguable for a patch release. It is deliberately the last commit on this branch so it can be dropped with a single revert, leaving the documentation notes from the preceding commits in place. --- CHANGELOG.md | 4 ++++ Objective-C/TOCropViewController/Views/TOCropToolbar.h | 4 ++-- Objective-C/TOCropViewController/Views/TOCropView.h | 3 ++- Swift/CropViewControllerTests/CropViewControllerTests.swift | 3 +++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de23cc18..06d203a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ x.y.z Release Notes (yyyy-MM-dd) - Toggling `displayHorizontalGridLines`/`displayVerticalGridLines` at runtime now lays out and hides the new grid lines correctly. - Setting `rotateCounterclockwiseButtonHidden` didn't lay the toolbar out again, so the hidden button stayed on screen until something else triggered a layout pass. Its setter had been misspelled, and therefore dead, since 2016. +## Changed + +- `TOCropView.gridOverlayView` is now annotated `nullable`, matching that circular cropping has no rectangular grid, as are `TOCropToolbar.doneTextButton` and `cancelTextButton`, which don't exist on iOS 26. Swift callers accessing these directly will need to unwrap them. + 3.1.2 Release Notes (2026-04-07) ## Enhancements diff --git a/Objective-C/TOCropViewController/Views/TOCropToolbar.h b/Objective-C/TOCropViewController/Views/TOCropToolbar.h index 4231974c..d8c29e4c 100644 --- a/Objective-C/TOCropViewController/Views/TOCropToolbar.h +++ b/Objective-C/TOCropViewController/Views/TOCropToolbar.h @@ -41,7 +41,7 @@ NS_ASSUME_NONNULL_BEGIN /* The 'Done' buttons to commit the crop. The text button is displayed in portrait mode and the icon one, in landscape. The text button is nil on iOS 26 and up, where the toolbar is always icon-only. */ -@property (nonatomic, strong, readonly) UIButton *doneTextButton; +@property (nullable, nonatomic, strong, readonly) UIButton *doneTextButton; @property (nonatomic, strong, readonly) UIButton *doneIconButton; @property (nonatomic, copy) NSString *doneTextButtonTitle; @property (null_resettable, nonatomic, copy) UIColor *doneButtonColor; @@ -49,7 +49,7 @@ NS_ASSUME_NONNULL_BEGIN /* The 'Cancel' buttons to cancel the crop. The text button is displayed in portrait mode and the icon one, in landscape. The text button is nil on iOS 26 and up, where the toolbar is always icon-only. */ -@property (nonatomic, strong, readonly) UIButton *cancelTextButton; +@property (nullable, nonatomic, strong, readonly) UIButton *cancelTextButton; @property (nonatomic, strong, readonly) UIButton *cancelIconButton; @property (nonatomic, readonly) UIView *visibleCancelButton; @property (nonatomic, copy) NSString *cancelTextButtonTitle; diff --git a/Objective-C/TOCropViewController/Views/TOCropView.h b/Objective-C/TOCropViewController/Views/TOCropView.h index 38690e42..edf24167 100644 --- a/Objective-C/TOCropViewController/Views/TOCropView.h +++ b/Objective-C/TOCropViewController/Views/TOCropView.h @@ -59,8 +59,9 @@ NS_ASSUME_NONNULL_BEGIN /** A grid view overlaid on top of the foreground image view's container. + This is nil when the cropping style is circular, which has no rectangular grid. */ -@property (nonnull, nonatomic, strong, readonly) TOCropOverlayView *gridOverlayView; +@property (nullable, nonatomic, strong, readonly) TOCropOverlayView *gridOverlayView; /** A container view that clips the a copy of the image so it appears over the dimming view diff --git a/Swift/CropViewControllerTests/CropViewControllerTests.swift b/Swift/CropViewControllerTests/CropViewControllerTests.swift index bb786271..4d9ebf88 100644 --- a/Swift/CropViewControllerTests/CropViewControllerTests.swift +++ b/Swift/CropViewControllerTests/CropViewControllerTests.swift @@ -281,6 +281,9 @@ final class CropViewControllerTests: XCTestCase { let circularController = CropViewController(croppingStyle: .circular, image: image) XCTAssertEqual(circularController.croppingStyle, .circular) XCTAssertEqual(circularController.cropView.croppingStyle, .circular) + + // Circular cropping has no rectangular grid overlay + XCTAssertNil(circularController.cropView.gridOverlayView) } func testChildViewControllerRelationshipIsEstablished() {