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) { 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 } +} +