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:
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.animateKeyFrames
and 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 philosophyThere 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.
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
, calldismiss(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 thecancel()
method ofUIPercentDrivenInteractiveTransition
- After such a cancel, tapping the close button initiates a new
dismiss(animated:completion:)
but because theUIPercentDrivenInteractiveTransition
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 callingcancel
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 allocatedUIPercentDrivenInteractiveTransition
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
Restricting App Installations from Appstore Only to Users with iPhone 5/5S/5C
Swift 3: Type 'Any' Has No Subscript Members
How to Get Data from Firebase in Descending Order of Value
Clear Background for Form Sections in Swiftui
Auto Login Dropbox Account on Core API Without Login Prompt
App Not Sized Properly iOS 8 iPhone Simulator
What's a Redirect URI? How Does It Apply to iOS App for Oauth2.0
iOS 9 Segue Causes App to Freeze (No Crash or Error Thrown)
Color All Occurrences of String in Swift
Resize Textfield Based on Content
Screenshot Showing Up Blank - Swift
What Is Kcferrordomaincfnetwork Code=303
Once Jailbroken, Will iOS Apps Run with Root Privilege
Automatically Adjustable View Height Based on Text Height in Swiftui
Update Label from Background Timer