Introduction
I'm creating an app that has, in its rootViewController, a UITableView and a UIPanGestureRecognizer attached to a small UIView acting as a "handle" which enables a custom view controller transition for a UIViewController called "SlideOutViewController" to be panned from the right.
Issue
I have noticed two issues with my approach. But the actual custom transition works as expected.
When the SlideOutViewController is created it is not attached to the navigation stack I believe, therefore it has no associated navigationBar. And if I use the navigationController to push it on the stack, I loose the interactive transition.
Side note: I have not found a way to connect the handle to the SlideOutViewController that is interactively dragged out. So the translation of the handle is not consistent with the SlideOutViewControllers position.
Question
How can I add the SlideOutViewController to the navigation stack? So that the SlideOutViewController transitions with a navigationBar when I trigger the UIPanGestureRecognizer?
My code
In the rootViewController.
class RootViewController: UIViewController {
...
let slideControllerHandle = UIView()
var interactionController : UIPercentDrivenInteractiveTransition?
override func viewDidLoad() {
super.viewDidLoad()
... // Setting up the table view etc...
setupPanGForSlideOutController()
}
private func setupPanGForSlideOutController() {
slideControllerHandle.translatesAutoresizingMaskIntoConstraints = false
slideControllerHandle.layer.borderColor = UIColor.black.cgColor
slideControllerHandle.layer.borderWidth = 1
slideControllerHandle.layer.cornerRadius = 30
view.addSubview(slideControllerHandle)
slideControllerHandle.frame = CGRect(x: view.frame.width - 12.5, y: view.frame.height / 2, width: 25, height: 60)
let panGestureForCalendar = UIPanGestureRecognizer(target: self, action: #selector(handlePanGestureForSlideOutViewController(_:)))
slideControllerHandle.addGestureRecognizer(panGestureForCalendar)
}
#objc private func handlePanGestureForSlideOutViewController(_ gesture: UIPanGestureRecognizer) {
let xPosition = gesture.location(in: view).x
let percent = 1 - (xPosition / view.frame.size.width)
switch gesture.state {
case .began:
guard let slideOutController = storyboard?.instantiateViewController(withIdentifier: "CNSlideOutViewControllerID") as? SlideOutViewController else { fatalError("Sigh...") }
interactionController = UIPercentDrivenInteractiveTransition()
slideOutController.customTransitionDelegate.interactionController = interactionController
self.present(slideOutController, animated: true)
case .changed:
slideControllerHandle.center = CGPoint(x: xPosition, y: slideControllerHandle.center.y)
interactionController?.update(percent)
case .ended, .cancelled:
let velocity = gesture.velocity(in: view)
interactionController?.completionSpeed = 0.999
if percent > 0.5 || velocity.x < 10 {
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5, options: .curveEaseInOut, animations: {
self.slideControllerHandle.center = CGPoint(x: self.view.frame.width, y: self.slideControllerHandle.center.y)
})
interactionController?.finish()
} else {
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5, options: .curveEaseInOut, animations: {
self.slideControllerHandle.center = CGPoint(x: -25, y: self.slideControllerHandle.center.y)
})
interactionController?.cancel()
}
interactionController = nil
default:
break
}
}
The SlideOutViewController
class SlideOutViewController: UIViewController {
var interactionController : UIPercentDrivenInteractiveTransition?
let customTransitionDelegate = TransitionDelegate()
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
modalPresentationStyle = .custom
transitioningDelegate = customTransitionDelegate
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .red
navigationItem.title = "Slide Controller"
let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addNewData(_:)))
navigationItem.setRightBarButton(addButton, animated: true)
}
}
The custom transition code. Based on Rob's descriptive answer on this SO question
TransitionDelegate
class TransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
weak var interactionController : UIPercentDrivenInteractiveTransition?
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return CNRightDragAnimationController(transitionType: .presenting)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return CNRightDragAnimationController(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
}
}
DragAnimatedTransitioning
class CNRightDragAnimationController: 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.x = frame.size.width
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.x = frame.size.width
fromView.frame = frame
}, completion: { finished in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.5
}
}
PresentationController
class PresentationController: UIPresentationController {
override var shouldRemovePresentersView: Bool { return true }
}
Thanks for reading my question.
The animation code you’ve taken this from is for custom “present” (e.g. modal) transitions. But if you want a custom navigation as you push/pop when using a navigation controller, you specify a delegate for your UINavigationController and then return the appropriate transitioning delegate in navigationController(_:animationControllerFor:from:to:). And also implement navigationController(_:interactionControllerFor:) and return your interaction controller there.
E.g. I'd do something like:
class FirstViewController: UIViewController {
let navigationDelegate = CustomNavigationDelegate()
override func viewDidLoad() {
super.viewDidLoad()
navigationController?.delegate = navigationDelegate
navigationDelegate.addPushInteractionController(to: view)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationDelegate.pushDestination = { [weak self] in
self?.storyboard?.instantiateViewController(withIdentifier: "Second")
}
}
}
Where:
class CustomNavigationDelegate: NSObject, UINavigationControllerDelegate {
var interactionController: UIPercentDrivenInteractiveTransition?
var current: UIViewController?
var pushDestination: (() -> UIViewController?)?
func navigationController(_ navigationController: UINavigationController,
animationControllerFor operation: UINavigationController.Operation,
from fromVC: UIViewController,
to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return CustomNavigationAnimator(transitionType: operation)
}
func navigationController(_ navigationController: UINavigationController,
interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactionController
}
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
current = viewController
}
}
// MARK: - Push
extension CustomNavigationDelegate {
func addPushInteractionController(to view: UIView) {
let swipe = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handlePushGesture(_:)))
swipe.edges = [.right]
view.addGestureRecognizer(swipe)
}
#objc private func handlePushGesture(_ gesture: UIScreenEdgePanGestureRecognizer) {
guard let pushDestination = pushDestination else { return }
let position = gesture.translation(in: gesture.view)
let percentComplete = min(-position.x / gesture.view!.bounds.width, 1.0)
switch gesture.state {
case .began:
interactionController = UIPercentDrivenInteractiveTransition()
guard let controller = pushDestination() else { fatalError("No push destination") }
current?.navigationController?.pushViewController(controller, animated: true)
case .changed:
interactionController?.update(percentComplete)
case .ended, .cancelled:
let speed = gesture.velocity(in: gesture.view)
if speed.x < 0 || (speed.x == 0 && percentComplete > 0.5) {
interactionController?.finish()
} else {
interactionController?.cancel()
}
interactionController = nil
default:
break
}
}
}
// MARK: - Pop
extension CustomNavigationDelegate {
func addPopInteractionController(to view: UIView) {
let swipe = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handlePopGesture(_:)))
swipe.edges = [.left]
view.addGestureRecognizer(swipe)
}
#objc private func handlePopGesture(_ gesture: UIScreenEdgePanGestureRecognizer) {
let position = gesture.translation(in: gesture.view)
let percentComplete = min(position.x / gesture.view!.bounds.width, 1)
switch gesture.state {
case .began:
interactionController = UIPercentDrivenInteractiveTransition()
current?.navigationController?.popViewController(animated: true)
case .changed:
interactionController?.update(percentComplete)
case .ended, .cancelled:
let speed = gesture.velocity(in: gesture.view)
if speed.x > 0 || (speed.x == 0 && percentComplete > 0.5) {
interactionController?.finish()
} else {
interactionController?.cancel()
}
interactionController = nil
default:
break
}
}
}
And
class CustomNavigationAnimator: NSObject, UIViewControllerAnimatedTransitioning {
let transitionType: UINavigationController.Operation
init(transitionType: UINavigationController.Operation) {
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 .push:
frame.origin.x = frame.size.width
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 .pop:
toView.frame = frame
inView.insertSubview(toView, belowSubview: fromView)
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
frame.origin.x = frame.size.width
fromView.frame = frame
}, completion: { finished in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
case .none:
break
}
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.5
}
}
Then, if the second view controller wanted to have the custom interactive pop plus the ability to swipe to the third view controller:
class SecondViewController: UIViewController {
var navigationDelegate: CustomNavigationDelegate { return navigationController!.delegate as! CustomNavigationDelegate }
override func viewDidLoad() {
super.viewDidLoad()
navigationDelegate.addPushInteractionController(to: view)
navigationDelegate.addPopInteractionController(to: view)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationDelegate.pushDestination = { [weak self] in
self?.storyboard?.instantiateViewController(withIdentifier: "Third")
}
}
}
But if the last view controller can't push to anything, but only pop:
class ThirdViewController: UIViewController {
var navigationDelegate: CustomNavigationDelegate { return navigationController!.delegate as! CustomNavigationDelegate }
override func viewDidLoad() {
super.viewDidLoad()
navigationDelegate.addPopInteractionController(to: view)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationDelegate.pushDestination = nil
}
}
Related
To me it looks like the safe area of a view is not updated after the owning view controller's .viewWillDisappear() method is called.
Is this intended or a bug in the framework?
The issue is easily visualised by creating a custom UIViewControllerTransitioningDelegate that animates a smaller view in one view controller, to full screen size in another (constrained to safe areas). Then the safe area will expand as the present animation goes on (as expected), but will not shrink as the dismiss animation goes on (not expected!). The expected behaviour would be that the safe area expands during the present animation, and shrinks during the dismiss animation.
The gif below shows the unexpected behaviour. The grey area of the presented view controller is the safe area.
Below is the code I used to visualise this problem. ViewController.swift presents MyViewController.swift using FullScreenTransitionManager.swift
//
// FullScreenTransitionManager.swift
//
import Foundation
import UIKit
// MARK: FullScreenPresentationController
final class FullScreenPresentationController: UIPresentationController {
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)
}
override func presentationTransitionDidEnd(_ completed: Bool) {
if !completed {
containerView?.removeGestureRecognizer(tapGestureRecognizer)
}
}
override func dismissalTransitionDidEnd(_ completed: Bool) {
if completed {
containerView?.removeGestureRecognizer(tapGestureRecognizer)
}
}
}
// 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?.frame ?? CGRect(origin: presented.view.center, size: .zero)
return FullScreenAnimationController(animationType: .present,
anchorFrame: anchorFrame)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
let anchorFrame = anchorView?.frame ?? 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 = 5) {
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)
viewController.view.frame = anchorFrame
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(with transitionContext: UIViewControllerContextTransitioning,
animating viewController: UIViewController) -> UIViewPropertyAnimator {
return UIViewPropertyAnimator.runningPropertyAnimator(withDuration: transitionDuration(using: transitionContext),
delay: 0,
options: [.curveEaseInOut],
animations: {
viewController.view.frame = self.anchorFrame
viewController.view.layoutIfNeeded()
}, completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}
//
// MyViewController.swift
//
import UIKit
class MyViewController: UIViewController {
private let square: UIView = {
let view = UIView()
view.backgroundColor = .white
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemGray
view.addSubview(square)
NSLayoutConstraint.activate([
square.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
square.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
square.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
square.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
])
}
}
//
// ViewController.swift
//
import UIKit
class ViewController: UIViewController {
private let button: UIButton = {
let button = UIButton(type: .system)
button.setTitle("Click Me!", for: .normal)
button.setTitleColor(.black, for: .normal)
button.backgroundColor = .white
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
private var fullScreenTransitionManager: FullScreenTransitionManager?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
view.addSubview(button)
NSLayoutConstraint.activate([
button.widthAnchor.constraint(equalToConstant: 200),
button.heightAnchor.constraint(equalToConstant: 200),
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
button.addTarget(self, action: #selector(presentMyViewController), for: .primaryActionTriggered)
}
#objc private func presentMyViewController(_ button: UIButton) {
let fullScreenTransitionManager = FullScreenTransitionManager(anchorView: button)
let myViewController = MyViewController()
myViewController.modalPresentationStyle = .custom
myViewController.transitioningDelegate = fullScreenTransitionManager
present(myViewController, animated: true)
self.fullScreenTransitionManager = fullScreenTransitionManager
}
}
As confirmed by the answer on this post on the Apple Developer forums, this behaviour is expected. And safe areas is not updated if a view controller is not in the appearing state (between viewWillAppear and viewWillDisappear)
I have implemented a custom interactive transition for detecting the middle screen swipe with the UIPanGestureRecognizer along with UIPercentDrivenInteractiveTransition and animating it with UIViewControllerAnimatedTransitioning.
For now, I have a problem when the UIPercentDrivenInteractiveTransition was cancelled but the UINavigationBar pop animation was still animated like this
Example
Is there anyway to disable or cancel the animation when there are some situation like this?
Here is my code https://github.com/kanottonp/NavBarBugPOC
ViewController.swift
#IBOutlet weak var button: UIButton!
static var count = 1
private var percentDrivenInteractiveTransition: UIPercentDrivenInteractiveTransition!
private var panGestureRecognizer: UIPanGestureRecognizer!
override func viewDidLoad() {
super.viewDidLoad()
addGesture()
self.navigationController?.setNavigationBarHidden(false, animated: true)
self.title = "Hello \(ViewController.count)"
ViewController.count += 1
}
#IBAction func onTouch(_ sender: Any) {
guard let newVC = storyboard?.instantiateViewController(withIdentifier: "ViewController") else {
return
}
self.navigationController?.pushViewController(newVC, animated: true)
}
private func addGesture() {
guard panGestureRecognizer == nil else {
return
}
guard self.navigationController?.viewControllers.count > 1 else {
return
}
panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.handlePanGesture(_:)))
panGestureRecognizer.cancelsTouchesInView = true;
panGestureRecognizer.delaysTouchesBegan = true;
panGestureRecognizer.maximumNumberOfTouches = 1
self.view.addGestureRecognizer(panGestureRecognizer)
self.navigationController?.interactivePopGestureRecognizer?.delegate = panGestureRecognizer as? UIGestureRecognizerDelegate
}
#objc private func handlePanGesture(_ panGesture: UIPanGestureRecognizer) {
let percent = max(panGesture.translation(in: view).x, 0) / view.frame.width
switch panGesture.state {
case .began:
navigationController?.delegate = self
if panGesture.velocity(in: view).x > 0 {
_ = navigationController?.popViewController(animated: true)
}
case .changed:
if let percentDrivenInteractiveTransition = percentDrivenInteractiveTransition {
percentDrivenInteractiveTransition.update(percent)
}
case .ended:
let velocity = panGesture.velocity(in: view).x
// Continue if drag more than 50% of screen width or velocity is higher than 300
if let percentDrivenInteractiveTransition = percentDrivenInteractiveTransition {
if percent > 0.5 || velocity > 300 {
percentDrivenInteractiveTransition.finish()
} else {
percentDrivenInteractiveTransition.cancel()
}
}
case .cancelled, .failed:
percentDrivenInteractiveTransition.cancel()
default:
break
}
}
extension ViewController: UINavigationControllerDelegate
public func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return SlideAnimatedTransitioning()
}
public func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
navigationController.delegate = nil
if panGestureRecognizer.state == .began && panGestureRecognizer.velocity(in: view).x > 0 {
percentDrivenInteractiveTransition = UIPercentDrivenInteractiveTransition()
percentDrivenInteractiveTransition.completionCurve = .easeInOut
} else {
percentDrivenInteractiveTransition = nil
}
return percentDrivenInteractiveTransition
}
SlideAnimatedTransitioning.swift
extension SlideAnimatedTransitioning: UIViewControllerAnimatedTransitioning
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// use animator to implement animateTransition
let animator = interruptibleAnimator(using: transitionContext)
animator.startAnimation()
}
func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating {
if let propertyAnimator = propertyAnimator {
return propertyAnimator
}
let containerView = transitionContext.containerView
let fromViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)!
let toViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)!
let fromView = transitionContext.view(forKey: .from)!
let toView = transitionContext.view(forKey: .to)!
toView.frame = transitionContext.finalFrame(for: toViewController)
toView.frame = CGRect(x: toView.frame.origin.x, y: toView.frame.origin.y, width: toView.frame.size.width, height: toView.frame.size.height + toView.frame.origin.y)
let width = containerView.frame.width
var offsetLeft = fromView.frame
offsetLeft.origin.x = width
var offscreenRight = toView.frame
offscreenRight.origin.x = -width / 3.33;
toView.frame = offscreenRight;
toView.layer.opacity = 0.9
containerView.insertSubview(toView, belowSubview: fromView)
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: UICubicTimingParameters(animationCurve: .easeInOut))
animator.addAnimations {
toView.frame = CGRect(x: fromView.frame.origin.x, y: toView.frame.origin.y, width: toView.frame.width, height: toView.frame.height)
fromView.frame = offsetLeft
toView.layer.opacity = 1.0
}
animator.addCompletion { (success) in
toView.layer.opacity = 1.0
fromView.layer.opacity = 1.0
fromViewController.navigationItem.titleView?.layer.opacity = 1
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
self.propertyAnimator = nil
}
self.propertyAnimator = animator
return animator
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
if transitionContext?.transitionWasCancelled == true { return 0 }
return 2
}
I am trying to implement a custom drag-to-dismiss interactive transition, using UIViewControllerAnimatedTransitioning and UIPercentDrivenInteractiveTransition.
Here is the animator code for dismissing, it just moves the view down completely out from the frame:
final class SheetDismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
0.5
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromVC = transitionContext.viewController(forKey: .from) else { return }
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
fromVC.view.frame.origin.y = transitionContext.containerView.frame.height
}, completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}
And here is the code for the modal view controller I am trying to dismiss, which has the pan gesture recognizer attached to it:
final class ModalViewController: UIViewController {
private let customTransitionDelegate = SheetTransitioningDelegate()
private var interactionController: UIPercentDrivenInteractiveTransition?
init() {
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .custom
transitioningDelegate = customTransitionDelegate
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .red
view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:))))
}
#objc func handleGesture(_ gesture: UIPanGestureRecognizer) {
let translate = gesture.translation(in: view)
let percent = translate.y / view.bounds.height
switch gesture.state {
case .began:
interactionController = UIPercentDrivenInteractiveTransition()
customTransitionDelegate.interactionController = interactionController
dismiss(animated: true)
case .changed:
interactionController?.update(percent)
case .cancelled:
interactionController?.cancel()
case .ended:
let velocity = gesture.velocity(in: gesture.view)
if percent > 0.5 || velocity.y > 700 {
interactionController?.finish()
} else {
interactionController?.cancel()
}
interactionController = nil
default:
break
}
}
}
The relevant part I believe is the handleGesture(_ gesture: UIPanGestureRecognizer) function, which calculates the percentage for advancing the animation. Pasted the whole code for clarity.
I would expect the animation to follow the pan gesture linearly, but this is what happens instead:
I solved the issue by switching to UIViewPropertyAnimator:
final class SheetDismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
0.5
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let propertyAnimator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext),
timingParameters: UISpringTimingParameters(dampingRatio: 1))
propertyAnimator.addAnimations {
switch self.type {
case .presented:
guard let toVC = transitionContext.viewController(forKey: .to) else { break }
toVC.view.frame = transitionContext.finalFrame(for: toVC)
case .dismissed:
transitionContext.view(forKey: .from)?.frame.origin.y = transitionContext.containerView.frame.maxY
}
}
propertyAnimator.addCompletion { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
propertyAnimator.startAnimation()
}
}
I also added the following into my gesture handling code, which is a basic attempt at transfering the gesture's velocity to the animation speed.
case .ended:
if percent > 0.45 || velocity.y > 700 {
interactor.timingCurve = UICubicTimingParameters(animationCurve: .linear)
interactor.completionSpeed = max(1, (view.frame.height * (1 / interactor.duration) / velocity.y))
interactor.finish()
} else {
interactor.cancel()
}
isInteractive = false
I am doing a custom transition and if after present animation, device will be rotated and then destinationVC will be dismissed, originVC transform is not correct (not fulfil screen). If there is no device rotation, everything works perfectly fine. Does any one can help me?
Here is my code for present and dismiss animation:
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let originViewController = transitionContext.viewController(forKey: .from),
let destinationViewController = transitionContext.viewController(forKey: .to) else { return }
destinationViewController.view.transform = CGAffineTransform(translationX: 0, y: destinationViewController.view.frame.height)
let duration = transitionDuration(using: transitionContext)
UIView.animate(withDuration: duration, animations: {
destinationViewController.view.transform = CGAffineTransform(translationX: 0, y: 0)
originViewController.view.transform = originViewController.view.transform.scaledBy(x: 0.95, y: 0.95)
originViewController.view.layer.cornerRadius = 8.0
}, completion: { completed in
transitionContext.completeTransition(completed)
})
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let originViewController = transitionContext.viewController(forKey: .from),
let destinationViewController = transitionContext.viewController(forKey: .to) else { return }
let duration = transitionDuration(using: transitionContext)
UIView.animate(withDuration: duration, animations: {
originViewController.view.transform = CGAffineTransform(translationX: 0, y: destinationViewController.view.frame.height)
destinationViewController.view.transform = CGAffineTransform.identity
destinationViewController.view.layer.cornerRadius = 0.0
}, completion: { completed in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
Screens:
Before present animation
After present animation
After device rotation
After dismiss animation
EDIT:
when I add destinationViewController.view.frame = transitionContext.finalFrame(for: destinationViewController) to dismiss animation everything seems works fine but I don't know if this is right way
Add a subView in ViewC1 with leading, top, bottom, trailing constraints.
Working full code
class ViewC1: UIViewController {
#IBAction func presentNow(_ sender: Any) {
let viewc = storyboard!.instantiateViewController(withIdentifier: "ViewC2") as! ViewC2
viewc.transitioningDelegate = viewc
viewc.modalPresentationStyle = .overCurrentContext
present(viewc, animated: true, completion: nil)
}
}
class ViewC2: UIViewController {
let pres = PresentAnimator()
let diss = DismissAnimator()
#IBAction func dissmisNow(_ sender: Any) {
dismiss(animated: true, completion: nil)
}
}
extension ViewC2: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return pres
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return diss
}
}
class PresentAnimator: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 1.0
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let originViewController = transitionContext.viewController(forKey: .from),
let destinationViewController = transitionContext.viewController(forKey: .to) else { return }
transitionContext.containerView.addSubview(destinationViewController.view)
destinationViewController.view.frame = transitionContext.containerView.bounds
let originView = originViewController.view.subviews.first
destinationViewController.view.transform = CGAffineTransform(translationX: 0, y: destinationViewController.view.frame.height)
let duration = transitionDuration(using: transitionContext)
UIView.animate(withDuration: duration, animations: {
destinationViewController.view.transform = CGAffineTransform(translationX: 0, y: 0)
originView?.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
originView?.layer.cornerRadius = 8.0
}, completion: { completed in
transitionContext.completeTransition(completed)
})
}
}
class DismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 1.0
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let originViewController = transitionContext.viewController(forKey: .from),
let destinationViewController = transitionContext.viewController(forKey: .to) else { return }
let originView = destinationViewController.view.subviews.first
let duration = transitionDuration(using: transitionContext)
UIView.animate(withDuration: duration, animations: {
originViewController.view.transform = CGAffineTransform(translationX: 0, y: destinationViewController.view.frame.height)
originView?.transform = CGAffineTransform.identity
originView?.layer.cornerRadius = 0.0
}, completion: { completed in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}
Update:
or you can override willRotate and didRotate if you dont want to use view.subviews.first
var prevTrans: CGAffineTransform?
override func willRotate(to toInterfaceOrientation: UIInterfaceOrientation, duration: TimeInterval) {
prevTrans = view.transform
view.transform = CGAffineTransform.identity
}
override func didRotate(from fromInterfaceOrientation: UIInterfaceOrientation) {
if let prevTrans = prevTrans {
view.transform = prevTrans
}
}
I have a TabBarController that has 3 navigationController, where each has a corresponding viewController: AViewController, BViewController and CViewController. First one has a UIView element that I want to run animation against it when viewDisappears using:
UIView.animateWithDuration(duration, delay: 0.0, options: .CurveEaseOut, animations: {
during...
override func viewWillDisappear(animated: Bool) {
If user clicks on item 2 or 3 in the tabbar, I want that animation to take place first and then take the user to the item #2 or #3.
Problem is that that when user clicks a different item, this code in the TabBarController is triggered first before the viewwillDisappears in my ViewController A
override func tabBar(tabBar: UITabBar, didSelectItem item: UITabBarItem) {
and then my animation doesn't run.
Where should I place my animation so it runs before user is taken to a different item in the tabbar?
You could implement tabBarController:shouldSelectViewController: from UITabBarControllerDelegate, return false, play your animation and then programatically change tab.
You need to write your own UIViewControllerAnimatedTransitioning (https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIViewControllerAnimatedTransitioning_Protocol/)
try this code:
TabBarController.swift
import UIKit
class TabBarController: UITabBarController, UITabBarControllerDelegate {
var buttons = [UIButton]()
override func viewDidLoad() {
super.viewDidLoad()
self.delegate = self
var newViewControllers = [UIViewController]()
for index in 0..<2 {
let viewController = ViewController()
var tabBarItem = UITabBarItem()
switch index {
case 0:
viewController.view.backgroundColor = UIColor.blueColor()
viewController.label.textColor = UIColor.whiteColor()
tabBarItem = UITabBarItem(tabBarSystemItem: .More, tag: index)
case 1:
viewController.view.backgroundColor = UIColor.greenColor()
viewController.label.textColor = UIColor.blackColor()
tabBarItem = UITabBarItem(tabBarSystemItem: .Favorites, tag: index)
default:
break
}
viewController.label.text = "Text \(index)"
viewController.tabBarItem = tabBarItem
newViewControllers.append(viewController)
//tabBar.items![index] = tabBarItem
}
selectedIndex = 0
// = newViewControllers
setViewControllers(newViewControllers, animated: false)
// Do any additional setup after loading the view, typically from a nib.
}
func tabBarController(tabBarController: UITabBarController, animationControllerForTransitionFromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
let fromIndex = tabBarController.viewControllers!.indexOf(fromVC)!
let toIndex = tabBarController.viewControllers!.indexOf(toVC)!
let tabChangeDirection: TabOperationDirection = toIndex < fromIndex ? .Left : .Right
let transitionType = SDETransitionType.TabTransition(tabChangeDirection)
let slideAnimationController = SlideAnimationController(type: transitionType)
return slideAnimationController
}
}
ViewController.swift
import UIKit
class ViewController: UIViewController {
let label = UILabel(frame: CGRect(x: 40, y: 60, width: 80, height: 40))
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(label)
}
var labelHidden = false {
willSet(value) {
if value {
self.label.frame.origin.y = -40
} else {
self.label.frame.origin.y = 60
}
}
}
}
SlideAnimationController.swift (resource: https://github.com/seedante/iOS-ViewController-Transition-Demo)
import UIKit
enum SDETransitionType{
case NavigationTransition(UINavigationControllerOperation)
case TabTransition(TabOperationDirection)
case ModalTransition(ModalOperation)
}
enum TabOperationDirection{
case Left, Right
}
enum ModalOperation{
case Presentation, Dismissal
}
class SlideAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
private var transitionType: SDETransitionType
init(type: SDETransitionType) {
transitionType = type
super.init()
}
func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
return 0.3
}
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
guard let containerView = transitionContext.containerView(), fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey), toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) else{
return
}
let fromView = fromVC.view
let toView = toVC.view
var translation = containerView.frame.width
var toViewTransform = CGAffineTransformIdentity
var fromViewTransform = CGAffineTransformIdentity
switch transitionType{
case .NavigationTransition(let operation):
translation = operation == .Push ? translation : -translation
toViewTransform = CGAffineTransformMakeTranslation(translation, 0)
fromViewTransform = CGAffineTransformMakeTranslation(-translation, 0)
case .TabTransition(let direction):
translation = direction == .Left ? translation : -translation
fromViewTransform = CGAffineTransformMakeTranslation(translation, 0)
toViewTransform = CGAffineTransformMakeTranslation(-translation, 0)
case .ModalTransition(let operation):
translation = containerView.frame.height
toViewTransform = CGAffineTransformMakeTranslation(0, (operation == .Presentation ? translation : 0))
fromViewTransform = CGAffineTransformMakeTranslation(0, (operation == .Presentation ? 0 : translation))
}
switch transitionType{
case .ModalTransition(let operation):
switch operation{
case .Presentation: containerView.addSubview(toView)
case .Dismissal: break
}
default: containerView.addSubview(toView)
}
toView.transform = toViewTransform
if let fromVC = fromVC as? ViewController {
UIView.animateWithDuration(0.3, delay: 0.0, options: .CurveEaseOut, animations: {
fromVC.labelHidden = true
}, completion: {
bool in
UIView.animateWithDuration(self.transitionDuration(transitionContext), animations: {
fromView.transform = fromViewTransform
toView.transform = CGAffineTransformIdentity
}, completion: { finished in
fromView.transform = CGAffineTransformIdentity
toView.transform = CGAffineTransformIdentity
let isCancelled = transitionContext.transitionWasCancelled()
transitionContext.completeTransition(!isCancelled)
fromVC.labelHidden = false
})
})
}
}
}