MusicアプリのTabBarControllerを再現してみた

iPhone、iPadの標準ミュージックアプリのプレイヤー画面付きのTabBarControllerを再現してみたので、紹介したいと思います。
※音楽アプリ専用というわけではないのでプレイヤー画面以外の好きな画面を表示することはできますが、この記事の中ではプレイヤー画面と言わせていただきます。

#完成したもの

https://github.com/HiroteruWatanabe/OverlayTabBarController
ソースを見たい方はこちらからどうぞ！　CocoaPodsでも導入可能です。
CocoaPodsの作成に慣れていないので、いろいろ荒いところはありますが、気にしないでください。

##動作環境

iOS 13.0以上
Swift 5

##iPhone

iPhoneの場合はタブバーの上にプレイヤーのプレビュー画面が表示されます。
プレビュー画面をタップするとプレイヤーをモーダル表示します。
プレイヤー画面の高さによって表示形式が変わります。

|プレイヤー非表示時|プレイヤー表示時|プレイヤー表示時（セミモーダル）|
|---|---|---|
|<img width="375" alt="Simulator Screen Shot - iPhone 11 - 2020-03-03 at 01.47.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539421/f6060a33-e4d3-dd2c-df9d-e7996a178d6b.png">|<img width="375" alt="Simulator Screen Shot - iPhone 11 - 2020-03-03 at 01.48.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539421/030c88b4-666b-8b85-ef8a-00d8fdb5f3c7.png">|<img width="375" alt="Simulator Screen Shot - iPhone 11 - 2020-03-06 at 01.34.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539421/77be8d33-98fd-1fb3-daa7-5d68b4540a87.png">|


##iPad

iPadの場合はタブバーの右横にプレビュー画面が表示されます。

|プレイヤー非表示時|プレイヤー表示時|
|---|---|
|![Simulator Screen Shot - iPad Pro (12.9-inch) (3rd generation) - 2020-03-03 at 01.15.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539421/308a2a73-dedd-1bc0-4a05-f68aab65e90b.png)|![Simulator Screen Shot - iPad Pro (12.9-inch) (3rd generation) - 2020-03-03 at 01.15.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539421/df8afcc5-a40f-3f6a-f4ae-a62e9450b0be.png)|

#使い方

基本的にはプレイヤー画面とプレビュー画面を生成してから以下のメソッドを実行するだけです。

```swift
// overlayViewControlelr：プレイヤー画面
// previewingViewController；プレビュー画面
// isExpanded：プレイヤーを表示するかどうか
setOverlayViewController(overlayViewController, previewingViewController: previewingViewController, isExpanded: false)
```

#実装

OverlayTabBarControllerというクラスをUITabBarControllerのサブクラスとして作成しました。
タブバーの上（横）にプレイヤー画面を持てるTabBarControllerです。

## タブバー

UITabBarControllerのタブバーは位置を調整するのが難しかったので、デフォルトのタブバーは見えないようにして同じ見た目のタブバーを追加しました。

```swift
// 実際にユーザーに見せるタブバーのセットアップ
private func setupFlexibleTabBar() {
    tabBar.isHidden = true　// デフォルトのタブバーは隠しておく
    if let flexibleTabbar = flexibleTabBar {
      flexibleTabbar.removeFromSuperview()
    }
    
    flexibleTabBar = UITabBar()
    flexibleTabBar.barStyle = tabBar.barStyle
    flexibleTabBar.translatesAutoresizingMaskIntoConstraints = false
    view.addSubview(flexibleTabBar)
    /*
     iPhoneの場合はデフォルトのタブバーと同じようにレイアウトする
　　　iPadの場合はプレイヤー画面を右側に設置するので、その分幅を小さくする
    */
    updateFlexibleTabBarConstraints()　

　　 //デフォルトのタブバーのタブをコピーする
    updateFlexibleTabBarItems()
  
    /*
     プレビュー画面用のタブバーを設置する
     iPhoneの場合はプレビュー画面がない場合は非表示にする
　　　iPadの場合は常にタブバーの右側に表示する
    */
    setupPreviewingTabBar()
  }
```

このようにデフォルトのタブバーを非表示にし、新たにタブバーを追加することでAutoLayoutで自由に位置を調整することができました。

##プレビュー画面（previewingViewController)

プレビュー画面はプレイヤーを表示していない時にタブバーの上、または横に表示されている画面です。
画像の「Tap Here」と書かれているところです。

<img width="375" alt="プレビュー画面.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539421/b6a477fe-badf-ddeb-b69b-11b967b90891.png">

タブバーと見た目を統一するため、背景にUITabBarを配置しています。（プレビュー画面の背景を透明にする必要があります。）

##プレイヤー画面（overlayViewController)

プレビュー画面をタップするとプレイヤー画面が表示されます。

setOverlayViewControllerメソッドでプレイヤー画面とプレビュー画面をセットします。
画面の横幅などの条件からどのように表示するかを決定します。

```swift
private func setOverlayViewController(_ overlayViewController: UIViewController, previewingViewController: UIViewController, isExpanded: Bool, animated: Bool = true, viewHeight: CGFloat?) {
    self.previewingViewController = previewingViewController
    self.overlayViewController = overlayViewController
    addChild(previewingViewController)
    if isHorizontalSizeClassRegular {
      setOverrideTraitCollection(UITraitCollection(traitsFrom: [UITraitCollection(horizontalSizeClass: .regular), UITraitCollection(verticalSizeClass: .regular)]), forChild: previewingViewController)
      setOverrideTraitCollection(UITraitCollection(traitsFrom: [UITraitCollection(horizontalSizeClass: .regular), UITraitCollection(verticalSizeClass: .regular)]), forChild: overlayViewController)
    } else {
      setOverrideTraitCollection(UITraitCollection(traitsFrom: [UITraitCollection(horizontalSizeClass: .compact), UITraitCollection(verticalSizeClass: .regular)]), forChild: previewingViewController)
      setOverrideTraitCollection(UITraitCollection(traitsFrom: [UITraitCollection(horizontalSizeClass: .regular), UITraitCollection(verticalSizeClass: .regular)]), forChild: overlayViewController)
    }
    
    isOverlayViewExpanded = isExpanded
    setupPreviewingTabBar()
    setupButterflyHandle()
    
    previewingTabBar.addSubview(previewingViewController.view)
    previewingViewController.didMove(toParent: self)
    
    overlayViewController.view.isHidden = !isExpanded
    
    //presentsOverlayViewAsModalでモーダル表示するかどうかを判断する
    if presentsOverlayViewAsModal(viewHeight: viewHeight) {
      // モーダル表示する場合は自動的に角丸がつくので、角丸をつける処理を行わない
      overlayViewController.view.layer.masksToBounds = false
      overlayViewController.view.layer.cornerRadius = 0
      if isExpanded {
        presentOverlayViewController(animated: animated, completion: nil)
      }
    } else {
      // モーダル表示でない場合はChildViewControllerとして登録する
      addChild(overlayViewController)
      view.addSubview(overlayViewController.view)
      setupOverlayViewConstraints(viewHeight: viewHeight)
      overlayViewController.view.layer.masksToBounds = true
      overlayViewController.view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
      overlayViewController.view.layer.cornerRadius = 12
      overlayViewController.didMove(toParent: self)
    }
    
    setupGestureRecognizers(view: previewingViewController.view)
    setupGestureResponder()
    gestureResponder?.isUserInteractionEnabled = isOverlayViewExpanded
    
    view.bringSubviewToFront(flexibleTabBar)
    isOverlayViewTemporaryRemoved = false
  }
```

UITabBarControllerにChildViewControllerを追加するとタブバーにそれが表示されることがあります。
プレビュー画面とプレイヤー画面をChildViewControllerとして登録してもタブバーに表示されないようにするため、以下のようにviewControllersをoverrideしています。
やっていることとしては、viewControllersにoverlayViewControllerとpreviewingViewControllerがあれば、除外しているだけです。

```swift
override open var viewControllers: [UIViewController]? {
    set {
      guard let overlayViewController = overlayViewController,
        let previewingViewController = previewingViewController else {
          super.viewControllers = newValue
          return
      }
　　　 // プレビュー画面とプレイヤー画面が含まれていたら除外する
      super.viewControllers = newValue?.filter({ $0 != overlayViewController && $0 != previewingViewController })
    }
    get {
      guard let overlayViewController = overlayViewController, let previewingViewController = previewingViewController else { return super.viewControllers }
　　　　// プレビュー画面とプレイヤー画面が含まれていたら除外する
      return super.viewControllers?.filter({ $0 != overlayViewController && $0 != previewingViewController })
    }
  }
```

あまり綺麗なやり方ではないのかもしれないですが、他に思いつかなかったのです。。

##画面サイズ変更時の処理

iPadのSplitViewや画面回転に対応するため、以下のメソッドをオーバーライドしています。
willTransitionとtraitCollectionDidChangeだけではtraitCollectionに変化がないiPadの画面回転に対応できないので、viewWillTransitionもオーバーライドしています。

```swift

override open func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)
    guard !isOverlayViewTemporaryRemoved else { return }
　　 // 一旦OverlayViewControllerを画面から取り外す
    temporaryRemoveOverlayViewController()
    setupButterflyHandle()
    setupGestureResponder()
　　 // 新しい画面サイズに合わせてOverlayViewControllerを設置する
    layoutOverlayView(viewHeight: size.height)
  }
  
  override open func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
    super.willTransition(to: newCollection, with: coordinator)
    guard traitCollection.horizontalSizeClass != newCollection.horizontalSizeClass else { return }
    guard overlayViewController != nil else { return }
　　 // 一旦OverlayViewControllerを画面から取り外す
    temporaryRemoveOverlayViewController()
    setupButterflyHandle()
    setupGestureResponder()
  }
  
  override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    guard traitCollection.horizontalSizeClass != previousTraitCollection?.horizontalSizeClass else { return }
    // 新しい画面サイズに合わせてOverlayViewControllerを設置する
    layoutOverlayView()
  }
  
  private func layoutOverlayView(viewHeight: CGFloat? = nil) {
    if let previewView = previewingViewController?.view {
      previewView.setNeedsLayout()
    }
    
    setupFlexibleTabBar()
    if let overlayViewController = overlayViewController, let previewViewController = previewingViewController {
      setOverlayViewController(overlayViewController, previewingViewController: previewViewController, isExpanded: isOverlayViewExpanded, animated: false, viewHeight: viewHeight)
      updateView(viewHeight: viewHeight)
    }
    view.setNeedsLayout()
  }
```

## 最後に

Musicアプリのタブバーではタブバーの幅が小さくなるとiPhoneのタブバーのようにタブアイテムのアイコンの下にタイトルが表示されるようになるのですが、OverlayTabBarControllerではそれが実現できず、常にアイコンの横にタイトルが表示されています。
これを実現する方法をご存知の方がいましたら、是非とも教えていただきたいです！