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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ x.y.z Release Notes (yyyy-MM-dd)
- 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).
- The Swift `CropViewController`'s closure bridge strongly retained the nominally weak `delegate`, leaking the controller and image when the delegate owned the controller, and kept delivering callbacks to a replaced delegate.
- The crop adjustment reset timer retained the crop view (and its images) after dismissal mid-adjustment.
- Touch cancellation from incoming calls or system alerts left the crop view stuck in its editing appearance.
- Swift: replacing the `delegate` left the previous delegate receiving callbacks, and clearing it discarded closures the host had assigned directly.
- Swift: the `cropViewControllerDidTapDone` bridge added in 3.1.2 captured the delegate strongly, the same retain cycle as the other callbacks.

3.1.2 Release Notes (2026-04-07)

Expand Down
33 changes: 31 additions & 2 deletions Objective-C/TOCropViewController/Views/TOCropView.m
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ - (void)setup {
self.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
self.scrollView.touchesBegan = ^{ [weakSelf startEditing]; };
self.scrollView.touchesEnded = ^{ [weakSelf startResetTimer]; };
self.scrollView.touchesCancelled = ^{ [weakSelf handleScrollViewTouchCancellation]; };

// Background Image View
self.backgroundImageView = [[UIImageView alloc] initWithImage:self.image];
Expand Down Expand Up @@ -232,6 +233,10 @@ - (void)setup {
[self addGestureRecognizer:self.gridPanGestureRecognizer];
}

- (void)dealloc {
[_resetTimer invalidate];
}

#pragma mark - View Layout -
- (void)performInitialSetup {
// Calling this more than once is potentially destructive
Expand Down Expand Up @@ -797,7 +802,9 @@ - (void)gridPanGestureRecognized:(UIPanGestureRecognizer *)recognizer {
self.tappedEdge = [self cropEdgeForPoint:self.panOriginPoint];
}

if (recognizer.state == UIGestureRecognizerStateEnded) {
if (recognizer.state == UIGestureRecognizerStateEnded ||
recognizer.state == UIGestureRecognizerStateCancelled ||
recognizer.state == UIGestureRecognizerStateFailed) {
[self startResetTimer];
}

Expand Down Expand Up @@ -840,7 +847,29 @@ - (void)startResetTimer {
if (self.resetTimer)
return;

self.resetTimer = [NSTimer scheduledTimerWithTimeInterval:self.cropAdjustingDelay target:self selector:@selector(timerTriggered) userInfo:nil repeats:NO];
// Use a block-based timer so a pending reset doesn't retain this view
// beyond its natural lifetime (eg, when dismissed mid-adjustment)
__weak typeof(self) weakSelf = self;
self.resetTimer = [NSTimer scheduledTimerWithTimeInterval:self.cropAdjustingDelay
repeats:NO
block:^(NSTimer *timer) {
[weakSelf timerTriggered];
}];
}

// A cancelled touch doesn't imply the interaction is over: the scroll view cancels the
// touches it delivered as a normal part of its own pan or pinch recognizer taking over,
// which happens with the user's finger still down. While one of those is underway the
// scroll view's delegate arms the timer for us once it finishes, so arming it here as
// well would trigger a reset mid-gesture. What's left is a touch cancelled while nothing
// is scrolling - an incoming call or system alert - which would otherwise leave the crop
// view stuck in its editing appearance.
- (void)handleScrollViewTouchCancellation {
if (self.scrollView.isDragging || self.scrollView.isZooming || self.scrollView.isDecelerating) {
return;
}

[self startResetTimer];
}

- (void)timerTriggered {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,42 @@
// Copyright (c) 2015 Tim Oliver. All rights reserved.
//

#import <objc/runtime.h>
#import <UIKit/UIKit.h>
#import <XCTest/XCTest.h>

#import "TOCropScrollView.h"
#import "TOCropViewController.h"

// Expose private state so the test can simulate an in-flight rotation
// Expose private state so tests can arm the reset timer, simulate an in-flight
// rotation, and drive the scroll view's touch hooks
@interface TOCropView (UnitTests)
- (void)startResetTimer;
@property (nonatomic, assign) BOOL rotateAnimationInProgress;
@property (nonatomic, strong, readonly) TOCropScrollView *scrollView;
@property (nonatomic, strong, readonly) NSTimer *resetTimer;
@end

// UIScrollView won't report itself as dragging without a real touch sequence, so
// stand in for one while a block runs
@interface UIScrollView (TOCropTestDragging)
- (BOOL)to_test_alwaysDragging;
@end

@implementation UIScrollView (TOCropTestDragging)
- (BOOL)to_test_alwaysDragging {
return YES;
}
@end

static void TOCropRunWhileScrollViewIsDragging(void (^block)(void)) {
Method real = class_getInstanceMethod([UIScrollView class], @selector(isDragging));
Method stub = class_getInstanceMethod([UIScrollView class], @selector(to_test_alwaysDragging));
method_exchangeImplementations(real, stub);
block();
method_exchangeImplementations(real, stub);
}

@interface TOCropViewControllerTests : XCTestCase

@end
Expand Down Expand Up @@ -114,6 +140,40 @@ - (void)testImageCropFrameStaysWithinImageBounds {
}
}

- (void)testResetTimerIsNotArmedWhenAPanCancelsTheTouch {
TOCropView *cropView = [self cropViewWithImageSize:(CGSize){40, 20}];

cropView.scrollView.touchesBegan();
XCTAssertNil(cropView.resetTimer, @"editing should have cancelled any pending reset");

// UIScrollView cancels the touches it delivered as a normal part of its pan
// recogniser taking over, so this fires mid-gesture with the finger still down
TOCropRunWhileScrollViewIsDragging(^{
cropView.scrollView.touchesCancelled();
});
XCTAssertNil(cropView.resetTimer, @"a pan taking over the touch must not arm the reset timer");
}

- (void)testResetTimerIsArmedWhenAnIdleTouchIsInterrupted {
TOCropView *cropView = [self cropViewWithImageSize:(CGSize){40, 20}];

// Nothing is scrolling, so this is a real interruption (incoming call, system
// alert) and the view would otherwise stay stuck in its editing appearance
cropView.scrollView.touchesBegan();
cropView.scrollView.touchesCancelled();
XCTAssertNotNil(cropView.resetTimer, @"an interrupted idle touch must arm the reset timer");
}

- (void)testCropViewIsReleasedWithPendingResetTimer {
__weak TOCropView *weakCropView = nil;
@autoreleasepool {
TOCropView *cropView = [self cropViewWithImageSize:(CGSize){40, 20}];
[cropView startResetTimer];
weakCropView = cropView;
}
XCTAssertNil(weakCropView);
}

- (void)testViewControllerInstance {
// Create a basic image
UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(10, 10)];
Expand Down
90 changes: 65 additions & 25 deletions Swift/CropViewController/CropViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,18 @@ open class CropViewController: UIViewController, TOCropViewControllerDelegate {
public weak var delegate: (any CropViewControllerDelegate)? {
didSet { self.setUpDelegateHandlers() }
}


/**
The callback slots that currently hold a delegate-bridging closure, so that
replacing the delegate can tear down its bridges without clobbering closures
the host assigned directly.
:nodoc:
*/
private enum BridgedCallback {
case didTapDone, didCropImageToRect, didCropToRect, didCropToCircleImage, didFinishCancelled
}
private var bridgedCallbacks: Set<BridgedCallback> = []

/**
Set the title text that appears at the top of the view controller
*/
Expand Down Expand Up @@ -424,7 +435,7 @@ open class CropViewController: UIViewController, TOCropViewControllerDelegate {
/**
If true, button icons are visible in portairt instead button text.

Default is NO.
Default is NO. Has no effect on iOS 26 and up, where the toolbar is always icon-only.
*/
public var showOnlyIcons: Bool {
set { toCropViewController.showOnlyIcons = newValue }
Expand Down Expand Up @@ -658,49 +669,78 @@ extension CropViewController {
}

fileprivate func setUpDelegateHandlers() {
guard let delegate = self.delegate else {
onDidTapDone = nil
onDidCropToRect = nil
onDidCropImageToRect = nil
onDidCropToCircleImage = nil
onDidFinishCancelled = nil
return
}

// Tear down the bridges installed for the previous delegate, so that a delegate
// which has since been replaced stops receiving callbacks. Slots the host
// assigned a closure to directly were never ours, so they're left alone.
if bridgedCallbacks.contains(.didTapDone) { onDidTapDone = nil }
if bridgedCallbacks.contains(.didCropImageToRect) { onDidCropImageToRect = nil }
if bridgedCallbacks.contains(.didCropToRect) { onDidCropToRect = nil }
if bridgedCallbacks.contains(.didCropToCircleImage) { onDidCropToCircleImage = nil }
if bridgedCallbacks.contains(.didFinishCancelled) { onDidFinishCancelled = nil }
bridgedCallbacks.removeAll()

guard let delegate = self.delegate else { return }

// Install a bridging closure only for the selectors this delegate implements,
// leaving directly-assigned closures on the other slots untouched. The delegate
// is captured weakly to honor the `weak` contract of the delegate property; if
// it has been deallocated by the time a closure fires, fall back to the default
// dismissal so the controller can't be left stranded on screen.
if delegate.responds(to: #selector((any CropViewControllerDelegate).cropViewControllerDidTapDone(_:))) {
self.onDidTapDone = { [weak self] in
guard let self else { return }
delegate.cropViewControllerDidTapDone?(self)
bridgedCallbacks.insert(.didTapDone)
// No dismissal fallback for this one: it's fired alongside the crop
// callbacks rather than instead of them, so they own the dismissal.
self.onDidTapDone = {[weak self, weak delegate] in
guard let strongSelf = self, let delegate = delegate else { return }
delegate.cropViewControllerDidTapDone?(strongSelf)
}
}

if delegate.responds(to: #selector((any CropViewControllerDelegate).cropViewController(_:didCropImageToRect:angle:))) {
self.onDidCropImageToRect = {[weak self] rect, angle in
bridgedCallbacks.insert(.didCropImageToRect)
self.onDidCropImageToRect = {[weak self, weak delegate] rect, angle in
guard let strongSelf = self else { return }
delegate.cropViewController!(strongSelf, didCropImageToRect: rect, angle: angle)
guard let delegate = delegate else { strongSelf.performDefaultDismissal(); return }
delegate.cropViewController?(strongSelf, didCropImageToRect: rect, angle: angle)
}
}

if delegate.responds(to: #selector((any CropViewControllerDelegate).cropViewController(_:didCropToImage:withRect:angle:))) {
self.onDidCropToRect = {[weak self] image, rect, angle in
bridgedCallbacks.insert(.didCropToRect)
self.onDidCropToRect = {[weak self, weak delegate] image, rect, angle in
guard let strongSelf = self else { return }
delegate.cropViewController!(strongSelf, didCropToImage: image, withRect: rect, angle: angle)
guard let delegate = delegate else { strongSelf.performDefaultDismissal(); return }
delegate.cropViewController?(strongSelf, didCropToImage: image, withRect: rect, angle: angle)
}
}

if delegate.responds(to: #selector((any CropViewControllerDelegate).cropViewController(_:didCropToCircularImage:withRect:angle:))) {
self.onDidCropToCircleImage = {[weak self] image, rect, angle in
bridgedCallbacks.insert(.didCropToCircleImage)
self.onDidCropToCircleImage = {[weak self, weak delegate] image, rect, angle in
guard let strongSelf = self else { return }
delegate.cropViewController!(strongSelf, didCropToCircularImage: image, withRect: rect, angle: angle)
guard let delegate = delegate else { strongSelf.performDefaultDismissal(); return }
delegate.cropViewController?(strongSelf, didCropToCircularImage: image, withRect: rect, angle: angle)
}
}

if delegate.responds(to: #selector((any CropViewControllerDelegate).cropViewController(_:didFinishCancelled:))) {
self.onDidFinishCancelled = {[weak self] finished in
bridgedCallbacks.insert(.didFinishCancelled)
self.onDidFinishCancelled = {[weak self, weak delegate] finished in
guard let strongSelf = self else { return }
delegate.cropViewController!(strongSelf, didFinishCancelled: finished)
guard let delegate = delegate else { strongSelf.performDefaultDismissal(); return }
delegate.cropViewController?(strongSelf, didFinishCancelled: finished)
}
}
}

/// Mirrors the Objective-C layer's default dismissal behavior, for when a bridged
/// delegate callback fires after the delegate itself has gone away.
fileprivate func performDefaultDismissal() {
if let navigationController = self.navigationController, navigationController.viewControllers.count > 1 {
navigationController.popViewController(animated: true)
} else {
presentingViewController?.dismiss(animated: true)
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// CropViewControllerTests-Bridging-Header.h
// CropViewControllerTests
//
// Copyright 2017-2025 Timothy Oliver. All rights reserved.
//
// The test bundle compiles CropViewController.swift directly, rather than importing a
// built module, matching how the example apps consume it. That means the Objective-C
// layer it wraps has to be exposed to Swift through a bridging header.
//

#import "TOCropViewController.h"

// TOCropView.h only forward-declares TOCropOverlayView, and Swift drops properties
// whose class is incomplete, so `cropView.gridOverlayView` is invisible without this.
#import "TOCropOverlayView.h"
Loading
Loading