I'm trying to implement a custom pan gesture to interactively transition to a new view controller. The way it works is that I have a button (labeled "Template Editor", see below) on which you can start a pan to move the current view controller to the right, revealing the new view controller next to it (I've recorded my problem, see below).
Everything is working but there is a bug that I don't understand at all:
Sometimes, when I just swipe over the button (triggering a pan gesture) then lift my finger again (touch down -> fast, short swipe to the right -> touch up) the interactive transition glitches out. It starts to very slowly complete the transition and afterwards, I cannot dismiss the presented view controller, nor can I present anything on that presented view controller.
I have no idea why. Here's my code:
First, the UIViewControllerAnimatedTransitioning class. It's implemented using UIViewPropertyAnimator and just adds the animation using transform:
class MovingTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
enum Direction {
case left, right
}
// MARK: - Properties
// ========== PROPERTIES ==========
private var animator: UIViewImplicitlyAnimating?
var duration = 0.6
var presenting = true
var shouldAnimateInteractively: Bool = false
public var direction: Direction = .left
private var movingMultiplicator: CGFloat {
return direction == .left ? -1 : 1
}
// ====================
// MARK: - Initializers
// ========== INITIALIZERS ==========
// ====================
// MARK: - Overrides
// ========== OVERRIDES ==========
// ====================
// MARK: - Functions
// ========== FUNCTIONS ==========
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let animator = interruptibleAnimator(using: transitionContext)
animator.startAnimation()
}
func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
// If the animator already exists, return it (important, see documentation!)
if let animator = self.animator {
return animator
}
// Otherwise, create the animator
let containerView = transitionContext.containerView
let fromView = transitionContext.view(forKey: .from)!
let toView = transitionContext.view(forKey: .to)!
if presenting {
toView.frame = containerView.frame
toView.transform = CGAffineTransform(translationX: movingMultiplicator * toView.frame.width, y: 0)
} else {
toView.frame = containerView.frame
toView.transform = CGAffineTransform(translationX: -movingMultiplicator * toView.frame.width, y: 0)
}
containerView.addSubview(toView)
let animator = UIViewPropertyAnimator(duration: duration, dampingRatio: 0.9, animations: nil)
animator.addAnimations {
if self.presenting {
toView.transform = .identity
fromView.transform = CGAffineTransform(translationX: -self.movingMultiplicator * toView.frame.width, y: 0)
} else {
toView.transform = .identity
fromView.transform = CGAffineTransform(translationX: self.movingMultiplicator * toView.frame.width, y: 0)
}
}
animator.addCompletion { (position) in
// Important to set frame above (device rotation will otherwise mess things up)
toView.transform = .identity
fromView.transform = .identity
if !transitionContext.transitionWasCancelled {
self.shouldAnimateInteractively = false
}
self.animator = nil
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
self.animator = animator
return animator
}
// ====================
}
Here's the part that adds the interactivity. It's a method that's being called by a UIPanGestureRecognizer I added to the button.
public lazy var transitionAnimator: MovingTransitionAnimator = MovingTransitionAnimator()
public lazy var interactionController = UIPercentDrivenInteractiveTransition()
...
#objc private func handlePan(pan: UIPanGestureRecognizer) {
let translation = pan.translation(in: utilityView)
var progress = (translation.x / utilityView.frame.width)
progress = CGFloat(fminf(fmaxf(Float(progress), 0.0), 1.0))
switch pan.state {
case .began:
// This is a flag that helps me distinguish between when a user taps on the button and when he starts a pan
transitionAnimator.shouldAnimateInteractively = true
// Just a dummy view controller that's dismissing as soon as its been presented (the problem occurs with every view controller I use here)
let vc = UIViewController()
vc.view.backgroundColor = .red
vc.transitioningDelegate = self
present(vc, animated: true, completion: {
self.transitionAnimator.shouldAnimateInteractively = false
vc.dismiss(animated: true, completion: nil)
})
case .changed:
interactionController.update(progress)
case .cancelled:
interactionController.cancel()
case .ended:
if progress > 0.55 || pan.velocity(in: utilityView).x > 600
interactionController.completionSpeed = 0.8
interactionController.finish()
} else {
interactionController.completionSpeed = 0.8
interactionController.cancel()
}
default:
break
}
}
I also implemented all the necessary delegate methods:
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transitionAnimator.presenting = true
return transitionAnimator
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transitionAnimator.presenting = false
return transitionAnimator
}
func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
guard let animator = animator as? MovingTransitionAnimator, animator.shouldAnimateInteractively else { return nil }
return interactionController
}
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
guard let animator = animator as? MovingTransitionAnimator, animator.shouldAnimateInteractively else { return nil }
return interactionController
}
That's it. There's no more logic behind it (I think; if you need more information, please tell me), but it still has this bug. Here's a recording of the bug. You can't really see my touch but all I'm doing is touching down -> fast, shortly swiping to the right -> touching up. And after this really slow transition has finished, I can't dismiss the red view controller. It's stuck there:
Here's what's even stranger:
Neither interactionController.finish() nor interactionController.cancel() is being called when this occurs (at least not from within my handlePan(_:)method).
I checked the view hierarchy in Xcode after this bug occurred and I got this:
First, it's seemingly stuck in the transition (everything is still inside UITransitionView).
Second, on the left hand side you see the views of the first view controller(the one I start the transition from). However, on the image there only is the red view controller visible, the one that was about to be presented.
Do you have any idea what's going on? I've been trying to figure this out for the past 3 hours but I can't get get it to work properly. I'd appreciate any help
Thank you!
EDIT
Okay, I found a way to reproduce it 100% of the time. I also created an isolated project demonstrating the problem (it's a little differently structured because I tried many things but the result is still exactly the same)
Here's the project: https://github.com/d3mueller/InteractiveTransitionDemo2
How to reproduce the problem:
Swipe from right to left and then quickly from left to right. This will trigger the bug.
Also, a similar bug will appear, when you swipe from right to left very fast multiple times. Then it will actually run the transition and finish it correctly (but it shouldn't even start because moving from right to left keeps the progress at 0.0)
You might try setting:
/// Set this to NO in order to start an interruptible transition non
/// interactively. By default this is YES, which is consistent with the behavior
/// before 10.0.
#property (nonatomic) BOOL wantsInteractiveStart NS_AVAILABLE_IOS(10_0);
to NO on your interactionController
Good luck and curious to hear if you figure it out.
Related
iOS 13 seems to use a new UIPresentationController for presenting modal view controllers, but one that does not rely on taking snapshots of the presenting view controller (as most / all libraries out there do). The presenting view controller is 'live' and continues to display animations / changes while the modal view controller is showing above a transparent / tinted background.
I'm able to replicate this easily (as the aim is to make a backward compatible version for iOS 10 / 11 / 12 etc) by using a CGAffineTransform on the presenting view controller's view, however frequently while rotating the device, the presenting view begins to de-shape and grow out of bounds seemingly because the system updates its frame while there's an active transform applied to it.
According to the documentation, frame is undefined when there's a transform applied to the view. Given the system seems to be modifying the frame and not me, how do I solve this without ending up with hacky solutions where I'm updating the presenting view's bounds? I need this presentation controller to remain generic since the presenting controller could be any shape or form, and won't necessarily be a full-screen view.
Here's what I have so far - it's a simple UIPresentationController subclass, which seems to work fine, however rotating the device and then dismissing the presented view controller seems to de-shape the presenting view controller's bounds (becomes too wide or shrinks, depending on whether you presented the modal controller while in landscape / portrait)
class SheetPresentationController: UIPresentationController {
override var frameOfPresentedViewInContainerView: CGRect {
return CGRect(x: 40, y: containerView!.bounds.height / 2, width: containerView!.bounds.width-80, height: containerView!.bounds.height / 2)
}
override func containerViewWillLayoutSubviews() {
super.containerViewWillLayoutSubviews()
if let _ = presentingViewController.transitionCoordinator {
// We're transitioning - don't touch the frame yet as it'll
// clash with our transform
} else {
self.presentedView?.frame = self.frameOfPresentedViewInContainerView
}
}
override func presentationTransitionWillBegin() {
super.presentationTransitionWillBegin()
containerView?.backgroundColor = .clear
if let coordinator = presentingViewController.transitionCoordinator {
coordinator.animate(alongsideTransition: { [weak self] _ in
self?.containerView?.backgroundColor = UIColor.black.withAlphaComponent(0.3)
// Scale the presenting view
self?.presentingViewController.view.layer.cornerRadius = 16
self?.presentingViewController.view.transform = CGAffineTransform.init(scaleX: 0.9, y: 0.9)
}, completion: nil)
}
}
override func dismissalTransitionWillBegin() {
if let coordinator = presentingViewController.transitionCoordinator {
coordinator.animate(alongsideTransition: { [weak self] _ in
self?.containerView?.backgroundColor = .clear
self?.presentingViewController.view.layer.cornerRadius = 0
self?.presentingViewController.view.transform = .identity
}, completion: nil)
}
}
}
And the Presenting Animation controller:
import UIKit
final class PresentingAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let presentedViewController = transitionContext.viewController(forKey: .to) else {
return
}
let springTiming = UISpringTimingParameters(dampingRatio: 1.0, initialVelocity: CGVector(dx:1.0, dy: 1.0))
let animator: UIViewPropertyAnimator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: springTiming)
let containerView = transitionContext.containerView
containerView.addSubview(presentedViewController.view)
let finalFrameForPresentedView = transitionContext.finalFrame(for: presentedViewController)
presentedViewController.view.frame = finalFrameForPresentedView
// Move it below the screen so it slides up
presentedViewController.view.frame.origin.y = containerView.bounds.height
animator.addAnimations {
presentedViewController.view.frame = finalFrameForPresentedView
}
animator.addCompletion { (animationPosition) in
if animationPosition == .end {
transitionContext.completeTransition(true)
}
}
animator.startAnimation()
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.6
}
}
As well as the dismissing animation controller:
import UIKit
final class DismissingAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let presentedViewController = transitionContext.viewController(forKey: .from) else {
return
}
guard let presentingViewController = transitionContext.viewController(forKey: .to) else {
return
}
let finalFrameForPresentedView = transitionContext.finalFrame(for: presentedViewController)
let containerView = transitionContext.containerView
let offscreenFrame = CGRect(x: finalFrameForPresentedView.minX, y: containerView.bounds.height, width: finalFrameForPresentedView.width, height: finalFrameForPresentedView.height)
let springTiming = UISpringTimingParameters(dampingRatio: 1.0, initialVelocity: CGVector(dx:1.0, dy: 1.0))
let animator: UIViewPropertyAnimator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: springTiming)
animator.addAnimations {
presentedViewController.view.frame = offscreenFrame
}
animator.addCompletion { (position) in
if position == .end {
// Complete transition
transitionContext.completeTransition(true)
}
}
animator.startAnimation()
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.6
}
}
Okay I figured it out. It seems iOS 13 does NOT use a scale transform. The moment you do that, as explained, rotating the device will modify the frame of the presenting view and since you've got a transform applied to the view already, the view will resize in unexpected ways and the transform will no longer be valid.
The solution is to instead use a z-axis perspective, which will give you the exact same result, but doing so will survive rotations etc since all you're doing is moving the view back into 3D space (Z-axis), thus effectively zooming it out. Here's the transform that did this for me (Swift):
func calculatePerspectiveTransform() -> CATransform3D {
let eyePosition:Float = 10.0;
var contentTransform:CATransform3D = CATransform3DIdentity
contentTransform.m34 = CGFloat(-1/eyePosition)
contentTransform = CATransform3DTranslate(contentTransform, 0, 0, -2)
return contentTransform
}
Here's an article explaining how this works: https://whackylabs.com/uikit/2014/10/29/add-some-perspective-to-your-uiviews/
In your UIPresenterController, you would need to do the following too in order to handle this transform across rotations properly:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
// Reset transform before we rotate and then apply it again during rotation
if let presentingView = presentingViewController.view {
presentingView.layer.transform = CATransform3DIdentity
}
coordinator.animate(alongsideTransition: { [weak self] (context) in
if let presentingView = self?.presentingViewController.view {
presentingView.layer.transform = self?.calculatePerspectiveTransform() ?? CATransform3DIdentity
}
})
}
Custom presentations are a tricky part of UIKit. Here's what comes to mind, no guarantees ;-)
I would suggest you either try to "commit" the animation on the presenting view - so in the presentationTransitionDidEnd(Bool) callback remove the transform and set appropriate constraints on the presenting view that match what the transform did. Or you could also just animate the constraint changes to mimic a transform.
Presumably you will get a viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) call back to manage the ongoing presentation if a rotation occurs.
I have a tableview with a list of elements. I've tried to implement a zoom transition to the detailview, when tapping a cell in the tableview.
The transition works as intended - but when I press "back", i'm taken to a black view.
I'm new to working with custom transitions, so maybe there's something that I'm missing - I'm just not sure what. I followed this article: https://blog.rocketinsights.com/how-to-create-a-navigation-transition-like-the-apple-news-app/ to make the zoomtransition, and converted the code to Swift 4.
Here's my zoomtransition class:
class ZoomTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
private let duration: TimeInterval = 0.5
var operation: UINavigationControllerOperation = .push
var thumbnailFrame = CGRect.zero
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let presenting = operation == .push
// Determine which is the master view and which is the detail view that we're navigating to and from. The container view will house the views for transition animation.
let containerView = transitionContext.containerView
guard let toView = transitionContext.view(forKey: UITransitionContextViewKey.to) else { return }
guard let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from) else { return }
let storyFeedView = presenting ? fromView : toView
let storyDetailView = presenting ? toView : fromView
// Determine the starting frame of the detail view for the animation. When we're presenting, the detail view will grow out of the thumbnail frame. When we're dismissing, the detail view will shrink back into that same thumbnail frame.
var initialFrame = presenting ? thumbnailFrame : storyDetailView.frame
let finalFrame = presenting ? storyDetailView.frame : thumbnailFrame
// Resize the detail view to fit within the thumbnail's frame at the beginning of the push animation and at the end of the pop animation while maintaining it's inherent aspect ratio.
let initialFrameAspectRatio = initialFrame.width / initialFrame.height
let storyDetailAspectRatio = storyDetailView.frame.width / storyDetailView.frame.height
if initialFrameAspectRatio > storyDetailAspectRatio {
initialFrame.size = CGSize(width: initialFrame.height * storyDetailAspectRatio, height: initialFrame.height)
}
else {
initialFrame.size = CGSize(width: initialFrame.width, height: initialFrame.width / storyDetailAspectRatio)
}
let finalFrameAspectRatio = finalFrame.width / finalFrame.height
var resizedFinalFrame = finalFrame
if finalFrameAspectRatio > storyDetailAspectRatio {
resizedFinalFrame.size = CGSize(width: finalFrame.height * storyDetailAspectRatio, height: finalFrame.height)
}
else {
resizedFinalFrame.size = CGSize(width: finalFrame.width, height: finalFrame.width / storyDetailAspectRatio)
}
// Determine how much the detail view needs to grow or shrink.
let scaleFactor = resizedFinalFrame.width / initialFrame.width
let growScaleFactor = presenting ? scaleFactor: 1/scaleFactor
let shrinkScaleFactor = 1/growScaleFactor
if presenting {
// Shrink the detail view for the initial frame. The detail view will be scaled to CGAffineTransformIdentity below.
storyDetailView.transform = CGAffineTransform(scaleX: shrinkScaleFactor, y: shrinkScaleFactor)
storyDetailView.center = CGPoint(x: thumbnailFrame.midX, y: thumbnailFrame.midY)
storyDetailView.clipsToBounds = true
}
// Set the initial state of the alpha for the master and detail views so that we can fade them in and out during the animation.
storyDetailView.alpha = presenting ? 0 : 1
storyFeedView.alpha = presenting ? 1 : 0
// Add the view that we're transitioning to to the container view that houses the animation.
containerView.addSubview(toView)
containerView.bringSubview(toFront: storyDetailView)
// Animate the transition.
UIView.animate(withDuration: duration, delay: 0.0, usingSpringWithDamping: 1, initialSpringVelocity: 1.0, options: [.curveEaseInOut], animations: {
// Fade the master and detail views in and out.
storyDetailView.alpha = presenting ? 1 : 0
storyFeedView.alpha = presenting ? 0 : 1
if presenting {
// Scale the master view in parallel with the detail view (which will grow to its inherent size). The translation gives the appearance that the anchor point for the zoom is the center of the thumbnail frame.
let scale = CGAffineTransform(scaleX: growScaleFactor, y: growScaleFactor)
let translate = storyFeedView.transform.translatedBy(x: storyFeedView.frame.midX - self.thumbnailFrame.midX, y: storyFeedView.frame.midY - self.thumbnailFrame.midY)
storyFeedView.transform = translate.concatenating(scale)
storyDetailView.transform = .identity
}
else {
// Return the master view to its inherent size and position and shrink the detail view.
storyFeedView.transform = .identity
storyDetailView.transform = CGAffineTransform(scaleX: shrinkScaleFactor, y: shrinkScaleFactor)
}
// Move the detail view to the final frame position.
storyDetailView.center = CGPoint(x: finalFrame.midX, y: finalFrame.midY)
}) { finished in
transitionContext.completeTransition(finished)
}
}
}
In my main viewcontroller I've implemented the UINavigationControllerDelegate, with the following code:
extension SearchVC: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if operation == .push {
//Pass the thumbnail frame to the transition animator.
guard let transitionThumbnail = transitionThumbnail, let transitionThumbnailSuperview = transitionThumbnail.superview else { return nil }
thumbnailZoomTransitionAnimator = ZoomTransitionAnimator()
thumbnailZoomTransitionAnimator?.thumbnailFrame = transitionThumbnailSuperview.convert(transitionThumbnail.frame, to: nil)
}
thumbnailZoomTransitionAnimator?.operation = operation
return thumbnailZoomTransitionAnimator
}
}
And finally when tapping a cell in the tableview, this is my code:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath as IndexPath, animated: true)
let cell = tableView.cellForRow(at: indexPath as IndexPath) as? MovieTableViewCell
transitionThumbnail = cell?.movieImageView
self.performSegue(withIdentifier: "searchToDetails", sender: indexPath.row)
}
My segue sends the data to the detailview in a "prepare for segue"-method.
Can anyone explain/help me what is going wrong? :-) It's as mentioned only when going back, that the view turns black.
I think you need to set the presenting once the animation is complete to be able to handle the toggle.
{ finished in
transitionContext.completeTransition(finished)
presenting = !presenting
}
I have two view controllers and from the first one I show the second one by a modal segue, which presentation style is set to over current context. I also use blur effect which appears during the transition and disappears after it.
I've created a demo app to show how the transition looks like:
Here, the second view controller contains a UIScrollView, and on top of that, a yellow rectangle, which is another UIView with a UIButton on it. That UIButton also closes the view controller. Also, as you can see, I've set the background color to clear, so the blur effect is visible.
Now, in my project the transition is basically the same, except, my view controllers are "heavier".
The first view controller is embedded inside a UINavigationController" and a UITabBarController and on it I have a custom segmented control which
is made from UIViews and UIButtons, and a UITableView with custom cells.
The second view controller consists of a UIScrollView and a UIView (like on the image above, just bigger a little). That view contains a UIImageView, UILabels and a smaller UIView which is used as button to close the view by tapping on it.
Here is the code that I use to close the second view controller by dragging it down (UIScrollViewDelegate's methods).
extension AuthorInfoViewController: UIScrollViewDelegate {
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
isDragging = true
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard isDragging else {
return
}
if !isDismissingAuthorInfo && scrollView.contentOffset.y < -30.0 && dismissAnimator != nil {
authoInfoViewControllerDelegate?.authorInfoViewControllerWillDismiss()
isDismissingAuthorInfo = true
dismissAnimator?.wantsInteractiveStart = true
dismiss(animated: true, completion: nil)
return
}
if isDismissingAuthorInfo {
let progress = max(0.0, min(1.0, ((-scrollView.contentOffset.y) - 30) / 90.0))
dismissAnimator?.update(progress)
}
}
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
let progress = max(0.0, min(1.0, ((-scrollView.contentOffset.y) - 30) / 90.0))
if progress > 0.5 {
dismissAnimator?.finish()
} else {
dismissAnimator?.cancel()
print("Canceled")
}
isDismissingAuthorInfo = false
isDragging = false
}
}
Here, isDismissingAuthorInfo and isDragging are booleans which keep
track whether the view is dismissing and is dragged at all. authorInfoViewControllerWillDismiss is a method implemented in a protocol to which conforms the first view controller. That methods calls another method which adds the blur animation to the custom transitions animator.
EDITED
The animator code is the following:
class DismissAnimator: UIPercentDrivenInteractiveTransition, UIViewControllerAnimatedTransitioning {
var auxAnimationsForBlur: (()->Void)?
var auxAnimationsForTabBar: (()->Void)?
var auxAnimationsCancelForBlur: (()->Void)?
var auxAnimationsCancelForTabBar: (()->Void)?
var tabBar: UITabBar?
var blurView: UIView?
let transitionDuration = 0.75
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return transitionDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
transitionAnimator(using: transitionContext).startAnimation()
}
func transitionAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
let duration = transitionDuration(using: transitionContext)
let container = transitionContext.containerView
let from = transitionContext.view(forKey: .from)!
container.addSubview(from)
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeOut)
animator.addAnimations({
from.transform = CGAffineTransform(translationX: 0.0, y: container.frame.size.height + 30)
}, delayFactor: 0.15)
animator.addCompletion { (position) in
switch position {
case .end:
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
self.tabBar?.isHidden = false
self.blurView?.removeFromSuperview()
default:
transitionContext.completeTransition(false)
self.auxAnimationsCancelForBlur?()
self.auxAnimationsCancelForTabBar?()
}
}
if let auxAnimationsForBlur = auxAnimationsForBlur {
animator.addAnimations(auxAnimationsForBlur)
}
if let auxAnimationsForTabBar = auxAnimationsForTabBar {
animator.addAnimations(auxAnimationsForTabBar)
}
return animator
}
func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
return transitionAnimator(using:transitionContext)
}
}
In the code above, the auxAnimationsForBlur is for adding blur animation to the animator, the auxAnimationsCancelForBlur is for canceling it, auxAnimationsForTabBar is for adding the animation of alpha value of tabBar, auxAnimationsCancelForTabBar is for canceling it.
Now, the problem is the following: the animation works fine, but after running it by dragging the second view controller for several times (5-9, approximately), after the transition is over and the first view controller is shown, it just stops responding. However, on the bottom I have a tab bar, and it works. So, when I change to another tab and return back, I see the second view controller and a black screen behind it (where the first view controller should have been). When this happens the cancel method on UIPercentDrivenInteractiveTransition gets called, from scrollViewWillEndDragging method above (on the console I see the word cancel printed). Is it possible that cancel method on UIPercentDrivenInteractiveTransition doesn't causes this problem, because when I comment out the call to that method, it seems everything works ok (in that case, I also comment out the call to interruptibleAnimator(using:) method on my animator, so I lose the interactive behaviour of the transition)? I couldn't reproduce this behaviour while closing the second view controller by tapping on a close button, so I think it has something to do with dragging.
What could cause this problem, and what could you suggest for solving it? I would appreciate all your help.
How would one replicate the bounce effect of the iOS Notification Center window that drops to the bottom of the screen and bounces without ever traveling below the height of the window? This appears to use damping and spring velocity but how do you prevent the object, such as in this case, from overshooting its mark and slinging back?
Take a look into UIKitDynamics. This will allow you to apply physics and collisions to your views.
You could have a view that drops from the top of the screen and collides with a view at the bottom of the screen. You'll need to use UIGravityBehavior and UICollisionBehavior, and should be able to adjust the constants to get the desired effect.
Updated answer: You could build your own custom transition.
Say you have two view controllers, StartController and EndController. You want to have this custom transition to happen from StartController to EndController.
1) Create your custom transition object like so:
class CustomSlideTransition: NSObject, UIViewControllerAnimatedTransitioning {
var duration: Double = 1.0
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// Access EndController
let toViewController = transitionContext.viewController(forKey: .to) as! EndController
// Access the container view :
let containerView = transitionContext.containerView
// Move EndController's view frame:
toViewController.view.frame.origin.y -= UIScreen.main.bounds.size.height
containerView.addSubview(toViewController.view)
// Adjust the properties of the spring to what fits your needs the most:
UIView.animate(withDuration: duration, delay: 0.0, usingSpringWithDamping: 0.2, initialSpringVelocity: 7.0, options: [.curveEaseInOut], animations: {
// Get your EndController's view frame moves back to its final position
toViewController.view.frame.origin.y += UIScreen.main.bounds.size.height
}, completion: { (finished) in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}
2) In the prepareForSegue method of your StartController, set the delegate to self :
class StartController: UIViewController {
// ...
// MARK: PrepareForSegue
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let destinationController = segue.destination as? EndController {
destinationController.transitioningDelegate = self
}
}
// ...
}
3) Make StartController conform to the UIViewControllerTransitioningDelegate :
extension StartController: UIViewControllerTransitioningDelegate {
// MARK: UIViewControllerTransitioningDelegate
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return CustomSlideTransition()
}
}
4) Design your EndController however you want!
Note: You may want to add a custom transition for dismissing EndController as well.
Update: Now if you really don't want your view controller to bounce even a little beyond the window, using a spring here may not be the best way to go.
Since on iPhone the bounces effect "power" is calculated using the swipe (from the top) offset, you might want to custom even more your animation block using key frame animations, or UIKIT Dynamics if you're comfortable with.
UIView.animate(withDuration: duration, animations: {
toViewController.view.frame.origin.y += UIScreen.main.bounds.size.height
}, completion: { (finished) in
// Create a custom bounce effect that doesn't go beyond the window (ie. only negative values)
let animation = CAKeyframeAnimation(keyPath: "transform.translation.y")
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
animation.duration = 0.75
animation.autoreverses = false
// Depending on how you would like to present EndController, you may want to change the hardcoded values below.
// For example you could make it so that if the user swipes really fast, it would bounce even more..
animation.values = [0, -60, 0, -25, 0]
// Add the key frame animation to the view's layer :
toViewController.view.layer.add(animation, forKey: "bounce")
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
I have created an interactive transition. My func animateTransition(transitionContext: UIViewControllerContextTransitioning) is quite normal, I get the container UIView, I add the two UIViewControllers and then I do the animation changes in a UIView.animateWithDuration(duration, animations, completion).
I add a UIScreenEdgePanGestureRecognizer to my from UIViewController. It works well except when I do a very quick pan.
In that last scenario, the app is not responsive, still on the same UIViewController (the transition seems not to have worked) but the background tasks run. When I run the Debug View Hierarchy, I see the new UIViewController instead of the previous one, and the previous one (at least its UIView) stands where it is supposed to stand at the end of the transition.
I did some print out and check points and from that I can say that when the problem occurs, the animation's completion (the one in my animateTransition method) is not reached, so I cannot call the transitionContext.completeTransition method to complete or not the transition.
I could see as well that the pan goes sometimes from UIGestureRecognizerState.Began straight to UIGestureRecognizerState.Ended without going through UIGestureRecognizerState.Changed.
When it goes through UIGestureRecognizerState.Changed, both the translation and the velocity stay the same for every UIGestureRecognizerState.Changed states.
EDIT :
Here is the code:
animateTransition method
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
self.transitionContext = transitionContext
let containerView = transitionContext.containerView()
let screens: (from: UIViewController, to: UIViewController) = (transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)!, transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)!)
let parentViewController = presenting ? screens.from : screens.to
let childViewController = presenting ? screens.to : screens.from
let parentView = parentViewController.view
let childView = childViewController.view
// positionning the "to" viewController's view for the animation
if presenting {
offStageChildViewController(childView)
}
containerView.addSubview(parentView)
containerView.addSubview(childView)
let duration = transitionDuration(transitionContext)
UIView.animateWithDuration(duration, animations: {
if self.presenting {
self.onStageViewController(childView)
self.offStageParentViewController(parentView)
} else {
self.onStageViewController(parentView)
self.offStageChildViewController(childView)
}}, completion: { finished in
if transitionContext.transitionWasCancelled() {
transitionContext.completeTransition(false)
} else {
transitionContext.completeTransition(true)
}
})
}
Gesture and gesture handler:
weak var fromViewController: UIViewController! {
didSet {
let screenEdgePanRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: "presentingViewController:")
screenEdgePanRecognizer.edges = edge
fromViewController.view.addGestureRecognizer(screenEdgePanRecognizer)
}
}
func presentingViewController(pan: UIPanGestureRecognizer) {
let percentage = getPercentage(pan)
switch pan.state {
case UIGestureRecognizerState.Began:
interactive = true
presentViewController(pan)
case UIGestureRecognizerState.Changed:
updateInteractiveTransition(percentage)
case UIGestureRecognizerState.Ended:
interactive = false
if finishPresenting(pan, percentage: percentage) {
finishInteractiveTransition()
} else {
cancelInteractiveTransition()
}
default:
break
}
}
Any idea what might happen?
EDIT 2:
Here are the undisclosed methods:
override func getPercentage(pan: UIPanGestureRecognizer) -> CGFloat {
let translation = pan.translationInView(pan.view!)
return abs(translation.x / pan.view!.bounds.width)
}
override func onStageViewController(view: UIView) {
view.transform = CGAffineTransformIdentity
}
override func offStageParentViewController(view: UIView) {
view.transform = CGAffineTransformMakeTranslation(-view.bounds.width / 2, 0)
}
override func offStageChildViewController(view: UIView) {
view.transform = CGAffineTransformMakeTranslation(view.bounds.width, 0)
}
override func presentViewController(pan: UIPanGestureRecognizer) {
let location = pan.locationInView((fromViewController as! MainViewController).tableView)
let indexPath = (fromViewController as! MainViewController).tableView.indexPathForRowAtPoint(location)
if indexPath == nil {
pan.state = .Failed
return
}
fromViewController.performSegueWithIdentifier("chartSegue", sender: pan)
}
I remove the "over" adding lines => didn't fix it
I added updateInteractiveTransition in .Began, in .Ended, in both => didn't fix it
I turned on shouldRasterize on the layer of the view of my toViewController and let it on all the time => didn't fix it
But the question is why, when doing a fast interactive gesture, is it not responding quickly enough
It actually works with a fast interactive gesture as long as I leave my finger long enough. For example, if I pan very fast on more than (let say) 1cm, it's ok. It's not ok if I pan very fast on a small surface (let say again) less than 1cm
Possible candidates include the views being animated are too complicated (or have complicated effects like shading)
I thought about a complicated view as well but I don't think my view is really complicated. There are a bunch of buttons and labels, a custom UIControl acting as a segmented segment, a chart (that is loaded once the controller appeared) and a xib is loaded inside the viewController.
Ok I just created a project with the MINIMUM classes and objects in order to trigger the problem. So to trigger it, you just do a fast and brief swipe from the right to the left.
What I noticed is that it works pretty easily the first time but if you drag the view controller normally the first time, then it get much harder to trigger it (even impossible?). While in my full project, it doesn't really matter.
When I was diagnosing this problem, I noticed that the gesture's change and ended state events were taking place before animateTransition even ran. So the animation was canceled/finished before it even started!
I tried using GCD animation synchronization queue to ensure that the updating of the UIPercentDrivenInterativeTransition doesn't happen until after `animate:
private let animationSynchronizationQueue = dispatch_queue_create("com.domain.app.animationsynchronization", DISPATCH_QUEUE_SERIAL)
I then had a utility method to use this queue:
func dispatchToMainFromSynchronizationQueue(block: dispatch_block_t) {
dispatch_async(animationSynchronizationQueue) {
dispatch_sync(dispatch_get_main_queue(), block)
}
}
And then my gesture handler made sure that changes and ended states were routed through that queue:
func handlePan(gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .Began:
dispatch_suspend(animationSynchronizationQueue)
fromViewController.performSegueWithIdentifier("segueID", sender: gesture)
case .Changed:
dispatchToMainFromSynchronizationQueue() {
self.updateInteractiveTransition(percentage)
}
case .Ended:
dispatchToMainFromSynchronizationQueue() {
if isOkToFinish {
self.finishInteractiveTransition()
} else {
self.cancelInteractiveTransition()
}
}
default:
break
}
}
So, I have the gesture recognizer's .Began state suspend that queue, and I have the animation controller resume that queue in animationTransition (ensuring that the queue starts again only after that method runs before the gesture proceeds to try to update the UIPercentDrivenInteractiveTransition object.
Have the same issue, tried to use serialQueue.suspend()/resume(), does not work.
This issue is because when pan gesture is too fast, end state is earlier than animateTransition starts, then context.completeTransition can not get run, the whole animation is messed up.
My solution is forcing to run context.completeTransition when this situation happened.
For example, I have two classes:
class SwipeInteractor: UIPercentDrivenInteractiveTransition {
var interactionInProgress = false
...
}
class AnimationController: UIViewControllerAnimatedTransitioning {
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
if !swipeInteractor.interactionInProgress {
DispatchQueue.main.asyncAfter(deadline: .now()+transitionDuration) {
if context.transitionWasCancelled {
toView?.removeFromSuperview()
} else {
fromView?.removeFromSuperview()
}
context.completeTransition(!context.transitionWasCancelled)
}
}
...
}
...
}
interactionInProgress is set to true when gesture began, set to false when gesture ends.
I had a similar problem, but with programmatic animation triggers not triggering the animation completion block. My solution was like Sam's, except instead of dispatching after a small delay, manually call finish on the UIPercentDrivenInteractiveTransition instance.
class SwipeInteractor: UIPercentDrivenInteractiveTransition {
var interactionInProgress = false
...
}
class AnimationController: UIViewControllerAnimatedTransitioning {
private var swipeInteractor: SwipeInteractor
..
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
...
if !swipeInteractor.interactionInProgress {
swipeInteractor.finish()
}
...
UIView.animateWithDuration(...)
}
}