From 64e9b7cbf8d5cbf40630b4d65be6624f385e85fb Mon Sep 17 00:00:00 2001 From: John Daub Date: Thu, 30 Jul 2026 13:42:07 -0500 Subject: [PATCH 1/2] Complete the transition instead of trapping in interruptibleAnimator #642 replaced the fatalError() in both transitionDuration methods with a graceful return, after #641 reported crashes there. The sibling interruptibleAnimator methods kept their bare fatalError(), and they trap on the same condition: UIKit running the transition with a context whose participating view controller isn't the FloatingPanelController. This is currently the top crasher in a shipping app (~110 events/week across ~95 users). It is overwhelmingly iOS 26, but reproduces on iOS 18 as well. It fires when a stack of presented controllers is torn down in one step: the panel is presented on a view controller that is itself being dismissed, so the dismiss transition runs with the outer controller as .from. Both guards now return an animator that immediately completes the transition. The nil-transitionAnimator fallbacks deliberately return a *non*-completing animator instead, because show()/hide() directly above have already scheduled completeTransition and completing it twice would be worse than the trap. Also removes a force-unwrap of fpc.transitionAnimator on the dismiss path. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/Transitioning.swift | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/Sources/Transitioning.swift b/Sources/Transitioning.swift index ff771f8b..ea2d833a 100644 --- a/Sources/Transitioning.swift +++ b/Sources/Transitioning.swift @@ -2,6 +2,22 @@ import UIKit +/// An animator that immediately completes `transitionContext`. +/// +/// UIKit can run a panel transition with a context whose participating view controller isn't the +/// panel — for instance when a stack of presented controllers is dismissed in one step. Finishing +/// the transition leaves the presentation state consistent, where trapping would kill the app. +private func makeCompletingAnimator( + for transitionContext: UIViewControllerContextTransitioning +) -> UIViewImplicitlyAnimating { + let animator = UIViewPropertyAnimator(duration: 0, curve: .linear) + animator.addAnimations {} + animator.addCompletion { _ in + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } + return animator +} + class ModalTransition: NSObject, UIViewControllerTransitioningDelegate { func animationController(forPresented presented: UIViewController, presenting: UIViewController, @@ -93,7 +109,7 @@ class ModalPresentTransition: NSObject, UIViewControllerAnimatedTransitioning { func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating { guard let fpc = transitionContext.viewController(forKey: .to) as? FloatingPanelController - else { fatalError() } + else { return makeCompletingAnimator(for: transitionContext) } if let animator = fpc.transitionAnimator { return animator @@ -104,10 +120,9 @@ class ModalPresentTransition: NSObject, UIViewControllerAnimatedTransitioning { fpc?.suspendTransitionAnimator(false) transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } - guard let transitionAnimator = fpc.transitionAnimator else { - fatalError("The panel state must be `hidden` but it is `\(fpc.state)`") - } - return transitionAnimator + // `show(animated:)` above already scheduled `completeTransition`, so this fallback must not + // complete the context a second time. + return fpc.transitionAnimator ?? UIViewPropertyAnimator(duration: 0, curve: .linear) } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { @@ -128,7 +143,7 @@ class ModalDismissTransition: NSObject, UIViewControllerAnimatedTransitioning { func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating { guard let fpc = transitionContext.viewController(forKey: .from) as? FloatingPanelController - else { fatalError() } + else { return makeCompletingAnimator(for: transitionContext) } if let animator = fpc.transitionAnimator { return animator @@ -139,7 +154,9 @@ class ModalDismissTransition: NSObject, UIViewControllerAnimatedTransitioning { fpc?.suspendTransitionAnimator(false) transitionContext.completeTransition(!transitionContext.transitionWasCancelled) } - return fpc.transitionAnimator! + // `hide(animated:)` above already scheduled `completeTransition`, so this fallback must not + // complete the context a second time. + return fpc.transitionAnimator ?? UIViewPropertyAnimator(duration: 0, curve: .linear) } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { From fd3737d64d91614f05c679e7bd2438033f915e0a Mon Sep 17 00:00:00 2001 From: John Daub Date: Thu, 30 Jul 2026 14:07:02 -0500 Subject: [PATCH 2/2] Add tests for transitions without a panel in the context Covers the guard paths in both interruptibleAnimator methods: the transition completes rather than trapping when the context's participating view controller isn't a FloatingPanelController. Also pins the existing zero-duration behaviour of transitionDuration on the same condition. MockTransitionContext follows MockTransitionCoordinator in TestSupports. Co-Authored-By: Claude Opus 5 (1M context) --- Tests/ControllerTests.swift | 25 +++++++++++++++++++++++++ Tests/TestSupports.swift | 30 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/Tests/ControllerTests.swift b/Tests/ControllerTests.swift index e0f41b16..85d3c3af 100644 --- a/Tests/ControllerTests.swift +++ b/Tests/ControllerTests.swift @@ -367,6 +367,31 @@ class ControllerTests: XCTestCase { XCTAssertEqual(fpc.state, .half) } + + func test_modalDismissTransition_withoutPanelInContext() { + // UIKit can run the transition with a context whose from-view controller isn't the panel, + // for instance when a stack of presented view controllers is dismissed in one step. + let context = MockTransitionContext(viewControllers: [.from: UIViewController()]) + + ModalDismissTransition().animateTransition(using: context) + waitRunLoop(secs: 0.1) + + XCTAssertEqual(context.completedTransition, true) + } + + func test_modalPresentTransition_withoutPanelInContext() { + let context = MockTransitionContext(viewControllers: [.to: UIViewController()]) + + ModalPresentTransition().animateTransition(using: context) + waitRunLoop(secs: 0.1) + + XCTAssertEqual(context.completedTransition, true) + } + + func test_modalDismissTransition_zeroDurationWhenPanelIsMissing() { + let context = MockTransitionContext(viewControllers: [.from: UIViewController()]) + XCTAssertEqual(ModalDismissTransition().transitionDuration(using: context), 0.0) + } } private class MyZombieViewController: UIViewController, FloatingPanelLayout, FloatingPanelBehavior, FloatingPanelControllerDelegate { diff --git a/Tests/TestSupports.swift b/Tests/TestSupports.swift index 9601cfe7..56a48d97 100644 --- a/Tests/TestSupports.swift +++ b/Tests/TestSupports.swift @@ -109,3 +109,33 @@ class MockTransitionCoordinator: NSObject, UIViewControllerTransitionCoordinator var targetTransform: CGAffineTransform = .identity } +class MockTransitionContext: NSObject, UIViewControllerContextTransitioning { + private let viewControllers: [UITransitionContextViewControllerKey: UIViewController] + private(set) var completedTransition: Bool? + + init(viewControllers: [UITransitionContextViewControllerKey: UIViewController]) { + self.viewControllers = viewControllers + } + + func viewController(forKey key: UITransitionContextViewControllerKey) -> UIViewController? { + return viewControllers[key] + } + func completeTransition(_ didComplete: Bool) { + completedTransition = didComplete + } + + let containerView = UIView() + var isAnimated: Bool = true + var isInteractive: Bool = false + var transitionWasCancelled: Bool = false + var presentationStyle: UIModalPresentationStyle = .custom + var targetTransform: CGAffineTransform = .identity + func updateInteractiveTransition(_ percentComplete: CGFloat) {} + func finishInteractiveTransition() {} + func cancelInteractiveTransition() {} + func pauseInteractiveTransition() {} + func view(forKey key: UITransitionContextViewKey) -> UIView? { nil } + func initialFrame(for vc: UIViewController) -> CGRect { .zero } + func finalFrame(for vc: UIViewController) -> CGRect { .zero } +} +