31

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

JoniVR
  • 1,839
  • 1
  • 22
  • 36

5 Answers5

80

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

gmogames
  • 2,993
  • 1
  • 28
  • 40
  • Thanks, this works and is a lot cleaner and less code! Any way to make the animation slide at the same time? (I'm looking for a combination of the two) – JoniVR Jul 28 '17 at 12:52
  • @JoniVR I did not try, but did you see that the options is an array of options, did you try adding another one in there to see if it works? – gmogames Jul 30 '17 at 22:34
  • Yes I tried them all, doesn't seem to be an option for that as far as I could tell, so it'd probably be with a custom animation like I tried before then, right? – JoniVR Aug 01 '17 at 17:03
  • 1
    None of the other transitions produce much of an effect other than the `.transitionCrossDissolve` with this method. – Anjan Biswas Nov 13 '17 at 03:55
  • This solution removed my app's ability to return back to the "root" VC of that tab. In other words, the solution needs some tweaking if your VC is embedded in a navigation controller. – shadowmoses Jan 27 '18 at 21:23
  • 1
    @Alfi Only seeing your code would help us answer that. Make sure you have this code setup and the delegate is being called. Put breakpoints in it and see if it's being called. if it's not, means something is not right in your code. – gmogames Feb 27 '18 at 18:09
  • 1
    To make this code perfect, You need to check if the new view controller is equal to current view controller. If you don't do this, you will have a beautiful black screen :) – Alberto García Jul 11 '18 at 15:04
  • Am I adding this code to the tabBarController?? and setting the delegate to self?? As if I do so then I only get the animation once. Which is weird. By the way all the setup is programmatic no storyboards. – AD Progress Jul 13 '18 at 13:35
  • @ADProgress Yes, UITabBarController subclass with delegate to self. What do you mean you get the animation only once? like if you have 4 tabbar items it only animates when you click in one of them but not the other? Did you set a breakpoint to see if the delegate and the transition animation is getting called? – gmogames Jul 13 '18 at 17:30
  • I did setup the tabbar and the whole ui programmatically so maybe I am missing something, I did set self.delegate=self in the tabbarcontroller and it only works when the app loads and I switch to another viewcontroller. But then it doesn’t work again when I switch back and forth. Do I need the tag properties to be setup?? – AD Progress Jul 13 '18 at 17:33
  • @ADProgress You don't need the tag. Your implementation may be losing the reference to your tabbar controller or the delegate for some reason. Did you do the `delegate=self` in viewDidLoad? Do you have a lot of custom code/logic in your UITabBarController subclass? – gmogames Jul 13 '18 at 17:40
  • I don't have a lot of custom code I only set it up once before loading the app. It only shows 2 tabs. Of two viewControllers wrapped in separate Navigation Controllers – AD Progress Jul 13 '18 at 17:54
  • Why not add the "fromView != toView" check to the guard statement? – Lloyd Keijzer Nov 08 '18 at 13:57
  • @LloydKeijzer because if its different it would `return false` and you still want `true` in that case – gmogames Nov 15 '18 at 22:50
  • 1
    This unfortunately doesn't work if you are setting selectedIndex on the tabBar programmatically. – Tomask Dec 29 '19 at 13:06
  • This is a nice solution which got better with all these nice comments. thank you guys all – Onur Şahindur Oct 17 '20 at 07:19
  • this solution do not call viewDillAppear of appearing controller at proper time – Krypt Sep 03 '23 at 00:32
34

If you want to use UIViewControllerAnimatedTransitioning to do something more custom than UIView.transition, take a look at this gist.

enter image description here

// 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
    }
}
Derek Soike
  • 11,238
  • 3
  • 79
  • 74
  • Two observations: 1. This only works if you have 4 viewControllers, the fifth will be UIMoreNavigationController, and no index will be returned for it. 2. getIndex can be optimized: return self.viewControllers?.firstIndex(of: vc) – Tekla Keresztesi Jul 19 '19 at 07:46
  • 1
    best solution bro :)) – logan.Nguyen Sep 07 '19 at 06:00
  • 1
    Working in Swift 5 . Awesome. – Keshu R. Oct 21 '19 at 12:06
  • It works good but I have a problem. When the transition is happening, the topbar color of the controller changes and it causes annoying flash effect. How to solve it? check the video please https://drive.google.com/file/d/15mlR9NfV_1C8gwi-4vfpV4CxpXbG5BUm/view?usp=sharing – Utku Dalmaz Jun 11 '20 at 12:33
14

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.

Tomask
  • 2,344
  • 3
  • 27
  • 37
  • Can we distinguish between changing the selected index programmatically or with user interaction here? I would like to animate the change only when it was called in code. – Roman Samoilenko Apr 01 '20 at 12:59
6

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.

TheJeff
  • 3,665
  • 34
  • 52
5

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.

JoniVR
  • 1,839
  • 1
  • 22
  • 36
  • hi i used your solution and it works! the only problem is that if you change tabs very fast screen goes black and nothing happen after that any solution? – Mamad Jan 26 '23 at 16:24