36

Tapping the tab bar icon for the current navigation controller already returns the user to the root view, but if they are scrolled way down, if they tap it again I want it to scroll to the top (same effect as tapping the status bar). How would I do this?

A good example is Instagram's feed, scroll down then tap the home icon in the tab bar to scroll back to top.

The scrolling back to the top is easy, but connecting it to the tab bar controller is what I'm stuck on.

Rishil Patel
  • 1,977
  • 3
  • 14
  • 30
Jonathan Sutherland
  • 825
  • 1
  • 8
  • 16

14 Answers14

39

Implement the UITabBarControllerDelegate method tabBarController:didSelectViewController: to be notified when the user selects a tab. This method is also called when the same tab button is tapped again, even if that tab is already selected.

A good place to implement this delegate would probably be your AppDelegate. Or the object that logically "owns" the tab bar controller.

I would declare and implement a method that can be called on your view controllers to scroll the UICollectionView.

- (void)tabBarController:(UITabBarController *)tabBarController 
 didSelectViewController:(UIViewController *)viewController
{
    static UIViewController *previousController = nil;
    if (previousController == viewController) {
        // the same tab was tapped a second time
        if ([viewController respondsToSelector:@selector(scrollToTop)]) {
            [viewController scrollToTop];
        }
    }
    previousController = viewController;
}
Rishil Patel
  • 1,977
  • 3
  • 14
  • 30
DrummerB
  • 39,814
  • 12
  • 105
  • 142
  • @nhgrif Because the method is called "didSelectViewController". Usually that means that the related property has already been changed by the time the method is called and your condition would always be true. – DrummerB Mar 13 '14 at 22:25
  • There's a missing ) in the second if statement – Jonathan Sutherland Mar 13 '14 at 22:55
  • 1
    This gives me an error: `No visible @interface for 'UIViewController' declares the selector 'scrollToTop'` – rebellion Jun 04 '14 at 22:58
  • 2
    [viewController performSelector:@selector(scrollToTop)] – elprup Jan 28 '15 at 04:16
  • 1
    I would also add a check to see if the view controller is a navigation controller and if so get the `topViewController` – CWitty Jan 20 '16 at 21:30
  • Hello Please I'm using a Storyboard, I have UITabBarController > UINavigationController > UITableViewController. Where should I put this code? – T. Israel Aug 29 '16 at 15:53
  • Where do i out this code? In the app Delegate ? And if so where there? – Newbie Questions Dec 04 '16 at 09:51
  • So, this delegate can fire if the user taps the tabBar icon, or if you set the value of `selectedIndex` somewhere in your code. How do we scroll up "ONLY" when the user taps and "NOT" when it's set through program? The reason is programmatic navigation would mean that the user would expect to reach back to the point where they left but when they just tap that button directly they would want to see the top of it (Pretty much like how facebook app works)..... – Anjan Biswas Dec 18 '17 at 03:16
  • @Annjawn just don't set the selectedIndex, if this index is already present. If its not present the code above won't do anything. – cornr Feb 21 '18 at 15:52
  • I would recommend not to put the code in the `AppDelegate` (Separation of concerns). Rather I would make a `UITabBarController` Subclass and set it as the UITabBarController delegate – cornr Feb 21 '18 at 15:55
25

SWIFT 3

Here goes..

First implement the UITabBarControllerDelegate in the class and make sure the delegate is set in viewDidLoad

class DesignStoryStreamVC: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UITabBarControllerDelegate {

 @IBOutlet weak var collectionView: UICollectionView!

 override func viewDidLoad() {
        super.viewDidLoad()

        self.tabBarController?.delegate = self

        collectionView.delegate = self
        collectionView.dataSource = self
    }
}

Next, put this delegate function somewhere in your class.

func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {

    let tabBarIndex = tabBarController.selectedIndex

    print(tabBarIndex)

    if tabBarIndex == 0 {
        self.collectionView.setContentOffset(CGPoint.zero, animated: true)
    }
}

Make sure to select the correct index in the "if" statement. I included the print function so you can double check.

jsanabria
  • 451
  • 6
  • 8
  • 1
    If this code is used in multiple VCs in a single `TabController`, won't it result in only one of them working? It's my understanding that if the `delegate` property is set by the second child `ViewControler` then it will replace the `delegate` set by the first child. – Andy Aug 28 '19 at 11:00
  • This worked well on a fixed tableview, but it wouldn't work for my infinite scroll tableview. It only came up the table view about half way when after loading the new scrolling data. self.tableView.scrollToRow(at: IndexPath.init(row: 0, section: 0), at: UITableView.ScrollPosition(rawValue: 0)!, animated: true) – RVACode Oct 06 '20 at 22:55
9

Swift 5: no need for stored properties in the UITabBarController.

In MyTabBarController.swift, implement tabBarController(_:shouldSelect) to detect when the user re-selects the tab bar item:

protocol TabBarReselectHandling {
    func handleReselect()
}

class MyTabBarController: UITabBarController, UITabBarControllerDelegate {
    override func viewDidLoad() {
        super.viewDidLoad()
        delegate = self
    }

    func tabBarController(
        _ tabBarController: UITabBarController,
        shouldSelect viewController: UIViewController
    ) -> Bool {
        if tabBarController.selectedViewController === viewController,
            let handler = viewController as? TabBarReselectHandling {
            // NOTE: viewController in line above might be a UINavigationController,
            // in which case you need to access its contents
            handler.handleReselect()
        }

        return true
    }
}

In MyTableViewController.swift, handle the re-selection by scrolling the table view to the top:

class MyTableViewController: UITableViewController, TabBarReselectHandling {
    func handleReselect() {
        tableView?.setContentOffset(.zero, animated: true)
    }
}

Now you can easily extend this to other tabs by just implementing TabBarReselectHandling.

Chris Chute
  • 3,229
  • 27
  • 18
  • 1
    You can handle a navigation controller like this: guard let navigationController = viewController as? UINavigationController else { return true } guard navigationController.viewControllers.count <= 1, let handler = navigationController.viewControllers.first as? TabBarReselectHandling else { return true } – TM Lynch Jan 02 '20 at 17:25
6

You can use shouldSelect rather than didSelect, which would omit the need for an external variable to keep track of the previous view controller.

- (BOOL)tabBarController:(UITabBarController *)tabBarController shouldSelectViewController:(UIViewController *)viewController
{
    if ([viewController isEqual:self] && [tabBarController.selectedViewController isEqual:viewController]) {
        // Do custom stuff here
    }
    return YES;
}
James Kuang
  • 10,710
  • 4
  • 28
  • 38
  • This solution is far better then storing a property to a previous selected VC. Though I don't geht the `[viewController isEqual:self]` part – cornr Feb 21 '18 at 16:26
5

I was using this View hierarchy.

UITabBarController > UINavigationController > UIViewController


I got a reference to the UITabBarController in the UIViewController

tabBarControllerRef = self.tabBarController as! CustomTabBarClass
tabBarControllerRef!.navigationControllerRef = self.navigationController as! CustomNavigationBarClass
tabBarControllerRef!.viewControllerRef = self

Then I created a Bool that was called at the correct times, and a method that allows scrolling to top smoothly

var canScrollToTop:Bool = true

// Called when the view becomes available
override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)
    canScrollToTop = true
}

// Called when the view becomes unavailable
override func viewWillDisappear(animated: Bool) {
    super.viewWillDisappear(animated)
    canScrollToTop = false
}

// Scrolls to top nicely
func scrollToTop() {
    self.collectionView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
}

Then in my UITabBarController Custom Class I called this

func tabBarController(tabBarController: UITabBarController, didSelectViewController viewController: UIViewController) {

    // Allows scrolling to top on second tab bar click
    if (viewController.isKindOfClass(CustomNavigationBarClass) && tabBarController.selectedIndex == 0) {
        if (viewControllerRef!.canScrollToTop) {
            viewControllerRef!.scrollToTop()
        }
    }
}

The Result is identical to Instagram and Twitter's feed :)

Michael
  • 9,639
  • 3
  • 64
  • 69
  • Hello. I quite dont understand what exactly you are doing here. Could you be a bit more specific about the steps? I have the same hierarcy as you – Konstantinos Natsios Aug 08 '16 at 13:28
  • @T.Israel sorry, I do not currently have the time. You (or someone else) are more than welcome to post another answered based off this with that code – Michael Dec 12 '16 at 16:35
  • @KwnstantinosNatsios hey sorry for the delay, essentially I am passing the action of the tab bar selection to the view controller that needs to update – Michael Dec 12 '16 at 16:36
  • Where should I put the first three lines of code (the ref) in my view controller? – T. Israel Feb 05 '17 at 21:05
  • @T.Israel in `viewDidLoad` or anywhere before you need the reference – Michael Feb 05 '17 at 21:52
  • tabBarControllerRef which type of this identifier @Michael – Asmita Jun 13 '17 at 09:20
5
 extension UIViewController {    
    func scrollToTop() {
        func scrollToTop(view: UIView?) {
            guard let view = view else { return }

            switch view {
            case let scrollView as UIScrollView:
                if scrollView.scrollsToTop == true {
                    scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: true)
                    return
                }
            default:
                break
            }

            for subView in view.subviews {
                scrollToTop(view: subView)
            }
        }

        scrollToTop(view: self.view)
    }

}

This is my answer in Swift 3. It uses a helper function for recursive calls and it automatically scrolls to top on call. Tested on a UICollectionViewController embedded into a UINavigationController embedded in a UITabBarController

  • Thanks for the answer. How can we call it when we push a tab bar item thought? – Konstantinos Natsios Feb 08 '17 at 23:33
  • I am sorry @KwnstantinosNatsios could you give some more details? What do you mean by "pushing a tab bar item"? – Alessandro Martin Feb 10 '17 at 07:59
  • i have some tab bar items. Lets say that in the "Home" item i have a table view that i'm on the 50th item and i want when i press the home item button to go to the top. That was the question and i'm asking you how to implement your code on the tab bar item button ! thanks :D – Konstantinos Natsios Feb 10 '17 at 14:27
  • 1
    @KwnstantinosNatsios The class that instantiates your `UITabBarController` should be its delegate; in it you need to implement the `UITabBarControllerDelegate` protocol method `tabBarController(_: didSelect viewController:)` method and in it, if the user has selected the tab that's already selected, you call `scrollToTop` on the selected view controller. – Alessandro Martin Feb 12 '17 at 17:43
5

Swift 3 approach::

//MARK: Properties
var previousController: UIViewController?

func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
    if self.previousController == viewController || self.previousController == nil {
        // the same tab was tapped a second time
        let nav = viewController as! UINavigationController

        // if in first level of navigation (table view) then and only then scroll to top
        if nav.viewControllers.count < 2 {
            let tableCont = nav.topViewController as! UITableViewController
            tableCont.tableView.setContentOffset(CGPoint(x: 0.0, y: -tableCont.tableView.contentInset.top), animated: true)
        }
    }
    self.previousController = viewController;
    return true
}

A few notes here:: "shouldSelect" instead of "didSelect" because the latter is taking place after transition, meaning viewController local var already changed. 2. We need to handle the event before changing controller, in order to have the information of navigation's view controllers regarding scrolling (or not) action.

Explanation:: We want to scroll to top, if current view is actually a List/Table view controller. If navigation has advanced and we tap same tab bar, desired action would be to just pop one step (default functionality) and not scroll to top. If navigation hasn't advanced meaning we are still in table/list controller then and only then we want to scroll to top when tapping again. (Same thing Facebook does when tapping "Feed" from a user's profile. It only goes back to feed without scrolling to top.

Jimi
  • 198
  • 2
  • 13
  • 1
    be careful, this looks like a retain cycle. try marking the `previousController` property as `weak`. – cornr Feb 21 '18 at 16:01
3

In this implementation you no need static variable and previous view controller state

If your UITableViewController in UINavigationController you can implement protocol and function:

protocol ScrollableToTop {
    func scrollToTop()
}

extension UIScrollView {
    func scrollToTop(_ animated: Bool) {
        var topContentOffset: CGPoint
        if #available(iOS 11.0, *) {
            topContentOffset = CGPoint(x: -safeAreaInsets.left, y: -safeAreaInsets.top)
        } else {
            topContentOffset = CGPoint(x: -contentInset.left, y: -contentInset.top)
        }
        setContentOffset(topContentOffset, animated: animated)
    }
}

Then in your UITableViewController:

class MyTableViewController: UITableViewController: ScrollableToTop {
   func scrollToTop() {
    if isViewLoaded {
        tableView.scrollToTop(true)
    }
   }
}

Then in UITabBarControllerDelegate:

extension MyTabBarController: UITabBarControllerDelegate {
    func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
        guard tabBarController.selectedViewController === viewController else { return true }
        guard let navigationController = viewController as? UINavigationController else {
            assertionFailure()
            return true
        }
        guard
            navigationController.viewControllers.count <= 1,
            let destinationViewController = navigationController.viewControllers.first as? ScrollableToTop
        else {
            return true
        }
        destinationViewController.scrollToTop()
        return false
    }
}
Alexander
  • 157
  • 2
  • 6
1

I have a collection view embedded in a navigation controller, in Swift this works.

var previousController: UIViewController?

func tabBarController(tabBarController: UITabBarController, didSelectViewController viewController: UIViewController) {
    if previousController == viewController {
        if let navVC = viewController as? UINavigationController, vc = navVC.viewControllers.first as? UICollectionViewController {
            vc.collectionView?.setContentOffset(CGPointZero, animated: true)
        }
    }
    previousController = viewController;
}
Niklas
  • 1,322
  • 14
  • 11
1

I've implemented a plug & play UITabBarController that you can freely re-use in your projects. To enable the scroll-to-top functionality, you should just have to use the subclass, nothing else.

Should work out of the box with Storyboards also.

Code:

/// A UITabBarController subclass that allows "scroll-to-top" gestures via tapping
/// tab bar items. You enable the functionality by simply subclassing.
class ScrollToTopTabBarController: UITabBarController, UITabBarControllerDelegate {

    /// Determines whether the scrolling capability's enabled.
    var scrollEnabled: Bool = true

    private var previousIndex = 0

    override func viewDidLoad() {
        super.viewDidLoad()

        delegate = self
    }

    /*
     Always call "super" if you're overriding this method in your subclass.
     */
    func tabBarController(tabBarController: UITabBarController, didSelectViewController viewController: UIViewController) {
        guard scrollEnabled else {
            return
        }

        guard let index = viewControllers?.indexOf(viewController) else {
            return
        }

        if index == previousIndex {

            dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), { [weak self] () in

                guard let scrollView = self?.iterateThroughSubviews(self?.view) else {
                    return
                }

                dispatch_async(dispatch_get_main_queue(), {
                    scrollView.setContentOffset(CGPointZero, animated: true)
                })
            })
        }

        previousIndex = index
    }

    /*
     Iterates through the view hierarchy in an attempt to locate a UIScrollView with "scrollsToTop" enabled.
     Since the functionality relies on "scrollsToTop", it plugs easily into existing architectures - you can
     control the behaviour by modifying "scrollsToTop" on your UIScrollViews.
     */
    private func iterateThroughSubviews(parentView: UIView?) -> UIScrollView? {
        guard let view = parentView else {
            return nil
        }

        for subview in view.subviews {
            if let scrollView = subview as? UIScrollView where scrollView.scrollsToTop == true {
                return scrollView
            }

            if let scrollView = iterateThroughSubviews(subview) {
                return scrollView
            }
        }

        return nil
    }
}

Edit (09.08.2016):

After attempting to compile with the default Release configuration (archiving) the compiler would not allow the possibility of creating a large number of closures that were captured in a recursive function, thus it would not compile. Changed out the code to return the first found UIScrollView with "scrollsToTop" set to true without using closures.

Marián Černý
  • 15,096
  • 4
  • 70
  • 83
D6mi
  • 611
  • 7
  • 16
  • Can we make it not to go until the top but to stay a bit lower? Lets say to go -50px of the top – Konstantinos Natsios Aug 18 '16 at 13:28
  • Ok stupid question i figured out... had to replace this one `dispatch_async(dispatch_get_main_queue(), { let pointer = CGPoint(x: 0, y: -60) scrollView.setContentOffset(pointer, animated: true) }) })` in my case! – Konstantinos Natsios Aug 18 '16 at 13:35
1

I tried the solution given by @jsanabria. This worked well on a fixed tableview, but it wouldn't work for my infinite scroll tableview. It only came up the table view about halfway after loading the new scrolling data.

Swift 5.0+

self.tableView.scrollToRow(at: IndexPath.init(row: 0, section: 0), at: UITableView.ScrollPosition(rawValue: 0)!, animated: true)
RVACode
  • 108
  • 4
0

TESTED SOLUTION IN SWIFT

STEP 1

In your main tabbarcontroller class declare

weak static var previousController: UIViewController?

STEP 2

In viewdidLoad() set

MainTabBarViewController.previousController = viewControllers?[0]

STEP 3

extension MainTabBarViewController: UITabBarControllerDelegate {
    func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
        if MainTabBarViewController.previousController == viewController {
         /// here comes your code
        }
        MainTabBarViewController.previousController = viewController
    }
}
0

Try these edits to D6mi's answer https://stackoverflow.com/a/38591323/7825073 I made it to properly scroll to top. In addition, it pops to root view when scroll is at the top.

  1. Edit func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) to this

    func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
         guard shouldSelectIndex == tabBarController.selectedIndex else { return }
         if let navVC = tabBarController.viewControllers![shouldSelectIndex].nearestNavigationController {
             if navVC.scrollToTop() == false {
                 navVC.popToRootViewController(animated: true)
             }
         }
     }
    
  2. Edit scrollToTop() to this

    extension UIViewController {
     func scrollToTop() -> Bool {
         // My edit Here
         func scrollToTop(view: UIView?) -> Bool {
             guard let view = view else { return false }
             if let scrollview = view as? UIScrollView {
                 if scrollview.scrollsToTop == true && scrollview.contentOffset.y > -scrollview.contentInset.top {
                     scrollview.setContentOffset(CGPoint(x: 0.0, y: -scrollview.contentInset.top), animated: true)
                     return true
                 }
             }
             return view.subviews.reduce(false) { partialResult, subview in
                 partialResult || scrollToTop(view: subview)
             }
         }
         return scrollToTop(view: view)
     }
    }
    
HangarRash
  • 7,314
  • 5
  • 5
  • 32
-1

I found the scrollRectToVisible method works better than the setContentOffset.

Swift:

After you catch the click on the tab bar from the delegate, something like below:

func tabBarController(tabBarController: UITabBarController, didSelectViewController viewController: UIViewController) {
 if (viewController.isKindOfClass(SomeControllerClass) && tabBarController.selectedIndex == 0) 
      {
        viewController.scrollToTop()
      }
 }

Now for the scrollToTop function inside the controller:

func scrollToTop()
{
    self.tableView.scrollRectToVisible(CGRectMake(0,0,CGRectGetWidth(self.tableView.frame), CGRectGetHeight(self.tableView.frame)), animated: true)
} 
Malloc
  • 15,434
  • 34
  • 105
  • 192
  • Excuse me but where do you put the first block of code? – Konstantinos Natsios Feb 23 '17 at 19:46
  • You put the first Block of code in your Custom `TabBarController` class. For this you need to subclass the `UITabBarController` and conform to protocol `UITabBarControllerDelegate`. `didSelectViewController` is a delegate method under that protocol. – Anjan Biswas Nov 15 '17 at 03:36