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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion Objective-C/TOCropViewController/Categories/UIImage+CropRotate.m
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

#import "UIImage+CropRotate.h"

@implementation UIImage (CropRotate)
@implementation UIImage (TOCropRotate)

- (BOOL)hasAlpha {
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(self.CGImage);
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<TOCropViewControllerAspectRatioPreset *> *)portraitPresets {
TOCropViewControllerAspectRatioPreset *object = [[TOCropViewControllerAspectRatioPreset alloc] initWithSize:CGSizeZero title:@"Original"];
NSBundle *resourceBundle = TO_CROP_VIEW_RESOURCE_BUNDLE_FOR_OBJECT(object);
Expand Down
55 changes: 51 additions & 4 deletions Objective-C/TOCropViewController/TOCropViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -614,11 +614,15 @@ - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIVi
}

#if !TARGET_OS_VISION
// Derive the layout from the aspect of the size we're transitioning to,
// matching the logic in -verticalLayout
UIInterfaceOrientation orientation = UIInterfaceOrientationPortrait;
CGSize currentSize = self.view.bounds.size;
if (currentSize.width < size.width) {
#if !TARGET_OS_MACCATALYST
// >= 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.
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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:)]) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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];
}
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
13 changes: 10 additions & 3 deletions Objective-C/TOCropViewController/Views/TOCropOverlayView.m
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions Objective-C/TOCropViewController/Views/TOCropToolbar.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ 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;

/* 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;
Expand Down
11 changes: 4 additions & 7 deletions Objective-C/TOCropViewController/Views/TOCropToolbar.m
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion Objective-C/TOCropViewController/Views/TOCropView.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 4 additions & 22 deletions Objective-C/TOCropViewController/Views/TOCropView.m
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,6 @@ @interface TOCropView () <UIScrollViewDelegate, UIGestureRecognizerDelegate>
@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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading