This is my first iOS development and so I am using this tiny project to learn how the system works and how the language (swift) works too.
I am trying to make a drawer menu similar to android app and a certain number of iOS app.
I found this tutorial that explains well how to do it and how it works : here
Now since I am using a NavigationController with show I have to modify the way it is done.
I swapped the UIViewControllerTransitioningDelegate to a UINavigationControllerDelegate so I can override the navigationController function.
This means I can get the drawer out and dismiss it. It works well with a button or with the gesture.
My problem is the following : If I don't finish to drag the drawer far enough for it to reach the threshold and finishing the animation, it will be cancel and hidden. This is all well and good but when that happens there is no call to a dismiss function meaning that the snapshot I put in place in the PresentMenuAnimator is still in front of all the layers and I am stuck there even though I can interact with what's behind it.
How can I catch a dismiss or a cancel with the NavigationController ? Is that possible ?
Interactor :
import UIKit
class Interactor:UIPercentDrivenInteractiveTransition {
var hasStarted: Bool = false;
var shouldFinish: Bool = false;
}
MenuHelper :
import Foundation
import UIKit
enum Direction {
case Up
case Down
case Left
case Right
}
struct MenuHelper {
static let menuWith:CGFloat = 0.8;
static let percentThreshold:CGFloat = 0.6;
static let snapshotNumber = 12345;
static func calculateProgress(translationInView:CGPoint, viewBounds:CGRect, direction: Direction) -> CGFloat {
let pointOnAxis:CGFloat;
let axisLength:CGFloat;
switch direction {
case .Up, .Down :
pointOnAxis = translationInView.y;
axisLength = viewBounds.height;
case .Left, .Right :
pointOnAxis = translationInView.x;
axisLength = viewBounds.width;
}
let movementOnAxis = pointOnAxis/axisLength;
let positiveMovementOnAxis:Float;
let positiveMovementOnAxisPercent:Float;
switch direction {
case .Right, .Down:
positiveMovementOnAxis = fmaxf(Float(movementOnAxis), 0.0);
positiveMovementOnAxisPercent = fminf(positiveMovementOnAxis, 1.0);
return CGFloat(positiveMovementOnAxisPercent);
case .Left, .Up :
positiveMovementOnAxis = fminf(Float(movementOnAxis), 0.0);
positiveMovementOnAxisPercent = fmaxf(positiveMovementOnAxis, -1.0);
return CGFloat(-positiveMovementOnAxisPercent);
}
}
static func mapGestureStateToInteractor(gestureState:UIGestureRecognizerState, progress:CGFloat, interactor: Interactor?, triggerSegue: () -> Void ) {
guard let interactor = interactor else {return };
switch gestureState {
case .began :
interactor.hasStarted = true;
interactor.shouldFinish = false;
triggerSegue();
case .changed :
interactor.shouldFinish = progress > percentThreshold;
interactor.update(progress);
case .cancelled :
interactor.hasStarted = false;
interactor.shouldFinish = false;
interactor.cancel();
case .ended :
interactor.hasStarted = false;
interactor.shouldFinish
? interactor.finish()
: interactor.cancel();
interactor.shouldFinish = false;
default :
break;
}
}
}
MenuNavigationController :
import Foundation
import UIKit
class MenuNavigationController: UINavigationController, UINavigationControllerDelegate {
let interactor = Interactor()
override func viewDidLoad() {
super.viewDidLoad();
self.delegate = self;
}
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if((toVC as? MenuViewController) != nil) {
return PresentMenuAnimator();
}
else {
return DismissMenuAnimator();
}
}
func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactor.hasStarted ? interactor : nil;
}
}
PresentMenuAnimator :
import UIKit
class PresentMenuAnimator: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.6;
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard
let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)
else {return};
let containerView = transitionContext.containerView;
containerView.insertSubview(toVC.view, aboveSubview: fromVC.view);
let snapshot = fromVC.view.snapshotView(afterScreenUpdates: false);
snapshot?.tag = MenuHelper.snapshotNumber;
snapshot?.isUserInteractionEnabled = false;
snapshot?.layer.shadowOpacity = 0.7;
containerView.insertSubview(snapshot!, aboveSubview: toVC.view);
fromVC.view.isHidden = true;
UIView.animate(withDuration: transitionDuration(using: transitionContext),
animations: {snapshot?.center.x+=UIScreen.main.bounds.width*MenuHelper.menuWith;},
completion: {_ in
fromVC.view.isHidden = false;
transitionContext.completeTransition(!transitionContext.transitionWasCancelled);}
);
}
}
DismissMenuAnimator :
import UIKit
class DismissMenuAnimator : NSObject {
}
extension DismissMenuAnimator : UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.6;
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard
let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)
else {
return
}
let containerView = transitionContext.containerView;
let snapshot = containerView.viewWithTag(MenuHelper.snapshotNumber)
UIView.animate(withDuration: transitionDuration(using: transitionContext),
animations: {
snapshot?.frame = CGRect(origin: CGPoint.zero, size: UIScreen.main.bounds.size)
},
completion: { _ in
let didTransitionComplete = !transitionContext.transitionWasCancelled
if didTransitionComplete {
containerView.insertSubview(toVC.view, aboveSubview: fromVC.view)
snapshot?.removeFromSuperview()
}
transitionContext.completeTransition(didTransitionComplete)
}
)
}
}
It is possible to know whether the animation was cancelled, and it can be caught in the func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) method from UINavigationControllerDelegate.
Here's a snippet of code on how to do so:
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
navigationController.transitionCoordinator?.notifyWhenInteractionEnds { context in
if context.isCancelled {
// The interactive back transition was cancelled
}
}
}
This method could be put in your MenuNavigationController, in which you could persist your PresentMenuAnimator and tell it that the transition was cancelled, and in there remove the snapshot that's hanging around.
To fix the problem I added a verification in PresentMenuAnimator to check if it the animation was canceled.
If it was then remove the snapshot in the UIView.Animate.
Related
So, straight to the problem:
I've created a custom UIViewControllerTransitioningDelegate that I use to animate a view from one view controller, to full-screen in another view controller. Im doing this by creating UIViewControllerAnimatedTransitioning-objects that animate the presented view's frame. And it works great! Except when I try to adjust the additionalSafeAreaInsets of the view controller owning the view during dismissal...
It looks like this property is not accounted for when I'm trying to animate the dismissal of the view controller and its view. It works fine during presentation.
The gif below shows how it looks. The red box is the safe area (plus some padding) of the presented view - which I'm trying to compensate for during animation, using the additionalSafeAreaInsets property of the view controller owning the view.
As the gif shows, the safe area is properly adjusted during presentation but not during dismissal.
So, what I want is: use additionalSafeAreaInsets to diminish the effect of the safe area during animation, by setting additionalSafeAreaInsets to the "inverted" values of the safe area. So that the effective safe area starts at 0 and "animates" to the expected value during presentation, and starts at expected value and "animates" to 0 during dismissal.
(I'm quoting "animates", since its actually the view's frame that is animated. But UIKit/Auto Layout use these properties when calculating the frames)
Any thoughts on how to battle this issue is great welcome!
The code for the custom UIViewControllerTransitioningDelegate is provided below.
//
// FullScreenTransitionManager.swift
//
import Foundation
import UIKit
// MARK: FullScreenPresentationController
final class FullScreenPresentationController: UIPresentationController {
private let backgroundView: UIView = {
let view = UIView()
view.backgroundColor = .systemBackground
view.alpha = 0
return view
}()
private lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(onTap))
#objc private func onTap(_ gesture: UITapGestureRecognizer) {
presentedViewController.dismiss(animated: true)
}
}
// MARK: UIPresentationController
extension FullScreenPresentationController {
override func presentationTransitionWillBegin() {
guard let containerView = containerView else { return }
containerView.addGestureRecognizer(tapGestureRecognizer)
containerView.addSubview(backgroundView)
backgroundView.frame = containerView.frame
guard let transitionCoordinator = presentingViewController.transitionCoordinator else { return }
transitionCoordinator.animate(alongsideTransition: { context in
self.backgroundView.alpha = 1
})
}
override func presentationTransitionDidEnd(_ completed: Bool) {
if !completed {
backgroundView.removeFromSuperview()
containerView?.removeGestureRecognizer(tapGestureRecognizer)
}
}
override func dismissalTransitionWillBegin() {
guard let transitionCoordinator = presentingViewController.transitionCoordinator else { return }
transitionCoordinator.animate(alongsideTransition: { context in
self.backgroundView.alpha = 0
})
}
override func dismissalTransitionDidEnd(_ completed: Bool) {
if completed {
backgroundView.removeFromSuperview()
containerView?.removeGestureRecognizer(tapGestureRecognizer)
}
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
guard
let containerView = containerView,
let presentedView = presentedView
else { return }
coordinator.animate(alongsideTransition: { context in
self.backgroundView.frame = containerView.frame
presentedView.frame = self.frameOfPresentedViewInContainerView
})
}
}
// MARK: FullScreenTransitionManager
final class FullScreenTransitionManager: NSObject, UIViewControllerTransitioningDelegate {
private weak var anchorView: UIView?
init(anchorView: UIView) {
self.anchorView = anchorView
}
func presentationController(forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController) -> UIPresentationController? {
FullScreenPresentationController(presentedViewController: presented, presenting: presenting)
}
func animationController(forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
let anchorFrame = anchorView?.safeAreaLayoutGuide.layoutFrame ?? CGRect(origin: presented.view.center, size: .zero)
return FullScreenAnimationController(animationType: .present,
anchorFrame: anchorFrame)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
let anchorFrame = anchorView?.safeAreaLayoutGuide.layoutFrame ?? CGRect(origin: dismissed.view.center, size: .zero)
return FullScreenAnimationController(animationType: .dismiss,
anchorFrame: anchorFrame)
}
}
// MARK: UIViewControllerAnimatedTransitioning
final class FullScreenAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
enum AnimationType {
case present
case dismiss
}
private let animationType: AnimationType
private let anchorFrame: CGRect
private let animationDuration: TimeInterval
private var propertyAnimator: UIViewPropertyAnimator?
init(animationType: AnimationType, anchorFrame: CGRect, animationDuration: TimeInterval = 0.3) {
self.animationType = animationType
self.anchorFrame = anchorFrame
self.animationDuration = animationDuration
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
animationDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
switch animationType {
case .present:
guard
let toViewController = transitionContext.viewController(forKey: .to)
else {
return transitionContext.completeTransition(false)
}
transitionContext.containerView.addSubview(toViewController.view)
propertyAnimator = presentAnimator(with: transitionContext, animating: toViewController)
case .dismiss:
guard
let fromViewController = transitionContext.viewController(forKey: .from)
else {
return transitionContext.completeTransition(false)
}
propertyAnimator = dismissAnimator(with: transitionContext, animating: fromViewController)
}
}
private func presentAnimator(with transitionContext: UIViewControllerContextTransitioning,
animating viewController: UIViewController) -> UIViewPropertyAnimator {
let finalFrame = transitionContext.finalFrame(for: viewController)
let safeAreaInsets = transitionContext.containerView.safeAreaInsets
let safeAreaCompensation = UIEdgeInsets(top: -safeAreaInsets.top,
left: -safeAreaInsets.left,
bottom: -safeAreaInsets.bottom,
right: -safeAreaInsets.right)
viewController.additionalSafeAreaInsets = safeAreaCompensation
viewController.view.frame = anchorFrame
viewController.view.setNeedsLayout()
viewController.view.layoutIfNeeded()
return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseInOut, .layoutSubviews], animations: {
viewController.additionalSafeAreaInsets = .zero
viewController.view.frame = finalFrame
viewController.view.setNeedsLayout()
viewController.view.layoutIfNeeded()
}, completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
private func dismissAnimator(with transitionContext: UIViewControllerContextTransitioning,
animating viewController: UIViewController) -> UIViewPropertyAnimator {
let finalFrame = anchorFrame
let safeAreaInsets = transitionContext.containerView.safeAreaInsets
let safeAreaCompensation = UIEdgeInsets(top: -safeAreaInsets.top,
left: -safeAreaInsets.left,
bottom: -safeAreaInsets.bottom,
right: -safeAreaInsets.right)
viewController.view.setNeedsLayout()
viewController.view.layoutIfNeeded()
return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseInOut, .layoutSubviews], animations: {
viewController.additionalSafeAreaInsets = safeAreaCompensation
viewController.view.frame = finalFrame
viewController.view.setNeedsLayout()
viewController.view.layoutIfNeeded()
}, completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}
After some debugging I managed to find a workaround to this problem.
In short, it looks like the safe area is not updated after UIViewController.viewWillDisappear is called, and hence any changes to .additionalSafeAreaInsets is ignored (since these insets modifies the safe area of the view controller's view).
My current workaround is somewhat hacky, but it gets the job done. Since UIViewControllerTransitioningDelegate.animationController(forDismissed...) is called right before UIViewController.viewWillDisappear and UIViewControllerAnimatedTransitioning.animateTransition(using transitionContext...), I start the dismiss animation already in that method. That way the layout calculations for the animation get correct, and the correct safe area is set.
Below is the code for my custom UIViewControllerTransitioningDelegate with the workaround. Note: I've removed the use of .additionalSafeAreaInsets since its not necessary at all! And I've no idea why I thought I needed it in the first place...
//
// FullScreenTransitionManager.swift
//
import Foundation
import UIKit
// MARK: FullScreenPresentationController
final class FullScreenPresentationController: UIPresentationController {
private let backgroundView: UIView = {
let view = UIView()
view.backgroundColor = .systemBackground
view.alpha = 0
return view
}()
private lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(onTap))
#objc private func onTap(_ gesture: UITapGestureRecognizer) {
presentedViewController.dismiss(animated: true)
}
}
// MARK: UIPresentationController
extension FullScreenPresentationController {
override func presentationTransitionWillBegin() {
guard let containerView = containerView else { return }
containerView.addGestureRecognizer(tapGestureRecognizer)
containerView.addSubview(backgroundView)
backgroundView.frame = containerView.frame
guard let transitionCoordinator = presentingViewController.transitionCoordinator else { return }
transitionCoordinator.animate(alongsideTransition: { context in
self.backgroundView.alpha = 1
})
}
override func presentationTransitionDidEnd(_ completed: Bool) {
if !completed {
backgroundView.removeFromSuperview()
containerView?.removeGestureRecognizer(tapGestureRecognizer)
}
}
override func dismissalTransitionWillBegin() {
guard let transitionCoordinator = presentingViewController.transitionCoordinator else { return }
transitionCoordinator.animate(alongsideTransition: { context in
self.backgroundView.alpha = 0
})
}
override func dismissalTransitionDidEnd(_ completed: Bool) {
if completed {
backgroundView.removeFromSuperview()
containerView?.removeGestureRecognizer(tapGestureRecognizer)
}
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
guard let containerView = containerView else { return }
coordinator.animate(alongsideTransition: { context in
self.backgroundView.frame = containerView.frame
})
}
}
// MARK: FullScreenTransitionManager
final class FullScreenTransitionManager: NSObject, UIViewControllerTransitioningDelegate {
fileprivate enum AnimationState {
case present
case dismiss
}
private weak var anchorView: UIView?
private var animationState: AnimationState = .present
private var animationDuration: TimeInterval = Resources.animation.duration
private var anchorViewFrame: CGRect = .zero
private var propertyAnimator: UIViewPropertyAnimator?
init(anchorView: UIView) {
self.anchorView = anchorView
}
func presentationController(forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController) -> UIPresentationController? {
FullScreenPresentationController(presentedViewController: presented, presenting: presenting)
}
func animationController(forPresented presented: UIViewController,
presenting: UIViewController,
source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
prepare(animationState: .present)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
// Starting the animation here, since UIKit do not update safe area insets after UIViewController.viewWillDisappear() is called
defer {
propertyAnimator = dismissAnimator(animating: dismissed)
}
return prepare(animationState: .dismiss)
}
}
// MARK: UIViewControllerAnimatedTransitioning
extension FullScreenTransitionManager: UIViewControllerAnimatedTransitioning {
private func prepare(animationState: AnimationState,
animationDuration: TimeInterval = Resources.animation.duration) -> UIViewControllerAnimatedTransitioning? {
guard let anchorView = anchorView else { return nil }
self.animationState = animationState
self.animationDuration = animationDuration
self.anchorViewFrame = anchorView.safeAreaLayoutGuide.layoutFrame
return self
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
animationDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
switch animationState {
case .present:
guard
let toViewController = transitionContext.viewController(forKey: .to)
else {
return transitionContext.completeTransition(false)
}
propertyAnimator = presentAnimator(with: transitionContext, animating: toViewController)
case .dismiss:
guard
let fromViewController = transitionContext.viewController(forKey: .from)
else {
return transitionContext.completeTransition(false)
}
propertyAnimator = updatedDismissAnimator(with: transitionContext, animating: fromViewController)
}
}
private func presentAnimator(with transitionContext: UIViewControllerContextTransitioning,
animating viewController: UIViewController) -> UIViewPropertyAnimator {
transitionContext.containerView.addSubview(viewController.view)
let finalFrame = transitionContext.finalFrame(for: viewController)
viewController.view.frame = anchorViewFrame
viewController.view.layoutIfNeeded()
return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext),
delay: 0,
options: [.curveEaseInOut],
animations: {
viewController.view.frame = finalFrame
viewController.view.layoutIfNeeded()
}, completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
private func dismissAnimator(animating viewController: UIViewController) -> UIViewPropertyAnimator {
let finalFrame = anchorViewFrame
return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: animationDuration,
delay: 0,
options: [.curveEaseInOut],
animations: {
viewController.view.frame = finalFrame
viewController.view.layoutIfNeeded()
})
}
private func updatedDismissAnimator(with transitionContext: UIViewControllerContextTransitioning,
animating viewController: UIViewController) -> UIViewPropertyAnimator {
let propertyAnimator = self.propertyAnimator ?? dismissAnimator(animating: viewController)
propertyAnimator.addCompletion({ _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
self.propertyAnimator = propertyAnimator
return propertyAnimator
}
}
Also, here is a link to a Stack Overflow post regarding the safe area not updating after UIViewController.viewWillDisappear. And a link to a similar post on the Apple forums.
I have a small custom view controller (it's a menu) I'm presenting, and I want you to be able to halt the presentation half way through if you change your mind.
Per this WWDC 2016 video, doing so should be pretty easy and just require the implementation of interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating, and return, say a UIViewPropertyAnimator.
However I'm not finding this to be the case. If I return my property animator there, and programmatically call my view controller to be dismissed half way through the transition, it waits until the animation is fully concluded for the dismiss animation to occur. It won't interrupt.
*Note: this is with a "normal" animation controller, not an interactive one. Per the video this should be fine, it's not inherently an interactive transition"
I set up my corresponding controllers as shown:
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return MyMenuAnimationController(type: .presentation)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return MyMenuAnimationController(type: .dismissal)
}
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
return MyMenuPresentationController(presentedViewController: presented, presenting: presenting)
}
class MyMenuAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
enum AnimationControllerType { case presentation, dismissal }
let type: AnimationControllerType
var animatorForCurrentSession: UIViewPropertyAnimator?
init(type: AnimationControllerType) {
self.type = type
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 5.0 // Artificially long for testing
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
if type == .presentation, let myMenuMenu: MyMenuMenu = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) as? MyMenuMenu {
transitionContext.containerView.addSubview(myMenuMenu.view)
}
interruptibleAnimator(using: transitionContext).startAnimation()
}
func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
if let animatorForCurrentSession = animatorForCurrentSession {
return animatorForCurrentSession
}
let propertyAnimator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), dampingRatio: 0.75)
propertyAnimator.isInterruptible = true
propertyAnimator.isUserInteractionEnabled = true
let isPresenting = type == .presentation
guard let myMenuMenu: MyMenuMenu = {
return (isPresenting ? transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) : transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)) as? MyMenuMenu
}() else {
preconditionFailure("Menu should be accessible")
}
myMenuMenu.view.frame = transitionContext.finalFrame(for: myMenuMenu)
let initialAlpha: CGFloat = isPresenting ? 0.0 : 1.0
let finalAlpha: CGFloat = isPresenting ? 1.0 : 0.0
myMenuMenu.view.alpha = initialAlpha
propertyAnimator.addAnimations {
myMenuMenu.view.alpha = finalAlpha
}
propertyAnimator.addCompletion { (position) in
guard position == .end else { return }
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
self.animatorForCurrentSession = nil
}
self.animatorForCurrentSession = propertyAnimator
return propertyAnimator
}
private func calculateTranslationRequired(forMyMenuMenuFrame myMenuMenuFrame: CGRect, toDesiredPoint desiredPoint: CGPoint) -> CGVector {
let centerPointOfMenuView = CGPoint(x: myMenuMenuFrame.origin.x + (myMenuMenuFrame.width / 2.0), y: myMenuMenuFrame.origin.y + (myMenuMenuFrame.height / 2.0))
let translationRequired = CGVector(dx: desiredPoint.x - centerPointOfMenuView.x, dy: desiredPoint.y - centerPointOfMenuView.y)
return translationRequired
}
}
And a simple presentation controller:
class MyMenuPresentationController: UIPresentationController {
override var frameOfPresentedViewInContainerView: CGRect {
return CGRect(x: 100, y: 100, width: 100, height: 100)
}
}
But as I said, if I present it simply and then 1 second into the 5 second animation I dismiss it (signifying someone tapping outside the view controller to dismiss it mid transition), nothing occurs until the animation is done. It's as if it's queued up, but won't execute until the presentation is completed.
let myMenuMenu = MyMenuMenu()
present(myMenuMenu, animated: true, completion: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
myMenuMenu.dismiss(animated: true, completion: nil)
}
Am I misunderstanding a core part of the custom view controller transition flow? Do I have to make it an interactive transition? Something else? In the event that an interactive transition is required, where do I update the interaction controller in my code with progress? With a gesture it's obvious, but this is just a "fade 0 alpha to 1 alpha" animation.
I am facing an incomprehensible problem I have a CollectionViewController and I want to make a custom animation.
My collection is a gallery and I want to switch from collection gallery. to fullscreen gallery.
So I have ControllerTransitionDelegate
extension NavigationGalleryViewController: UIViewControllerTransitioningDelegate {
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
return DimmingPresentationController(presentedViewController: presented, presenting: presenting)
}
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
guard let selectedCellFrame = self.collectionView?.cellForItem(at: IndexPath(item: index, section: 0))?.frame else { return nil }
return PresentingAnimator(pageIndex: index, originFrame: selectedCellFrame)
}
My DimmingPresentationController
class DimmingPresentationController: UIPresentationController {
lazy var background = UIView(frame: .zero)
override var shouldRemovePresentersView: Bool {
return false
}
override func presentationTransitionWillBegin() {
setupBackground()
// Grabing the coordinator responsible for the presentation so that the background can be animated at the same rate
if let coordinator = presentedViewController.transitionCoordinator {
coordinator.animate(alongsideTransition: { (_) in
self.background.alpha = 1
}, completion: nil)
}
}
private func setupBackground() {
background.backgroundColor = UIColor.black
background.autoresizingMask = [.flexibleWidth, .flexibleHeight]
background.frame = containerView!.bounds
containerView!.insertSubview(background, at: 0)
background.alpha = 0
}
}
And my presenting animator
class PresentingAnimator: NSObject, UIViewControllerAnimatedTransitioning {
private let indexPath: IndexPath
private let originFrame: CGRect
private let duration: TimeInterval = 0.5
init(pageIndex: Int, originFrame: CGRect) {
self.indexPath = IndexPath(item: pageIndex, section: 0)
self.originFrame = originFrame
super.init()
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let toView = transitionContext.view(forKey: .to),
let fromVC = transitionContext.viewController(forKey: .from) as? NavigationGalleryViewController, // The problem is here !
let fromView = fromVC.collectionView?.cellForItem(at: indexPath) as? InstallationViewCell
else {
transitionContext.completeTransition(true)
return
}
// All the animation things
}
My BIG problem is that my execution go inside the else because he can't find the FromVC from the transitionContext.viewController.
And here is how I call my Gallery
gallery = SwiftPhotoGallery(delegate: self, dataSource: self)
// Gallery visual colours stuff
gallery.modalPresentationStyle = .custom
gallery.transitioningDelegate = self
present(gallery, animated: true, completion: { () -> Void in
self.gallery.currentPage = self.index
})
}
This is what I receive from the transitionContext :
Why the transitionContext won't give me the right VC ?
Well, I checked and noticed that transitionContext.viewController(forKey: .from) is NavigationController.
In line: let fromVC = transitionContext.viewController(forKey: .from) as? NavigationGalleryViewController should be nil, because it is not NavigationGalleryViewController but NavigationController.
If you want you can make smth like this: let fromVC = transitionContext.viewController(forKey: .from).childViewControllers.first as? NavigationGalleryViewController
I am trying to implement custom transition animation here is what I have
class NavDelegate: NSObject, UINavigationControllerDelegate {
private let animator = Animator()
func navigationController(_ navigationController: UINavigationController,
animationControllerFor operation: UINavigationControllerOperation,
from fromVC: UIViewController,
to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return animator
}
}
class Animator: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using context: UIViewControllerContextTransitioning?) -> TimeInterval {
return 10.0
}
func animateTransition(using context: UIViewControllerContextTransitioning) {
let fromViewController = context.viewController(forKey: UITransitionContextViewControllerKey.from)!
let toViewController = context.viewController(forKey: UITransitionContextViewControllerKey.to)!
if fromViewController is ViewController {
self.pushAnimation(from: fromViewController as! ViewController,
to: toViewController as! VC2ViewController,
with: context)
}
if toViewController is ViewController {
print("pop")
}
}
func pushAnimation(from viewController: ViewController,
to destinationViewController: VC2ViewController,
with context: UIViewControllerContextTransitioning) {
context.containerView.addSubview(destinationViewController.view)
//block 1
for cell in viewController.tableView1.visibleCells {
if let castCell = cell as? VC1TableViewCell {
castCell.contentViewToLeft.constant = -UIScreen.main.bounds.width
castCell.contentViewToRight.constant = UIScreen.main.bounds.width
let duration = Double(viewController.tableView1.visibleCells.index(of: cell)!)/Double(viewController.tableView1.visibleCells.count) + 0.2
UIView.animate(withDuration: duration, animations: {
castCell.layoutIfNeeded()
}) { animated in
}
}
}
//block 2
for cell in destinationViewController.tableView2.visibleCells {
if let castCell = cell as? VC2TableViewCell {
castCell.contentViewToLeft.constant = -UIScreen.main.bounds.width
castCell.contentViewToRight.constant = UIScreen.main.bounds.width
let duration = Double(destinationViewController.tableView2.visibleCells.index(of: cell)!)/Double(destinationViewController.tableView2.visibleCells.count) + 0.2
UIView.animate(withDuration: duration, animations: {
castCell.layoutIfNeeded()
}) { animated in
if duration > 1.1 {
context.completeTransition(!context.transitionWasCancelled)
}
}
}
}
}
}
The problem is that animation of constraints of destination controller (block 2) is never animated, layoutIfNeeded() executes without animation, although block 1 is working. What might be the problem?
I've spent a couple of days testing
transitionDuration(using context: UIViewControllerContextTransitioning?)
function, and find out that it is all about calling some blocks of toViewController animations inside DispatchQueue.main.async {}. Not sure how it works, but I'd made my code work as I was planning to.
I'm trying to create a transition effect on a UITabBarController somewhat similar to the Facebook app. I managed to get a "scrolling effect" working on tab switch, but I can't seem to figure out how to cross dissolve (or it doesn't work at least).
Here's my current code:
import UIKit
class ScrollingTabBarControllerDelegate: NSObject, UITabBarControllerDelegate {
func tabBarController(_ tabBarController: UITabBarController, animationControllerForTransitionFrom fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return ScrollingTransitionAnimator(tabBarController: tabBarController, lastIndex: tabBarController.selectedIndex)
}
}
class ScrollingTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
weak var transitionContext: UIViewControllerContextTransitioning?
var tabBarController: UITabBarController!
var lastIndex = 0
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.2
}
init(tabBarController: UITabBarController, lastIndex: Int) {
self.tabBarController = tabBarController
self.lastIndex = lastIndex
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
self.transitionContext = transitionContext
let containerView = transitionContext.containerView
let fromViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)
let toViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)
containerView.addSubview(toViewController!.view)
var viewWidth = toViewController!.view.bounds.width
if tabBarController.selectedIndex < lastIndex {
viewWidth = -viewWidth
}
toViewController!.view.transform = CGAffineTransform(translationX: viewWidth, y: 0)
UIView.animate(withDuration: self.transitionDuration(using: (self.transitionContext)), delay: 0.0, usingSpringWithDamping: 1.2, initialSpringVelocity: 2.5, options: .transitionCrossDissolve, animations: {
toViewController!.view.transform = CGAffineTransform.identity
fromViewController!.view.transform = CGAffineTransform(translationX: -viewWidth, y: 0)
}, completion: { _ in
self.transitionContext?.completeTransition(!self.transitionContext!.transitionWasCancelled)
fromViewController!.view.transform = CGAffineTransform.identity
})
}
}
Would be great if anyone know how to get this to work, been trying for days now without progress... :/
edit: I got a cross dissolve working by replacing the UIView.animate block with:
UIView.transition(with: containerView, duration: 0.2, options: .transitionCrossDissolve, animations: {
toViewController!.view.transform = CGAffineTransform.identity
fromViewController!.view.transform = CGAffineTransform(translationX: -viewWidth, y: 0)
}, completion: { _ in
self.transitionContext?.completeTransition(!self.transitionContext!.transitionWasCancelled)
fromViewController!.view.transform = CGAffineTransform.identity
})
However, the animation is really laggy and not usable :(
edit 2: For people trying to use these snippets, don't forget to hook up the delegate for the UITabBarController, otherwise nothing will happen.
edit 3: I've found a Swift library that does exactly what I was looking for:
https://github.com/Interactive-Studio/TransitionableTab
There is a simpler way to doing this. Add the following code in the tabbar delegate:
Working on Swift 2, 3 and 4
class MySubclassedTabBarController: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
delegate = self
}
}
extension MySubclassedTabBarController: UITabBarControllerDelegate {
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
guard let fromView = selectedViewController?.view, let toView = viewController.view else {
return false // Make sure you want this as false
}
if fromView != toView {
UIView.transition(from: fromView, to: toView, duration: 0.3, options: [.transitionCrossDissolve], completion: nil)
}
return true
}
}
EDIT (4/23/18)
Since this answer is getting popular, I updated the code to remove the force unwraps, which is a bad practice, and added the guard statement.
EDIT (7/11/18)
#AlbertoGarcía is right. If you tap the tabbar icon twice you get a blank screen. So I added an extra check
If you want to use UIViewControllerAnimatedTransitioning to do something more custom than UIView.transition, take a look at this gist.
// MyTabController.swift
import UIKit
class MyTabBarController: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
delegate = self
}
}
extension MyTabBarController: UITabBarControllerDelegate {
func tabBarController(_ tabBarController: UITabBarController, animationControllerForTransitionFrom fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return MyTransition(viewControllers: tabBarController.viewControllers)
}
}
class MyTransition: NSObject, UIViewControllerAnimatedTransitioning {
let viewControllers: [UIViewController]?
let transitionDuration: Double = 1
init(viewControllers: [UIViewController]?) {
self.viewControllers = viewControllers
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return TimeInterval(transitionDuration)
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard
let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
let fromView = fromVC.view,
let fromIndex = getIndex(forViewController: fromVC),
let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to),
let toView = toVC.view,
let toIndex = getIndex(forViewController: toVC)
else {
transitionContext.completeTransition(false)
return
}
let frame = transitionContext.initialFrame(for: fromVC)
var fromFrameEnd = frame
var toFrameStart = frame
fromFrameEnd.origin.x = toIndex > fromIndex ? frame.origin.x - frame.width : frame.origin.x + frame.width
toFrameStart.origin.x = toIndex > fromIndex ? frame.origin.x + frame.width : frame.origin.x - frame.width
toView.frame = toFrameStart
DispatchQueue.main.async {
transitionContext.containerView.addSubview(toView)
UIView.animate(withDuration: self.transitionDuration, animations: {
fromView.frame = fromFrameEnd
toView.frame = frame
}, completion: {success in
fromView.removeFromSuperview()
transitionContext.completeTransition(success)
})
}
}
func getIndex(forViewController vc: UIViewController) -> Int? {
guard let vcs = self.viewControllers else { return nil }
for (index, thisVC) in vcs.enumerated() {
if thisVC == vc { return index }
}
return nil
}
}
I was struggling with the tab bar animation both from a user tap and programmatically calling selectedIndex = X since the accepted solution didn't work for me when setting the selected tab programatically.
In the end I managed to solve it by a UITabBarControllerDelegate and a custom UIViewControllerAnimatedTransitioning as follows:
extension MainController: UITabBarControllerDelegate {
public func tabBarController(
_ tabBarController: UITabBarController,
animationControllerForTransitionFrom fromVC: UIViewController,
to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return FadePushAnimator()
}
}
Where the FadePushAnimator looks like this:
class FadePushAnimator: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.3
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard
let toViewController = transitionContext.viewController(forKey: .to)
else {
return
}
transitionContext.containerView.addSubview(toViewController.view)
toViewController.view.alpha = 0
let duration = self.transitionDuration(using: transitionContext)
UIView.animate(withDuration: duration, animations: {
toViewController.view.alpha = 1
}, completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}
This approach supports any sort of custom animation and works both on user tap and setting the selected tab programatically. Tested on Swift 5.
To expand on #gmogames answer: https://stackoverflow.com/a/45362914/1993937
I couldn't get this to animate when selecting the tab bar index via code, as calling:
tabBarController.setSeletedIndex(0)
Doesn't seem to go through the same call heirarchy, and it skips the method:
tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController)
entirely, resulting in no animation.
In my code I wanted to have an animation transition for a user tapping a tab bar item in addition to me setting the tab bar item in-code manually under certain circumstances.
Here is my addition to the solution above which adds a different method to set the selected index via code that will animate the transition:
import Foundation
import UIKit
#objc class CustomTabBarController: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
delegate = self
}
#objc func set(selectedIndex index : Int) {
_ = self.tabBarController(self, shouldSelect: self.viewControllers![index])
}
}
#objc extension CustomTabBarController: UITabBarControllerDelegate {
#objc func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
guard let fromView = selectedViewController?.view, let toView = viewController.view else {
return false // Make sure you want this as false
}
if fromView != toView {
UIView.transition(from: fromView, to: toView, duration: 0.3, options: [.transitionCrossDissolve], completion: { (true) in
})
self.selectedViewController = viewController
}
return true
}
}
Now just call
tabBarController.setSelectedWithIndex(1)
for an in-code animated transition!
I still think it is unfortunate that to get this done we have to override a method that isn't a setter and manipulate data within it. It doesn't make the tab bar controller as extensible as it should be if this is the method that we need to override to get this done.
So, a few years later and more experienced, after revisiting my own question for the same behaviour, I improved a little bit upon Derek's answer.
I inherited most of his code (as it seems like the best solution).
What I changed
I added a crossDissolve animation (as I originally wanted) to the slide animation by adding a toCoverView and fromCoverView, these are snapshotviews of the other view which will be used to fade in/out at the same time.
Changed the frame width to already start at 75% instead of having to translate the full 100% width, it's only translating 25% now which makes it feel snappier.
Added SpringWithDamping and initialSpringVelocity settings.
These changes made it feel just about as close as I could get it to Facebook's implementation and I'm personally quite happy with it.
Here's the adapted answer (most of the credit goes to Derek so be sure to upvote him):
class MyTabBarController: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
delegate = self
}
}
extension MyTabBarController: UITabBarControllerDelegate {
func tabBarController(_ tabBarController: UITabBarController, animationControllerForTransitionFrom fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return MyTransition(viewControllers: tabBarController.viewControllers)
}
}
class MyTransition: NSObject, UIViewControllerAnimatedTransitioning {
let viewControllers: [UIViewController]?
let transitionDuration: Double = 0.2
init(viewControllers: [UIViewController]?) {
self.viewControllers = viewControllers
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return TimeInterval(transitionDuration)
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard
let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
let fromView = fromVC.view,
let fromIndex = getIndex(forViewController: fromVC),
let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to),
let toView = toVC.view,
let toIndex = getIndex(forViewController: toVC)
else {
transitionContext.completeTransition(false)
return
}
let frame = transitionContext.initialFrame(for: fromVC)
var fromFrameEnd = frame
var toFrameStart = frame
let quarterFrame = frame.width * 0.25
fromFrameEnd.origin.x = toIndex > fromIndex ? frame.origin.x - quarterFrame : frame.origin.x + quarterFrame
toFrameStart.origin.x = toIndex > fromIndex ? frame.origin.x + quarterFrame : frame.origin.x - quarterFrame
toView.frame = toFrameStart
let toCoverView = fromView.snapshotView(afterScreenUpdates: false)
if let toCoverView = toCoverView {
toView.addSubview(toCoverView)
}
let fromCoverView = toView.snapshotView(afterScreenUpdates: false)
if let fromCoverView = fromCoverView {
fromView.addSubview(fromCoverView)
}
DispatchQueue.main.async {
transitionContext.containerView.addSubview(toView)
UIView.animate(withDuration: self.transitionDuration, delay: 0, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.8, options: [.curveEaseOut], animations: {
fromView.frame = fromFrameEnd
toView.frame = frame
toCoverView?.alpha = 0
fromCoverView?.alpha = 1
}) { (success) in
fromCoverView?.removeFromSuperview()
toCoverView?.removeFromSuperview()
fromView.removeFromSuperview()
transitionContext.completeTransition(success)
}
}
}
func getIndex(forViewController vc: UIViewController) -> Int? {
guard let vcs = self.viewControllers else { return nil }
for (index, thisVC) in vcs.enumerated() {
if thisVC == vc { return index }
}
return nil
}
}
The only thing I've yet to figure out is how to make it "interruptible" like Facebook does. I know there's a interruptibleAnimator function for this but I haven't been able to make it work yet.