プログラミング

【Swift】YouTube風の動画ミニビューの実装方法

swift-eyecatch

前回のクライアントワークで、動画再生を実装するというものがありました。

ざっくりと、YouTubeのような画面を切り替えてもミニビューが表示されていたり、全画面に戻すことが出来たりというものを実装しました。

実装するにあたって、YouTubeやミルダムのアプリを参考にサンプルアプリを作成しましたので、その知験をシェアします。

実装したい要件

実装したい要件は以下の3点です。

  • 動画再生画面Viewをミニビューに切り替えられる
  • UITabBarでViewControllerを切り替えてもミニビューを表示し続ける
  • ミニビューから動画再生Viewに切り替えることが出来る

動画再生画面も画面上部に16:9の動画部分があり、その下に動画タイトル等の詳細情報、そしてコメント等が続くviewを想定しています。

ミニビューにすると、動画再生部分のみが画面下側(UITabBarのすぐ上)に収まり、裏側のViewControllerのviewが見えて操作が可能という感じです。

苦労した点

実装するにあたって、苦労したのが、ミニビューをどの画面でも維持するという点です。

当初は、動画のサムネイルがタップされたら、PlayerViewControllerをpresentするという形で実装していたのですが、これだと、タブを切り替えた際にミニビューが維持し続けるという要件が実現できなくなってしまい、設計を見直す羽目になりました。

対処方としては、AppDelegateの var window: UIWIndow! に UIView(PlayerView)をaddする形にしました。

この形だと、UITabBarControllerでTabを切り替えてもミニビューを表示し続けることが出来るようになります。

ミニビューを維持するための全体設計

ポイントは以下の2点です。

  • UIViewを継承したPlayerViewを実装し、スワイプでミニビューに切り替えられるようにする
  • AppDelegateにPlayerViewを表示・非表示の機能を付け加える

その他最低限サンプルアプリとして動作するように、UITabBarControllerの実装も行っています。

Player以外の事前準備

storyboard

まずは、UITabBarControllerを実装して、UIVIewControllerを2つ紐付けます。

1つ目のUIViewControllerには、PlayerViewをshowするためのUIButtonを設置し、IBOutletで紐付けておきます。

import UIKit

class HomeViewController: UIViewController {

    @IBOutlet weak var button: UIButton!
    var tabBarHeight: CGFloat = 0

    override func viewDidLoad() {
        super.viewDidLoad()
        let appDelegate = UIApplication.shared.delegate as! AppDelegate
        appDelegate.tabBarHeight = self.tabBarController?.tabBar.frame.height ?? 0
    }

    @IBAction func tapButton(_ sender: Any) {
        let appDelegate = UIApplication.shared.delegate as! AppDelegate
        appDelegate.showPlayerView()
    }
}

実装ポイントとしては、以下の2点かと思います。

UITabBarの高さをAppDelegateに保持しておく

ポイントとしては、PlayerViewがミニビューになった時に、UITabBarControllerの上部に収まるようにしたいので、UITabBarの高さを取得して、AppDelegateに保持しておきます。

このコードは、カスタムのUITabBarControllerを作成して、そちらで行っても良さそうです。

UIButtonがタップされたら、AppDelegateからshowPlayerView()を呼ぶ

PlayerViewの表示・非表示のコントロールは、AppDelegateに任せます。

なので、AppDelegateには、showPlayerView() の関数を実装しておきます。

class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    // playerViewをOptional型で保持しておきます
    var playerView: PlayerView?

    var backgroundViewCenterY: CGFloat = 0
    let miniPlayerHeight: CGFloat = 100.0
    var tabBarHeight: CGFloat = 0
    var baseHeight: CGFloat = 0
    var topInset: CGFloat = 0

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        return true
    }
}

extension AppDelegate {
    // showPlayerViewが呼ばれたら、画面サイズを確認し、PlayerViewをイニシャライズ
    // そして、windowにplayerViewをaddSubViewして表示します。 
    func showPlayerView() {
        topInset = window?.safeAreaInsets.top ?? 0
        baseHeight = window?.frame.height ?? 0
        playerView = PlayerView(frame: CGRect(x: 0, y: topInset, width: UIScreen.main.bounds.width, height: baseHeight - topInset))
        // スワイプでplayerViewを閉じれる用にセットアップします。(後述)
        setupGesture()

        if let window = window {
            playerView!.alpha = 0.0
            playerView!.center.y += baseHeight
            UIView.transition(with: window, duration: 0.25, options: [.curveLinear], animations: {
                self.window?.addSubview(self.playerView!)
                self.playerView!.alpha = 1.0
                self.playerView!.center.y -= self.baseHeight
            }, completion: nil)
        }
    }
}

次にPlayerViewの実装の詳細を見ていきます。

PlayerViewの実装

import UIKit

final class PlayerView: UIView {

    // webPlaeyrViewには実際はWKWebViewを実装しますが、サンプルでは、一旦UIViewにしています。
    private var webPlayerView: UIView!

    // webPlayerViewのAutoLayoutをコードで操作するための変数
    private var webPlayerViewTopAnchor: NSLayoutConstraint!
    private var webPlayerViewLeadingAnchor: NSLayoutConstraint!
    private var webPlayerViewWidthAnchor: NSLayoutConstraint!
    private var webPlayerViewHeightAnchor: NSLayoutConstraint!

    required init?(coder: NSCoder) {
        // これはなくても良いかも
        fatalError("init(coder:) has not been implemented")
    }

    override init(frame: CGRect) {
        super.init(frame: frame)

        //PlayerView自体の色は一旦黄色にしてわかりやすくしています。
        backgroundColor = .systemYellow

        // PlayerViewがイニシャライズされたら、webPlayerViewをイニシャライズして、addしています。
        webPlayerView = UIView()
        // 背景にはわかりやすくグレーを設定
        webPlayerView.backgroundColor = UIColor.systemGray5
        // AutoLayoutをコードで指定するため、AutoLayoutの自動設定をoffに
        webPlayerView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(webPlayerView)

        // webPlayerViewのサイズ設定(横は画面いっぱいに16:9のサイズ)
        let webPlayerWidth = self.frame.width
        let webPlayerHeight = webPlayerWidth * 9 / 16

        // webPlayerViewのAutoLayoutをコードで設定しています。
        webPlayerViewTopAnchor = webPlayerView.topAnchor.constraint(equalTo: self.topAnchor)
        webPlayerViewTopAnchor.isActive = true
        webPlayerViewLeadingAnchor = webPlayerView.leadingAnchor.constraint(equalTo: self.leadingAnchor)
        webPlayerViewLeadingAnchor.isActive = true
        webPlayerViewWidthAnchor = webPlayerView.widthAnchor.constraint(equalToConstant: webPlayerWidth)
        webPlayerViewWidthAnchor.isActive = true
        webPlayerViewHeightAnchor = webPlayerView.heightAnchor.constraint(equalToConstant: webPlayerHeight)
        webPlayerViewHeightAnchor.isActive = true
        NSLayoutConstraint.activate([
            webPlayerViewTopAnchor,
            webPlayerViewLeadingAnchor,
            webPlayerViewWidthAnchor,
            webPlayerViewHeightAnchor
        ])
    }
}

// MARK: - functions
extension PlayerView {
    // ミニビューになったときにwebPlayerViewのサイズを変更する関数
    func minimizePlayerConstraint() {
        let webPlayerHeight = self.frame.height
        let webPlayerWidth = webPlayerHeight * 16 / 9

        UIView.animate(withDuration: 0.2, animations: { () -> Void in
            self.webPlayerViewWidthAnchor.constant = webPlayerWidth
            self.webPlayerViewHeightAnchor.constant = webPlayerHeight
            self.layoutIfNeeded()
        }, completion: nil)
    }

    // ミニビューから全画面に戻ったときにwebPlayerViewのサイズを変更する関数
    func maximizePlayerConstraint() {
        let webPlayerWidth = self.frame.width
        let webPlayerHeight = webPlayerWidth * 9 / 16

        UIView.animate(withDuration: 0.2, animations: { () -> Void in
            self.webPlayerViewWidthAnchor.constant = webPlayerWidth
            self.webPlayerViewHeightAnchor.constant = webPlayerHeight
            self.layoutIfNeeded()
        }, completion: nil)
    }
}

PlayerViewには、実際に動画が再生される部分のViewを設定しています。本来であればこの箇所はWKWebViewで実装したりするのですが、今回のサンプルでは、単純にUIViewのみにし、背景色をつけています。

次に、PlayerViewがミニビューになったり、全画面表示担った時に、webPlayerViewのAutoLayoutを調整するコードを定義しています。

PlayerView自体はAppDelegateで管理するのですが、サイズが変更された場合は、PlayerViewに定義した関数で、webPlayerViewのAutoLayoutを調整するようにします。

PlayerViewのPanGestureの実装

YouTubeや他の動画アプリでは、動画部分をスワイプで下に移動させることが出来ると思います。便利ですよね。これを実装していきます。

Swiftでは、スワイプ等の検知は、UIGestureRecognizerを使用します。

UIGestureRecognizerにはいくつか種類があり、タップを検知するもの、スワイプを検知するもの、ドラッグを検知するもの様々あります。

ややこしいのは、スワイプを実装したいからといって、UISwipeGestureRecognizerを実装しないことです。

感覚的には、今回のケースはUISwipeGestureRecognizerかなと思ってしまうのですが、ドラッグでViewを操作する時は、PanGestureです。UISwipeGestureRecognizerはスワイプしてすぐ指を離すようなケースで使用します。

なので、UIPanGestureRecognizerを実装して、このジェスチャーをplayerViewにaddします。

// MARK: - PanGesture - Swipe Down
    private func setupGesture() {
        backgroundViewCenterY = window!.center.y
        let gesture = UIPanGestureRecognizer(target: self, action: #selector(panGestureSwipeDown))
        playerView?.addGestureRecognizer(gesture)
    }

    @objc private func panGestureSwipeDown(sender: UIPanGestureRecognizer) {
        let translation = sender.translation(in: playerView!)
        var move = translation.y

        if playerView!.frame.height == 100.0 {
            // case of mini view
            if sender.state == .changed {
                playerView!.center.y = backgroundViewCenterY + move

            } else if sender.state == .ended {
                if move > 10.0 {
                    UIView.transition(with: window!, duration: 0.1, options: [.curveLinear], animations: {
                        self.playerView!.alpha = 0.0
                        self.playerView!.center.y += 100
                    }, completion: { _ in
                        self.playerView?.removeFromSuperview()
                        self.playerView = nil
                    })

                } else if move < -50.0 {
                    UIView.animate(withDuration: 0.3, animations: { () -> Void in
                        let frame = CGRect(x: 0, y: self.topInset, width: self.window!.frame.width, height: self.baseHeight - self.topInset)
                        self.playerView!.frame = frame
                        self.backgroundViewCenterY = self.playerView!.center.y
                        self.playerView!.maximizePlayerConstraint()
                    }, completion: nil)
                } else {
                    UIView.animate(withDuration: 0.1, animations: { () -> Void in
                        self.playerView!.center.y = self.backgroundViewCenterY
                    }, completion: nil)
                }
            }

        } else {
            // case of full view
            if sender.state == .changed {
                if translation.y < 0.0 {
                    move = 0
                }
                self.playerView!.center.y = backgroundViewCenterY + move
            } else if sender.state == .ended {
                if move > 100.0 {
                    UIView.animate(withDuration: 0.3, animations: { () -> Void in
                        let frame = CGRect(x: 0, y: self.baseHeight - self.miniPlayerHeight - self.tabBarHeight, width: self.window!.frame.width, height: 100)
                        self.playerView!.frame = frame
                        self.backgroundViewCenterY = self.playerView!.center.y
                        self.playerView?.minimizePlayerConstraint()
                    }, completion: nil)

                } else {
                    UIView.animate(withDuration: 0.1, animations: { () -> Void in
                        self.playerView!.center.y = self.backgroundViewCenterY
                    }, completion: nil)
                }
            }
        }
    }
}

swipe量を検知するには、

sender.translation(in: UIViewなど).y

で取得することが出来ます。

今回実装したPanGestureは、以下の3点です。

  • 全画面のとき、下方向へのsiwpe量が一定量を超えると、playerViewをminimizeする
  • ミニビューのとき、上方向へのswipe量が一定量を超えると、playerViewをmaximizeする
  • ミニビューの時、下方向へのswipe量が一定量を超えると、playerViewを非表示

ミニビューの高さを今回は100ポイントと定義しているので、playerViewの高さが100か100でないかで全画面表示されているかミニビューなのかを切り分けています。

また、PanGestureは “sender.state”で、pan開始時、pan中、pan終了時を検知することが出来ます。これでドラッグしている最中、ドラッグが終わった時の操作量で、viewの場所を元にもどすだけなのか、ミニマイズもしくはマキシマイズするのかという処理を行っています。

YouTube風の動画ミニビューの作り方のまとめ

説明はだいぶざっくりになってしまいました。。

一番大事なポイントとしては、PlayerViewをAppDelegateのwindowにaddすることで、アプリ内でグローバルにPlayerViewを表示し続けることが出来るという点です。

そして、PlayerViewの表示非表示だけでなく、ミニビューの切り替えなどもすべてAppDelegateでコントロール出来るようにしているという点です。

実際の実装では、このUIViewの継承であるPlayerViewにUITableViewをaddしたり、様々なxibを表示させたりとかなり複雑になるので、難しくはなりますが、アプリ全体でミニビューを維持するには、この方法が一番かなと思います。

もしかしたら、UITabBarControllerにこの作業を任せることは出来るかもしれません。

以上です。