Why does UIView.animate work with an interactive controller transition, but UIViewPropertyAnimator doesn't?
Asked Answered
L

1

13

For boilerplate on setting up a gesture recognizer and such for the interactive transition, see this answer.

I am experimenting with interactive transitions, and spent quite a bit of time trying to figure out why the controllers would transition normally instead of scrubbing through according to the gesture. I discovered that it was not working because I am using a UIViewPropertyAnimator. Switching to the older UIView animation blocks work out of the box. Why? What is the difference in implementation?

func animateTransition(using transitionContext: UIViewControllerContextTransitioning)
{
    // Ignore the forced unwrapping, for sake of brevity.
    let view_From       = transitionContext.viewController(forKey: .from)!.view!
    let view_To         = transitionContext.viewController(forKey: .to)!.view!
    transitionContext.containerView.insertSubview(view_To, aboveSubview: view_From)

    view_To.alpha = 0

    // This animation block works - it will follow the progress value of the interaction controller
    UIView.animate(withDuration: 1, animations: {
        view_From.alpha = 0.0
        view_To.alpha = 1.0
    }, completion: { finished in
        transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
    })

    // This animation block fails - it will play out normally and not be interactive
    /*
    let animator = UIViewPropertyAnimator(duration: 1, curve: .linear)
    animator.addAnimations {
        view_To.alpha = 1
        view_From.alpha = 0
    }
    animator.addCompletion { (position) in
        switch position {
        case .end: print("Completion handler called at end of animation")
        case .current: print("Completion handler called mid-way through animation")
        case .start: print("Completion handler called  at start of animation")
        }
        transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
    }
    animator.startAnimation()
    */
}
Landwaiter answered 5/11, 2017 at 22:15 Comment(0)
T
27

With introduction of the UIViewPropertyAnimator in iOS 10 the UIViewControllerAnimatedTransitioning protocol got updated, too. They've added an optional func interruptibleAnimator(using: UIViewControllerContextTransitioning) that you don't have to implement (I guess for backward compatibility). But it was added exactly for the use case you mention here: to take advantage of the new UIViewPropertyAnimator.

So to get what you want: first, you have to implement interruptibleAnimator(using:) to create the animator - you don't create it in animateTransition(using:).

As per comment in the source code of UIViewControllerAnimatedTransitioning (emphasis is mine)(I have no idea why the documentation does not contain this info):

A conforming object implements this method if the transition it creates can be interrupted. For example, it could return an instance of a UIViewPropertyAnimator. It is expected that this method will return the same instance for the life of a transition.

You have to return the same animator for the duration of the transition. That's why you will find

private var animatorForCurrentSession: UIViewImplicitlyAnimating?

property in my BackAnimator implementation - I store the current animator there to return it if the transition haven't ended.

When the interruptibleAnimator(using:) is implemented, the environment will take that animator and use it instead of animating using animateTransition(using:). But to keep the contract of the protocol, animateTransition(using:) should be able to animate the transition - but you can simply use the interruptibleAnimator(using:) to create an animator and run the animation there.

Following is a working BackAnimator implementation that you can use with the example you referred in this SO question. I used your code as basis, but you can simply swap my BackAnimator for their implementation and you are good to go (I was testing it on their example).

class BackAnimator : NSObject, UIViewControllerAnimatedTransitioning {
    // property for keeping the animator for current ongoing transition
    private var animatorForCurrentTransition: UIViewImplicitlyAnimating?

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.5
    }

    func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
        // as per documentation, the same object should be returned for the ongoing transition
        if let animatorForCurrentSession = animatorForCurrentTransition {
            return animatorForCurrentSession
        }
        // normal creation of the propertyAnimator
        let view_From       = transitionContext.viewController(forKey: .from)!.view!
        let view_To         = transitionContext.viewController(forKey: .to)!.view!
        transitionContext.containerView.insertSubview(view_To, aboveSubview: view_From)

        view_To.alpha = 0
        let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: .linear)
        animator.addAnimations {
            view_To.alpha = 1
            view_From.alpha = 0
        }
        animator.addCompletion { (position) in
            switch position {
            case .end: print("Completion handler called at end of animation")
            case .current: print("Completion handler called mid-way through animation")
            case .start: print("Completion handler called  at start of animation")
            }
            // transition completed, reset the current animator:
            self.animatorForCurrentTransition = nil

            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
        // keep the reference to current animator
        self.animatorForCurrentTransition = animator
        return animator
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // animateTransition should work too, so let's just use the interruptibleAnimator implementation to achieve it
        let anim = self.interruptibleAnimator(using: transitionContext)
        anim.startAnimation()
    }
}

Also notice that the animator returned by the interruptibleAnimator(using:) is not started by us - the environment will start it when appropriate.

P.S.: Most of my knowledge on the subject comes from trying to implement an open source container that would allow custom interactive transitions between its containees - InteractiveTransitioningContainer. Maybe you'll find there some inspiration, too :).

Tittup answered 4/1, 2018 at 7:36 Comment(2)
Awesome. I didnt' think of retrieving the animator and starting it in interruptibleAnimator. This solution worked out great both interactive and not. Thank you much.Liberty
I ended up using your code, but I start the animator just before returning from interruptibleAnimator(...). I left animateTransition(...) empty. I read somewhere that this is okay, but can't find it now to reference. I believe it was in apple's docs. Just wanted to share.Liberty

© 2022 - 2024 — McMap. All rights reserved.