diff --git a/CHANGELOG.md b/CHANGELOG.md index f6ab4c3f..06d203a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,18 @@ 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. + +## 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) 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/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m b/Objective-C/TOCropViewController/Models/TOCropViewControllerAspectRatioPreset.m index 09bd7898..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 @@ -66,6 +64,15 @@ - (BOOL)isEqual:(id)object { return CGSizeEqualToSize(self.size, other.size) && [self.title isEqualToString:other.title]; } +- (NSUInteger)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 { 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 1b4aa545..2042c63d 100644 --- a/Objective-C/TOCropViewController/TOCropViewController.m +++ b/Objective-C/TOCropViewController/TOCropViewController.m @@ -614,11 +614,15 @@ - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id= so a square size agrees with -verticalLayout (width < height) + if (size.width >= 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 +759,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; } @@ -979,10 +983,13 @@ - (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 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:)]) { @@ -1015,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) { @@ -1037,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]; } @@ -1072,22 +1095,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 @@ -1157,6 +1200,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/TOCropOverlayView.m b/Objective-C/TOCropViewController/Views/TOCropOverlayView.m index 8202c614..1cc16f99 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,10 @@ - (void)setDisplayHorizontalGridLines:(BOOL)displayHorizontalGridLines { } else { self.horizontalGridLines = @[]; } - [self setNeedsDisplay]; + + // Re-apply the current visibility to the rebuilt lines and lay them out + [self setGridHidden:_gridHidden animated:NO]; + [self layoutLines]; } - (void)setDisplayVerticalGridLines:(BOOL)displayVerticalGridLines { @@ -214,7 +218,10 @@ - (void)setDisplayVerticalGridLines:(BOOL)displayVerticalGridLines { } else { self.verticalGridLines = @[]; } - [self setNeedsDisplay]; + + // Re-apply the current visibility to the rebuilt lines and lay them out + [self setGridHidden:_gridHidden animated:NO]; + [self layoutLines]; } - (void)setGridHidden:(BOOL)gridHidden { 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/TOCropToolbar.m b/Objective-C/TOCropViewController/Views/TOCropToolbar.m index 65b18e30..80d4a0b2 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]; @@ -430,7 +426,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 { @@ -461,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.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/Objective-C/TOCropViewController/Views/TOCropView.m b/Objective-C/TOCropViewController/Views/TOCropView.m index 0e9df79e..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; @@ -186,16 +179,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; @@ -749,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 bf84cd1a..827a9895 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 { @@ -174,17 +189,124 @@ - (void)testCropViewIsReleasedWithPendingResetTimer { XCTAssertNil(weakCropView); } -- (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)]; - }]; +- (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); +} - // Perform test - TOCropViewController *controller = [[TOCropViewController alloc] initWithImage:image]; +- (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 { + 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 diff --git a/Swift/CropViewControllerTests/CropViewControllerTests.swift b/Swift/CropViewControllerTests/CropViewControllerTests.swift index ae391fda..4d9ebf88 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) @@ -210,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() {