TabのItemをタップして上部スクロールさせる

6 min read読了の目安(約6000字

概要

タブのアイコンをタップした時に自動で最上部にスクロールさせる

実装

下準備

UITabBarControllerのviewControllersに任意の数のUINavigationControllerをセット
ここはどうでもいいので適宜お願いします

スクロールさせるケース

まず、どのような状態の時にスクロールさせるか整理します
スクロールさせるのは以下の一つのケースのみです

  • navigationControllerのrootで同tabのアイコンをタップした場合

つまり、タブのアイコンをタップしても上記以外のケースではスクロールを起こしません。上記以外のケースは、

  • navigationControllerのrootから別タブへ遷移
  • navigationControllerのroot以外の画面から同タブのrootへ戻る

などなどですが、スクロールさせるケースが一つしかないので、そのケースのみで動くような条件を書けば良いわけです
スクロールさせる条件は

  • 現在表示している画面がnavigationControllerのrootである
  • 現在表示しているタブのアイコンをタップする

では一つずつ実装していきます

class ItemTapScrollTabController: UITabBarController {  //アイコンタップでスクロールさせるためのラッパークラス
    private var isRoot: Bool = false  //表示中のviewControllerがrootかどうかのフラグ
    private var currentItemIndex: Int = -1  //表示中のviewControllerのTabから見たときのインデックス
    private var nextItemIndex: Int = -1  //遷移先のviewControllerのTabから見たときのインデックス
    
    override func loadView() {
        super.loadView()
        delegate = self
    }
}

extension ItemTapScrollTabController: UITabBarControllerDelegate {
    func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
        //アイコンをタップする時に呼ばれる
        return true
    }
    func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
        //画面遷移後に呼ばれる
    }
}

フラグ用のプロパティを作ったら中身を入れていきます

class ItemTapScrollTabController: UITabBarController {
    private var isRoot: Bool = false
    private var currentItemIndex: Int = -1
    private var nextItemIndex: Int = -1
    
    override func loadView() {
        super.loadView()
        delegate = self
    }
}

extension ItemTapScrollTabController: UITabBarControllerDelegate {
    func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
        currentItemIndex = tabBarController.selectedIndex
        return true
    }
    func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
        nextItemIndex = tabBarController.selectedIndex
    }
}

tabBarController.selectedIndexですがshouldSelect内では現在表示中のviewControllerのUITabBarControllerから見たときのインデックス、didSelect内では遷移先のviewControllerのUITabBarControllerから見たときのインデックスが取得されます

class ItemTapScrollTabController: UITabBarController {
    private var isRoot: Bool = false
    private var currentItemIndex: Int = -1
    private var nextItemIndex: Int = -1
    
    override func loadView() {
        super.loadView()
        delegate = self
    }
}

extension ItemTapScrollTabController: UITabBarControllerDelegate {
    func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
        currentItemIndex = tabBarController.selectedIndex
	if let nav = viewController as? UINavigationController,
           let current = nav.visibleViewController,
           let currentStackIdx = nav.viewControllers.firstIndex(of: current) {
            isRoot = currentStackIdx == 0
        }
        return true
    }
    func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
        nextItemIndex = tabBarController.selectedIndex
	guard currentItemIndex == nextItemIndex, isRoot else { return }
	//currntItemIndex == nextItemIndex → 表示中のviewControllerのタブから見たインデックスと遷移先のviewControllerのタブから見たインデックスが同じ, 表示中のタブと同じタブを選択
        //isRoot → 表示中のviewControllerがnavigationControllerのrootである
    }
}

shouldSelectのif let nav...内では、まず

  • UINavigationControllerにキャスト
  • 現在表示中のviewControllerを取得
  • そのviewControllerがUINavigationControllerから見たときのインデックスを取得

を行っています
そして、そのインデックスが0であればUINavigationControllerのroot、つまり、表示中のviewControllerがUINavigationControllerのrootViewControllerであることを意味するのでisRootをtrueにしています

次のdidSelectのguard currentItemIndex == ...で上記の2つの条件をコードにおとしこんでいます

navigationController.visibleViewControllerですが、

  • 別タブへ遷移した場合
    • shouldSelect, didSelect: 遷移先のviewController
  • 同タブのrootへ戻った場合
    • shouldSelect: 表示中のviewController
    • didSelect: 遷移先のviewController

と、変な挙動があるので注意が必要です(理由は分からないので偉い人教えてください)
shouldSelectでは表示中のviewControllerを取得したいので、別タブへ遷移した場合は正しくviewControllerが取得できません
本コードでもshouldSelect内のvisibleViewControllerで取得されるviewControllerは状況によって変わってしまいますが、currentItemIndex == nextItemIndexの部分で、同タブの遷移の場合しか処理を行わないようにしているため、動きとしては問題ありません

ここまくればあとはスクロール処理を書くだけです。スクロール処理はUIScrollView, UITableView, UICollectionViewなど複数のコンポーネントで実装される可能性があるので、プロトコルをきってそれぞれのviewControllerに実装させます

protocol Scrollable {
    func scrollToTop()
}
class ViewController: UIViewController {
    private let tableView: UITableView = .init()
}

extension ViewController: Scrollable {
    func scrollToTop() {
        tableView.scrollToRow(at: .init(row: 0, section: 0), at: .top, animated: true)
    }
}
extension ItemTapScrollTabController: UITabBarControllerDelegate {
    func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
        currentItemIndex = tabBarController.selectedIndex
        if let nav = viewController as? UINavigationController,
           let current = nav.visibleViewController,
           let currentStackIdx = nav.viewControllers.firstIndex(of: current) {
            isRoot = currentStackIdx == 0
        }
        return true
    }
    func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
        nextItemIndex = tabBarController.selectedIndex
        guard currentItemIndex == nextItemIndex, isRoot else { return }
        if let nav = viewController as? UINavigationController,
           let current = nav.visibleViewController,
           let scrollable = current as? Scrollable {
            scrollable.scrollToTop()
        }
    }
}

終わり

思ったより挙動めんどくさくてうわーとなっていたのですが、自分の方では一旦上のコードで実装しています。あんまりテストしていないのでもしかしたら変な挙動があるかもしれません。
サンプルのGifをのせたいのですが容量オーバーでのせられないので(泣)、GitHubのリンクを置いておきます

https://github.com/hara-94/TabItemTapScroll

参考

https://qiita.com/s_higeru/items/0fc28a890e39777d73e9