Custom Interactive Transition Animation

custom interactive transition animation

See WWDC 2013 video Custom Transitions Using View Controllers for discussion of the transition delegate, animation controller, and interaction controller. See WWDC 2014 videos View Controller Advancements in iOS 8 and A Look Inside Presentation Controllers for introduction to presentation controllers (which you should also use).

The basic idea is to create a transition delegate object that identifies what animation controller, interaction controller, and presentation controller will be used for the custom transition:

class TransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {

/// Interaction controller
///
/// If gesture triggers transition, it will set will manage its own
/// `UIPercentDrivenInteractiveTransition`, but it must set this
/// reference to that interaction controller here, so that this
/// knows whether it's interactive or not.

weak var interactionController: UIPercentDrivenInteractiveTransition?

func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return PullDownAnimationController(transitionType: .presenting)
}

func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return PullDownAnimationController(transitionType: .dismissing)
}

func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
return PresentationController(presentedViewController: presented, presenting: presenting)
}

func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactionController
}

func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactionController
}

}

You then just have to specify that a custom transition is being used and what transitioning delegate should be used. You can do that when you instantiate the destination view controller, or you can specify that as part of the init for the destination view controller, such as:

class SecondViewController: UIViewController {

let customTransitionDelegate = TransitioningDelegate()

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)

modalPresentationStyle = .custom
transitioningDelegate = customTransitionDelegate
}

...
}

The animation controller specifies the details of the animation (how to animate, duration to be used for non-interactive transitions, etc.):

class PullDownAnimationController: NSObject, UIViewControllerAnimatedTransitioning {

enum TransitionType {
case presenting
case dismissing
}

let transitionType: TransitionType

init(transitionType: TransitionType) {
self.transitionType = transitionType

super.init()
}

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let inView = transitionContext.containerView
let toView = transitionContext.view(forKey: .to)!
let fromView = transitionContext.view(forKey: .from)!

var frame = inView.bounds

switch transitionType {
case .presenting:
frame.origin.y = -frame.size.height
toView.frame = frame

inView.addSubview(toView)
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
toView.frame = inView.bounds
}, completion: { finished in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
case .dismissing:
toView.frame = frame
inView.insertSubview(toView, belowSubview: fromView)

UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
frame.origin.y = -frame.size.height
fromView.frame = frame
}, completion: { finished in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}

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

The above animation controller handles both presenting and dismissing, but if that feels too complicated, you theoretically could split it into two classes, one for presenting and another for dismissing. But I don't like to have two different classes so tightly coupled, so I'll bear the cost of the slight complexity of animateTransition in order to make sure it's all nicely encapsulated in one class.

Anyway, the next object we want is the presentation controller. In this case, the presentation controller tells us to remove the originating view controller's view from the view hierarchy. (We do this, in this case, because the scene you're transitioning to happens to occupy the whole screen, so there's no need to keep the old view in the view hierarchy.) If you were adding any other additional chrome (e.g. adding dimming/blurring views, etc.), that would belong in the presentation controller.

Anyways, in this case, the presentation controller is quite simple:

class PresentationController: UIPresentationController {
override var shouldRemovePresentersView: Bool { return true }
}

Finally, you presumably want an gesture recognizer that:

  • instantiates the UIPercentDrivenInteractiveTransition;
  • initiates the transition itself;
  • updates the UIPercentDrivenInteractiveTransition as the gesture progresses;
  • either cancels or finishes the interactive transition when the gesture is done; and
  • removes the UIPercentDrivenInteractiveTransition when it's done (to make sure it's not lingering about so it doesn't interfere with any non-interactive transitions you might want to do later ... this is a subtle little point that's easy to overlook).

So the "presenting" view controller might have a gesture recognizer that might do something like:

class ViewController: UIViewController {

override func viewDidLoad() {
super.viewDidLoad()

let panDown = UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:)))
view.addGestureRecognizer(panDown)
}

var interactionController: UIPercentDrivenInteractiveTransition?

// pan down transitions to next view controller

func handleGesture(_ gesture: UIPanGestureRecognizer) {
let translate = gesture.translation(in: gesture.view)
let percent = translate.y / gesture.view!.bounds.size.height

if gesture.state == .began {
let controller = storyboard!.instantiateViewController(withIdentifier: "SecondViewController") as! SecondViewController
interactionController = UIPercentDrivenInteractiveTransition()
controller.customTransitionDelegate.interactionController = interactionController

show(controller, sender: self)
} else if gesture.state == .changed {
interactionController?.update(percent)
} else if gesture.state == .ended || gesture.state == .cancelled {
let velocity = gesture.velocity(in: gesture.view)
if (percent > 0.5 && velocity.y == 0) || velocity.y > 0 {
interactionController?.finish()
} else {
interactionController?.cancel()
}
interactionController = nil
}
}

}

You'd probably also want to change this so it only recognizes downward gestures (rather than any, old pan), but hopefully this illustrates the idea.

And you presumably want the "presented" view controller to have a gesture recognizer for dismissing the scene:

class SecondViewController: UIViewController {

let customTransitionDelegate = TransitioningDelegate()

required init?(coder aDecoder: NSCoder) {
// as shown above
}

override func viewDidLoad() {
super.viewDidLoad()

let panUp = UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:)))
view.addGestureRecognizer(panUp)
}

// pan up transitions back to the presenting view controller

var interactionController: UIPercentDrivenInteractiveTransition?

func handleGesture(_ gesture: UIPanGestureRecognizer) {
let translate = gesture.translation(in: gesture.view)
let percent = -translate.y / gesture.view!.bounds.size.height

if gesture.state == .began {
interactionController = UIPercentDrivenInteractiveTransition()
customTransitionDelegate.interactionController = interactionController

dismiss(animated: true)
} else if gesture.state == .changed {
interactionController?.update(percent)
} else if gesture.state == .ended {
let velocity = gesture.velocity(in: gesture.view)
if (percent > 0.5 && velocity.y == 0) || velocity.y < 0 {
interactionController?.finish()
} else {
interactionController?.cancel()
}
interactionController = nil
}

}

@IBAction func didTapButton(_ sender: UIButton) {
dismiss(animated: true)
}

}

See https://github.com/robertmryan/SwiftCustomTransitions for a demonstration of the above code.

It looks like:

interactive gesture demo

But, bottom line, custom transitions are a little complicated, so I again refer you to those original videos. Make sure you watch them in some detail before posting any further questions. Most of your questions will likely be answered in those videos.

iOS Swift: Custom interactive transition

Solved animating a snapshot of the destination view, instead of directly animating the destination view.

let to = transitionContext.view(forKey: .to)
let toViewSnapshot = to.snapshotView(afterScreenUpdates: true)

Just use the toViewSnapshot for the animation

How to do interactive transition + ending animation?

In the end, I use UIView.animateKeyFramesand dividing the interactive transition into two-part animation (as explained in the question):

let progressUntilDismissing = 0.4
UIView.animateKeyframes(withDuration: 0.5, delay: 0, options: [], animations: {

UIView.addKeyframe(withRelativeStartTime: 0.0,
relativeDuration: progressUntilDismissing,
animations: {
// interactive dismissing animation...
})

UIView.addKeyframe(withRelativeStartTime: progressUntilDismissing,
relativeDuration: (1 - progressUntilDismissing),
animations: {
// closing dismissing animation...
})
}) { (finished) in
//...
}

Then in the pan gesture recognizer, I calculate the pan progress and determine if it passes progressUntilDismissing or not.

If yes, call finish() on UIPercentDrivenInteractiveTransition subclass, it will animate the closing dismissing animation automatically.

In case anyone is curious, this is what I'm playing with:
AppStoreTodayInteractiveTransition

Custom Interactive Presentation Transition


Update 2015.11.19

A demo project as well

Obj-C -> https://github.com/saiday/DraggableViewControllerDemo

Swift 2.x -> https://github.com/ostatnicky/DraggableViewController

Swift 4.x -> https://github.com/satishVekariya/DraggableViewController

Thanks @avdyushin mentioned my blog post.

And yes, my post Draggable view controller? Interactive view controller! is a tutorial just about your topic.

Quoted from my blog post:

How to do it

Design philosophy

There is no two UIViewController in together, instead there's only one UIViewController at a time. When user begin drag on certain subview, present another view controller with custom UIViewControllerAnimatedTransitioning by custom UIPercentDrivenInteractiveTransition protocol.

Technical things

These two protocols is the foundation of customize interactive UIViewController transitions. In case you don't know yet, take a glance before starting.

1. UIViewControllerAnimatedTransitioning protocol

2. UIPercentDrivenInteractiveTransition protocol

Xcode 10: Custom Animated Transitions Stuck

So found the source of why transitions where stuck between VC B and A but still don't really understand what's different between Xcode9/iOS11 and Xcode10/iOS12 that produced the different behaviour.

To keep it short:

  • When using a pan gesture to initiate an interactive transition to dismiss VC B I allocate a UIPercentDrivenInteractiveTransition, call dismiss(animated:completion:) on the VC and update it according to the pan progress. In some cases, when the pan didn't traverse enough "ground" my gesture handler deems the transitions canceled and calls the cancel() method of UIPercentDrivenInteractiveTransition
  • After such a cancel, tapping the close button initiates a new dismiss(animated:completion:) but because the UIPercentDrivenInteractiveTransition is still allocated it is returned by my transition delegate and the OS actually attempts an interactive dismissal although that wasn't the intent. This is a bug on my part as after calling cancel I should also make sure the transition delegate doesn't attempt an interactive transition in this case (although on Xcode9/iOS11 it didn't).
  • The reason the transition is 'stuck' is because its an interactive transition with no updates (no gesture updates when tapping 'close'. I verified this by forcing a finish() on the mistakenly allocated UIPercentDrivenInteractiveTransition so it completes and everything is back to normal.

Making sure the dismiss transition is either interactive or not based on the user interaction, especially after canceling an interactive one, fixed the problem.

What I don't understand is why isn't this consistent behaviour between Xcode/iOS versions. This problem never ever happened to me before on any device or simulator.
There's something different in the way custom animations/transitions are handled - nothing in the Apple docs that could explain this - perhaps in the internal implementation of the transition context.

From a naive "eye-test" it seems that transition animations on the Xcode10 simulator are slower in reaction time and less smooth than before but still doesn't fully explain it.



Related Topics



Leave a reply



Submit