UIKit でタブバー選択時にアイコンをアニメーションさせる

# はじめに

UIKit でタブバー選択時にアイコンのアニメーションの実装をすることがあったので、備忘録として記事を書きます！

# 経緯

元々はタブバーのアニメーションは、以下ライブラリを利用して実装していました。

https://github.com/Ramotion/animated-tab-bar

ただ、storyboad 必須のライブラリで、コードのみの実装できませんでした。
個人的に storyboad はメンテしづらくて好きじゃないので、コードベースでタブバーを実装しようと思うと、`animated-tab-bar`の依存を剥がさざるを得ませんでした。
そこで、タブバーのアニメーションは UIKit で直接実装することにしました。

# ゴール

実装した結果はこんな感じです。
タブバーを選択したら、選択されたアイコンがバウンドするようなアニメーションを動かします。

![Simulator Screen Recording - iPhone 16 - 2025-04-03 at 22.41.49.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/fc1b84a7-3c3a-4777-afae-d12a123456d3.gif)

# 実装する

## タブバーの ViewController を用意する

まずはタブバーの ViewController クラスを実装します。
今回の例では、以下タブを用意します。

- Home
- Chat
- Graph
- Setting

実装内容はこちらです。

```swift
import UIKit

final class MainTabBarController: UITabBarController {

	// MARK: - lifecycle method

	override func viewDidLoad() {

		super.viewDidLoad()
		setupTab()
	}
}

// MARK: - private method

private extension MainTabBarController {

	func setupTab() {

		guard let mainVC = UIStoryboard(custom: .Main).instantiateViewController(custom: .MainViewController) as? MainViewController,
		      let chatVC = UIStoryboard(custom: .Main).instantiateViewController(custom: .ChatViewController) as? ChatViewController,
		      let graphVC = UIStoryboard(custom: .Main).instantiateViewController(custom: .DispViewController) as? DispViewController,
		      let settingVC = UIStoryboard(custom: .Main).instantiateViewController(custom: .SettingViewController) as? SettingViewController
		else {

			assertionFailure("Not found require viewController.")
			return
		}

		mainVC.tabBarItem = getTabBarItem(title: "Home",
		                                  systemImageName: "homekit",
		                                  tag: 0)
		chatVC.tabBarItem = getTabBarItem(title: "Chat",
		                                  systemImageName: "message.fill",
		                                  tag: 1)
		graphVC.tabBarItem = getTabBarItem(title: "Graph",
		                                   systemImageName: "chart.xyaxis.line",
		                                   tag: 2)
		settingVC.tabBarItem = getTabBarItem(title: "Setting",
		                                     systemImageName: "gearshape.fill",
		                                     tag: 3)

		viewControllers = [mainVC, chatVC, graphVC, settingVC]
	}

	func getTabBarItem(title: String, systemImageName: String, tag: Int) -> UITabBarItem {

		let tabBarItem = UITabBarItem(title: title, image: getTabIcon(systemImageName), tag: tag)
		tabBarItem.selectedImage = getTabIcon(systemImageName)
		return tabBarItem
	}

	func getTabIcon(_ iconName: String) -> UIImage? {

		return UIImage(systemName: iconName)
	}
}
```

## タブバー選択時のアニメーションを実装する

ここからが本題で、タブバー選択時に選択された要素をアニメーションさせていきます。

まずタブバーで選択されたときにアニメーションを行うので、`UITabBarControllerDelegate`に準拠して、選択されたタイミングを検知できるようにします。
追加したdelegateメソッドの`tabBarController(_ tabBarController: UITabBarController,　shouldSelect viewController: UIViewController) -> Bool`では、タブバーのいずれかの要素をタップされた瞬間にトリガーされます。

https://developer.apple.com/documentation/uikit/uitabbarcontrollerdelegate/tabbarcontroller(_:shouldselect:)

先ほどのコードに、タブバーのいずれかの要素をタップされたときに検知できるコードを追加します。
```diff_swift
import UIKit

final class MainTabBarController: UITabBarController {

	// MARK: - lifecycle method

	override func viewDidLoad() {

		super.viewDidLoad()
		setupTab()
+       self.delegate = self
	}
}

+ extension MainTabBarController: UITabBarControllerDelegate {
+ 
+ 	func tabBarController(_ tabBarController: UITabBarController,
+ 	                      shouldSelect viewController: UIViewController) -> Bool {
+ 
+ 		return true
+ 	}
+ }
```

続いて、タブバーで選択された要素をアニメーションさせる処理を追加していきます。

```diff_swift
extension MainTabBarController: UITabBarControllerDelegate {

	func tabBarController(_ tabBarController: UITabBarController,
	                      shouldSelect viewController: UIViewController) -> Bool {
        
+       animateTabItem(tabBarController, shouldSelect: viewController)
		return true
	}

+   private func animateTabItem(_ tabBarController: UITabBarController,
+                               shouldSelect viewController: UIViewController) {
+       guard let item = viewController.tabBarItem,
+             let index = tabBarController.tabBar.items?.firstIndex(of: item),
+             let targetTabBarView = getBarItemView(tabBar: tabBarController.tabBar,
+                                                   selectedIndex: index + 1)
+       else {
+           return
+       }
+       
+       bounceAnimation(target: targetTabBarView)
+   }
+
+	private func getBarItemView(tabBar: UITabBar, selectedIndex: Int) -> UIView? {
+
+		let tabBarSubviews = tabBar.subviews.sorted(by: { $0.frame.minX < $1.frame.minX })
+		guard tabBarSubviews.indices.contains(selectedIndex) else { return nil }
+		return tabBarSubviews[selectedIndex]
+	}
+
+	private func bounceAnimation(target view: UIView) {
+
+		UIView.animate(withDuration: 0.1) {
+
+			view.transform = .init(scaleX: 1.2, y: 1.2)
+		} completion: {
+
+			guard $0 else { return }
+			UIView.animate(withDuration: 0.1) {
+
+				view.transform = .init(scaleX: 1, y: 1)
+			}
+		}
+	}
}
```

`animateTabItem`メソッドで選択されたタブバーのボタンをアニメーションさせる処理を行います。
具体的にやっていることはまず、アニメーションしたいtabBarItemを`UIView`型として取得します。

以下処理で、選択されたタブの画面(`viewController`)から`tabBarItem`を取得して、選択された画面のタブバーからみたときのインデックスを取得しています。
```swift
        guard let item = viewController.tabBarItem,
	          let index = tabBarController.tabBar.items?.firstIndex(of: item),
	          let targetTabBarView = getBarItemView(tabBar: tabBarController.tabBar,
		                                            selectedIndex: index + 1)
		else {
			return
		}
```

`getBarItemView`メソッドでは、選択されたインデックスを使ってタブバーの`subviews`から選択されたタブの`UIView`型で取得します。
```swift
private func getBarItemView(tabBar: UITabBar, selectedIndex: Int) -> UIView? {

		let tabBarSubviews = tabBar.subviews.sorted(by: { $0.frame.minX < $1.frame.minX })
		guard tabBarSubviews.indices.contains(selectedIndex) else { return nil }
		return tabBarSubviews[selectedIndex]
	}
```

タブバーの`subviews`を取得するときに、`frame`をみてタブの左から右の順に並び替えていますが(画面でタブを見た時と同じ並びにする)
これを行っているのは、[こちらの記事](https://qiita.com/kamimi01/items/829dcfc9d03380dd4b38)に記載されている通り時々`subviews`でとれるviewの並びが画面通りの並びになっておらず選択したタブのインデックスと異なるタブが取れてしまうことがあるため、並び替えています。
```swift
let tabBarSubviews = tabBar.subviews.sorted(by: { $0.frame.minX < $1.frame.minX })
```

上記流れで、アニメーションさせたい`UIView`が取得できたので、`bounceAnimation`でアニメーションさせる実装を追加します。

```swift
private func bounceAnimation(target view: UIView) {

		UIView.animate(withDuration: 0.1) {

			view.transform = .init(scaleX: 1.2, y: 1.2)
		} completion: {

			guard $0 else { return }
			UIView.animate(withDuration: 0.1) {

				view.transform = .init(scaleX: 1, y: 1)
			}
		}
	}
```

アニメーションの実装はシンプルで、
0.1秒間で選択したタブを1.2倍に膨らませて、その後0.1秒間で元の大きさに戻すようにアニメーションをしています。

最終的な実装が以下の通りです。

```swift
final class MainTabBarController: UITabBarController {

	// MARK: - factory method

	static func build() -> UIViewController {

		let vc = MainTabBarController()
		return vc
	}

	// MARK: - lifecycle method

	override func viewDidLoad() {

		super.viewDidLoad()
		self.delegate = self
		setupTab()
	}
}

extension MainTabBarController: UITabBarControllerDelegate {

	func tabBarController(_ tabBarController: UITabBarController,
	                      shouldSelect viewController: UIViewController) -> Bool {

		animateTabItem(tabBarController, shouldSelect: viewController)
		return true
	}

	private func animateTabItem(_ tabBarController: UITabBarController,
	                            shouldSelect viewController: UIViewController) {
		guard let item = viewController.tabBarItem,
		      let index = tabBarController.tabBar.items?.firstIndex(of: item),
		      let targetTabBarView = getBarItemView(tabBar: tabBarController.tabBar,
		                                            selectedIndex: index + 1)
		else {
			return
		}

		bounceAnimation(target: targetTabBarView)
	}

	private func getBarItemView(tabBar: UITabBar, selectedIndex: Int) -> UIView? {

		let tabBarSubviews = tabBar.subviews.sorted(by: { $0.frame.minX < $1.frame.minX })
		guard tabBarSubviews.indices.contains(selectedIndex) else { return nil }
		return tabBarSubviews[selectedIndex]
	}

	private func bounceAnimation(target view: UIView) {

		UIView.animate(withDuration: 0.1) {

			view.transform = .init(scaleX: 1.2, y: 1.2)
		} completion: {

			guard $0 else { return }
			UIView.animate(withDuration: 0.1) {

				view.transform = .init(scaleX: 1, y: 1)
			}
		}
	}
}

// MARK: - private method

private extension MainTabBarController {

	func setupTab() {

		guard let mainVC = UIStoryboard(custom: .Main).instantiateViewController(custom: .MainViewController) as? MainViewController,
		      let chatVC = UIStoryboard(custom: .Main).instantiateViewController(custom: .ChatViewController) as? ChatViewController,
		      let graphVC = UIStoryboard(custom: .Main).instantiateViewController(custom: .DispViewController) as? DispViewController,
		      let settingVC = UIStoryboard(custom: .Main).instantiateViewController(custom: .SettingViewController) as? SettingViewController
		else {

			assertionFailure("Not found require viewController.")
			return
		}

		mainVC.tabBarItem = getTabBarItem(title: "Home",
		                                  systemImageName: "homekit",
		                                  tag: 0)
		chatVC.tabBarItem = getTabBarItem(title: "Chat",
		                                  systemImageName: "message.fill",
		                                  tag: 1)
		graphVC.tabBarItem = getTabBarItem(title: "Graph",
		                                   systemImageName: "chart.xyaxis.line",
		                                   tag: 2)
		settingVC.tabBarItem = getTabBarItem(title: "Setting",
		                                     systemImageName: "gearshape.fill",
		                                     tag: 3)

		viewControllers = [mainVC, chatVC, graphVC, settingVC]
	}

	func getTabBarItem(title: String, systemImageName: String, tag: Int) -> UITabBarItem {

		let tabBarItem = UITabBarItem(title: title, image: getTabIcon(systemImageName), tag: tag)
		tabBarItem.selectedImage = getTabIcon(systemImageName)
		return tabBarItem
	}

	func getTabIcon(_ iconName: String) -> UIImage? {

		return UIImage(systemName: iconName)
	}
}
```

以上で、目標のタブバー選択時のタブのアニメーションの実装が完了です！

# 終わり
タブバーのアニメーションの実装例を紹介しましたが、他にももっと良い実装があると思うので、ご指摘などありましたらぜひコメントいただけると幸いです🙏