UITabBarとUITabBarControllerについて調べた

今更ながら、UITabBarとUITabBarControllerの使い方を調べました。アプリに何度も登場せず、一度作ると作ったっきりになりがちなので改めて調べてみよう、という動機です。実装して検証した結果をまとめました。
2020.06.15

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

概要

大阪オフィスの山田です。最近、湿気がすごくてかなしいです。 今更ながら、UITabBarとUITabBarControllerの使い方を調べました。アプリに何度も登場せず、一度作ると作ったっきりになりがちなので改めて調べてみよう、という動機です。なお、公式のドキュメントはこちらです。

開発環境

  • macOS: 10.15.4
  • Xcode: 11.5
  • 検証端末: iPhone SE 2nd Generation(Simulator)

目次

少し長いので、目次を記載しておきます。

Interface Builderを使って、TabBarControllerを追加する

StoryboardでLibaryを開いて、TabBarControllerを追加します。

TabBarControllerを追加すると、付随してViewControllerが2つ追加されました。

TabBarControllerをInitial Launch ViewControllerに設定し、ViewControllerを次の画像のように、背景色設定とラベルを配置しました。

TabBarの設定を変更する

TabBarをタップし、BackgroundShadowに画像を設定しました。

ShadowはTabBarの上端部分に表示されるようです。

Selectionに画像を設定すると、選択しているタブのアイコン画像の後ろに、画像が表示されます。

Styleではblackを選ぶことができ、TabBarが黒くなります。 Translucentは、TabBarの透過/非透過を設定することができます。 Bar Tintで、TabBarの色を自由に変更することができます。

Item Positioningで、Barの項目の配置を変更することが可能です。 Item PositioningAutomaticFillCenteredから選択できます。 Fillは、TabBarに広がるように項目を配置し、必要に応じて項目の間隔を調整します。Centeredは、項目の幅と間隔を設定して、項目を中央から配置します。 Automaticは、環境に応じて(compact, regular)FillCenteredが自動で設定されます。

Centeredに設定した時の動作がこちら。

TabBarItemの設定を変更する

IB上で、追加されたViewControllerの下部にTab Bar Itemが表示されているので、選択してプロパティを見ていきます。Badgeにはバッジとして表示する文字を設定することができます。

バッジはタブを選択しただけでは自動で消えません。またバッジの色も変更することができます。

アイコンとタイトルはSystemのものを選択することができます。

アイコンとタイトルを独自のものにするには、Customを選択して、Selected ImageTitleImageを設定します。

横向きにした時に表示する画像をLandscapeで指定することができます。画像ではわかりにくいですがPencilを指定しています。

プログラムからTabBarControllerを生成する

IBを使用しないで、プログラムだけでTabBarControllerを作成します。 まず、子となるViewControllerを定義します。

class FirstViewController: UIViewController {
    lazy var centerLabel: UILabel = {
        let label = UILabel()
        label.text = "First"
        label.font = UIFont.boldSystemFont(ofSize: 70.0)
        label.textColor = UIColor.white
        return label
    }()

    override func loadView() {
        view = UIView()
        view.backgroundColor = .blue

        centerLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(centerLabel)
        NSLayoutConstraint.activate([
            centerLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            centerLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
    }
}
class SecondViewController: UIViewController {
    lazy var centerLabel: UILabel = {
        let label = UILabel()
        label.text = "Second"
        label.font = UIFont.boldSystemFont(ofSize: 70.0)
        label.textColor = UIColor.white
        return label
    }()

    override func loadView() {
        view = UIView()
        view.backgroundColor = .brown

        centerLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(centerLabel)
        NSLayoutConstraint.activate([
            centerLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            centerLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
    }
}

次にTabBarControllerを定義します。

class MainTabBarController: UITabBarController {
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTab()
    }
}

private extension MainTabBarController {
    func setupTab() {
        let firstViewController = FirstViewController()
        firstViewController.tabBarItem = UITabBarItem(tabBarSystemItem: .history, tag: 0)
        let secondViewController = SecondViewController()
        secondViewController.tabBarItem = UITabBarItem(tabBarSystemItem: .downloads, tag: 0)
        viewControllers = [firstViewController, secondViewController]
    }
}

それぞれ、子のViewControllerを生成し、tabBarItemプロパティを設定し、viewControllersプロパティに子のViewControllerを設定します。この記事の目的から少し外れますが、SceneDelegateも修正を加えます。

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let scene = (scene as? UIWindowScene) else { return }
    let window = UIWindow(windowScene: scene)
    self.window = window

    let vc = MainTabBarController()
    window.rootViewController = vc
}

これで、起動時に定義したMainTabBarControllerが表示され、タブで表示するViewControllerを切り替えることができました。

タブアイテムタップ時にモーダルで画面を表示する

モーダルで表示する画面のクラスを定義します。

class ModalViewController: UIViewController {
    lazy var centerLabel: UILabel = {
        let label = UILabel()
        label.text = "Modal"
        label.font = UIFont.boldSystemFont(ofSize: 70.0)
        label.textColor = UIColor.white
        return label
    }()

    override func loadView() {
        view = UIView()
        view.backgroundColor = .darkGray

        centerLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(centerLabel)
        NSLayoutConstraint.activate([
            centerLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            centerLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
    }
}

TabBarControllerに以下の処理を追加します。

  • TabBarControllerのdelegateを設定
  • tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController)メソッドを実装
class MainTabBarController: UITabBarController {
    override func viewDidLoad() {
        super.viewDidLoad()
        setupTab()
    }
}

private extension MainTabBarController {
    func setupTab() {
        delegate = self  // delegateを設定
        let firstViewController = FirstViewController()
        firstViewController.tabBarItem = UITabBarItem(tabBarSystemItem: .history, tag: 0)
        let secondViewController = SecondViewController()
        secondViewController.tabBarItem = UITabBarItem(tabBarSystemItem: .downloads, tag: 0)
        viewControllers = [firstViewController, secondViewController]
    }
}

extension MainTabBarController: UITabBarControllerDelegate {
    // shouldSelectメソッドを実装
    func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
        if viewController == tabBarController.viewControllers?[1] {
            present(ModalViewController(), animated: true, completion: nil)
            return false
        }
        return true
    }
}

shouldSelectのメソッドで、タブアイテムをタップした時のイベントを拾います。今回は2つ目のタブアイテムをタップした時に、モーダル表示する処理を入れています。falseを返却することでActiveなタブをそのままにしておくことができます。そのため、2つ目のタブをタップしても、選択されているタブは変更されません。

プログラムからアクティブなタブを設定した時の挙動

UITabBarControllerのselectedIndexプロパティに値をセットすることで、アクティブなタブをプログラムから設定することができます。この場合、先ほど実装したshouldSelectメソッドはコールされないため、モーダルで画面は表示されず、タブが切り替わる挙動になります。以下、検証用の実装です。

class FirstViewController: UIViewController {
    lazy var centerLabel: UILabel = {
        let label = UILabel()
        label.text = "First"
        label.font = UIFont.boldSystemFont(ofSize: 70.0)
        label.textColor = UIColor.white
        return label
    }()

    // 「2つ目のタブを選択」ボタンを追加
    lazy var selectSecondTabButton: UIButton = {
        let button = UIButton()
        button.setTitle("2つ目のタブを選択", for: .normal)
        return button
    }()

    override func loadView() {
        view = UIView()
        view.backgroundColor = .blue

        centerLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(centerLabel)
        NSLayoutConstraint.activate([
            centerLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            centerLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])

        // 「2つ目のタブを選択」ボタンを配置し、タップ時のイベントを設定
        selectSecondTabButton.translatesAutoresizingMaskIntoConstraints = false
        selectSecondTabButton.addTarget(self, action: #selector(tapSelectSecondTabButton), for: .touchUpInside)
        view.addSubview(selectSecondTabButton)
        NSLayoutConstraint.activate([
            selectSecondTabButton.topAnchor.constraint(equalTo: centerLabel.bottomAnchor),
            selectSecondTabButton.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
    }
}

private extension FirstViewController {
    // 「2つ目のタブを選択」ボタンをタップしたら、2つ目のタブをアクティブにする
    @objc func tapSelectSecondTabButton(_ sender: UIButton) {
        self.tabBarController?.selectedIndex = 1
    }
}

この例では以下の処理を追加しています。

  • 子のViewControllerにボタンを追加
  • ボタンがタップされたら、tabBarControllerプロパティにてUITabBarControllerにアクセスして、selectedIndexを変更

モーダルダイアログを表示しつつ、後ろのTabBarのアクティブタブを変更してみる

結論から言うと可能でした。以下のように実装して、TabBarControllerのselectedIndexを変更するコードを書いて動かしたところ、正常にアクティブタブを変更できました。 ※3つ目のタブを追加していますが、他のタブとほぼ一緒なので割愛します。

  • Modalで表示する画面にボタンとTabBarのクロージャプロパティを追加
  • TabBarControllerで、モーダル表示するタイミングで、クロージャプロパティに値を設定
class ModalViewController: UIViewController {
    lazy var centerLabel: UILabel = {
        let label = UILabel()
        label.text = "Modal"
        label.font = UIFont.boldSystemFont(ofSize: 70.0)
        label.textColor = UIColor.white
        return label
    }()

    // 「3つ目のタブを選択」ボタンを追加
    lazy var selectThirdTabButton: UIButton = {
        let button = UIButton()
        button.setTitle("3つ目のタブを選択", for: .normal)
        return button
    }()

    lazy var closeButton: UIButton = {
        let button = UIButton()
        button.setTitle("閉じる", for: .normal)
        return button
    }()

    var selectThirdTab: (() -> Void)?

    override func loadView() {
        view = UIView()
        view.backgroundColor = .darkGray

        centerLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(centerLabel)
        NSLayoutConstraint.activate([
            centerLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            centerLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])

        // 「3つ目のタブを選択」ボタンを配置し、タップ時のイベントを設定
        selectThirdTabButton.translatesAutoresizingMaskIntoConstraints = false
        selectThirdTabButton.addTarget(self, action: #selector(tapSelectThirdTabButton), for: .touchUpInside)
        view.addSubview(selectThirdTabButton)
        NSLayoutConstraint.activate([
            selectThirdTabButton.topAnchor.constraint(equalTo: centerLabel.bottomAnchor),
            selectThirdTabButton.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])

        // 「閉じる」ボタンを配置し、タップ時のイベントを設定
        closeButton.translatesAutoresizingMaskIntoConstraints = false
        closeButton.addTarget(self, action: #selector(tapCloseButton), for: .touchUpInside)
        view.addSubview(closeButton)
        NSLayoutConstraint.activate([
            closeButton.topAnchor.constraint(equalTo: selectThirdTabButton.bottomAnchor),
            closeButton.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
    }
}

private extension ModalViewController {
    // 「3つ目のタブを選択」ボタンをタップしたら、3つ目のタブをアクティブにする
    @objc func tapSelectThirdTabButton(_ sender: UIButton) {
        selectThirdTab?()
    }

    @objc func tapCloseButton(_ sender: UIButton) {
        dismiss(animated: true, completion: nil)
    }
}
extension MainTabBarController: UITabBarControllerDelegate {
    // shouldSelectメソッドを実装
    func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
        if viewController == tabBarController.viewControllers?[1] {
            let modal = ModalViewController()
            modal.selectThirdTab = { [weak self] in
                self?.selectedIndex = 2
            }
            present(modal, animated: true, completion: nil)
            return false
        }
        return true
    }
}

タブバーの高さを変更する

UITabBarのsizeThatFitsメソッドにて高さを設定します。TabBarをextensionするか、TabBarを継承したクラスを作って適用します。

class CustomTabBar: UITabBar {
    override func sizeThatFits(_ size: CGSize) -> CGSize {
        super.sizeThatFits(size)
        var sizeThatFits = super.sizeThatFits(size)
        sizeThatFits.height = 70
        sizeThatFits.height += safeAreaInsets.bottom

        return sizeThatFits
    }
}

UITabBarControllerのtabBarでCustomTabBarを使うように設定します。IBであれば、IB上のTabBarのclassにCustomTabBarクラスを指定したら良いです。プログラムのみの場合、以下のようなコードで実現しました。

class MainTabBarController: UITabBarController {
    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
        object_setClass(tabBar, CustomTabBar.self)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTab()
    }
}

バッジを付ける、消す

バッジを付ける

3つ目のViewControllerにボタンを追加し、タップすると1つ目のタブにバッジを付けます。 今回、バッジの色も指定しています。

private extension ThirdViewController {
    // 「バッジを表示」ボタンをタップしたら、1つ目のタブにバッジを表示する
    @objc func tapShowBadgeButton(_ sender: UIButton) {
        let tabBarItem = tabBarController?.viewControllers?[0].tabBarItem
        tabBarItem?.badgeValue = "test"
        tabBarItem?.badgeColor = UIColor.purple
    }
}

バッジを消す

1つ目のタブをタップすると、バッジを消すようにします。実装はTabControllerのshouldSelectメソッドに記述します。UITabBarItemのbadgeValueにnilをセットするとバッジが消えます。

    // shouldSelectメソッドを実装
    func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
        if viewController == tabBarController.viewControllers?[0] {
            // badgeを消す
            viewController.tabBarItem.badgeValue = nil
        }
        return true
    }

タブ切り替え時のアニメーション

ViewControllerのアニメーション

いくつか方法はありますが、今回はUIView.transitionメソッドを使います。実装はTabControllerのshouldSelectメソッドに記述します。

    func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
        // ViewControllerの切り替えアニメーション
        guard let fromView = selectedViewController?.view, let toView = viewController.view else {
          return false
        }
        if fromView != toView {
          UIView.transition(from: fromView, to: toView, duration: 0.3, options: [.transitionCrossDissolve], completion: nil)
        }
        return true
    }

タブアイコンのタップ時アニメーション

タブをタップした時に、アイコンがアニメーションするように実装してみます。それらしいプロパティは存在しないので以下の手順で実装を進めます。 1. タブアイコンのUIImageViewを取得する 2. タップ時にUIImageViewに対してアニメーションを実行する

タブアイコンのUIImageViewを取得する

プロパティはありませんのでsubviewsの中からUIImageViewを探し出すことにします。 Hierarchy Viewを見ると、以下のようになっています。iOS13で確認しています。

以下のように並んでいますので、一番左のUITabBarButtonを取得するにはsubviews[1]となります。Index+1をすると良さそうです。ただし、iOSのバージョンによってこの構造が違うようなので、各バージョンで確認する必要があります。

  • UIBackground
  • UITabBarButton
  • UITabBarButton
  • UITabBarButton

UITabBarButtonの中にUITabBarSwappableImageViewが存在するのでUIImageViewとして取得します。 まず、subviewsの中から再起的にsubviewsを取得して、一次元配列として扱えるようにします。 こちらの記事を参考にしました。

class CustomTabBar: UITabBar {
    /// indexを受け取り、タブのUIImageViewを返却する
    func barItemImage(index: Int) -> UIImageView? {
        let view = subviews[index + 1]
        return view.recursiveSubviews.compactMap { $0 as? UIImageView }
        .first
    }
}

extension UIView {
    // 再起的にsubviewsを取得
    var recursiveSubviews: [UIView] {
        return subviews + subviews.flatMap { $0.recursiveSubviews }
    }
}

タップ時にアニメーションを実行する

UITabBarControllerに戻り、didSelectメソッドとアニメーションを実装します。 didSelectメソッドでタップされたタブのUIImageViewを取得し、アニメーションのメソッドに渡しています。

    override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
        guard let index = tabBar.items?.firstIndex(of: item),
            let customTabBar = tabBar as? CustomTabBar,
            let imageView = customTabBar.barItemImage(index: index) else {
                return
        }
        iconBounceAnimation(view: imageView)
    }

    func iconBounceAnimation(view: UIView) {
        UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 0.5, options: .curveEaseInOut, animations: {
            view.transform = CGAffineTransform(scaleX: 1.25, y: 1.25)
            UIView.animate(withDuration: 0.5, delay: 0.2, usingSpringWithDamping: 0.5, initialSpringVelocity: 0.5, options: .curveEaseInOut, animations: {
                view.transform = CGAffineTransform(scaleX: 1, y: 1)
            }, completion: nil)
        }, completion: nil)
    }

参考

今回のブログを書くにあたって、実装したソースコードはGitHubに上げています。

おわりに

「あれ、これ簡単にできないじゃん」ってあるあるだと思うんですよ。