post Image
[iOS][Swift4.1] YouTube動画一覧をNetflix風に無限スクロールして表示させる

YouTube動画一覧をNetflix風にスクロール表示し、表示されたら自動再生する実装方法を記載します。
仕様は以下の通り。

  • スクロールしたら自動的に再生が始まる
  • 再生が終わったら次の動画にスクロールして再生する
  • 動画の左右をタップしたら前/次の動画にスクロールする

youtubescrollview.png

サンプルコードは以下にアップ。
https://github.com/atsushijike/YouTubeScrollView

  • Xcode 9.4.1
  • Swift 4.1

外部ライブラリ

Carthageを使って SnapKitYoutubeKit をインストールする。

YoutubeKit はYouTube Data APIとYouTube IFrame Player APIをサポートした
WKWebView ベースのYouTubeプレイヤーframework。
https://github.com/rinov/YoutubeKit

YouTubeが公式に用意している YouTube-Player-iOS-Helper は3年以上放置されており UIWebView ベースでレガシーだしObjective-CだしCarthage使えないしで敬遠した。

$ vi Cartfile
github "SnapKit/SnapKit"
github "rinov/YoutubeKit"
$ carthage update --platform iOS

ビュー構造

  • stackScrollView (StackScrollView)
    • scrollView (UIScrollView)
    • stackView (UIStackView)
      • subview (StackScrollArrangedSubview)
      • contentView (VideoView)
        • actionView (UIControl)
        • player (YTSwiftyPlayer) // プレイヤー
    • leadingBlindView (StackScrollBlindView) // 前へ
    • trailingBlindView (StackScrollBlindView) // 次へ

スクロールの実装

UIScrollView の中に UIStackView を配置してスクロールさせます。

StackScrollView.swift
    override init(frame: CGRect) {
        super.init(frame: frame)
        translatesAutoresizingMaskIntoConstraints = false

        scrollView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.clipsToBounds = false
        scrollView.isPagingEnabled = true
        scrollView.delegate = self
        addSubview(scrollView)

        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.axis = .horizontal
        stackView.distribution = .fillEqually
        scrollView.addSubview(stackView)
        ...
        scrollView.snp.makeConstraints { (make) in
            make.size.equalTo(contentSize)
            make.center.equalToSuperview()
        }
        stackView.snp.makeConstraints { (make) in
            make.edges.equalToSuperview()
        }
        ...
    }

UIStackView には arrangedSubview を常に3つだけ配置する。

StackScrollView.swift
    var contentSize: CGSize = .zero {
        didSet {
            stackView.arrangedSubviews.forEach(stackView.removeArrangedSubview)
            (0..<3).forEach { _ in
                let subview = StackScrollArrangedSubview(frame: .zero)
                subview.contentSize = contentSize
                stackView.addArrangedSubview(subview)
            }

            scrollView.snp.updateConstraints { (make) in
                make.size.equalTo(contentSize)
            }
            updateContentViews()
        }
    }

スクロールするごとに StackScrollArrangedSubviewcontentView に現在のindexを元に VideoView を配置して scrollView.contentOffset.x を真ん中にしておく。

StackScrollView.swift
    var pageIndex: Int = 0 {
        didSet {
            updateContentViews()
        }
    }

    private func updateContentViews() {
        if contentViews.count == 0 { return }

        contentViews.forEach { $0.removeFromSuperview() }
        // stackView.arrangedSubviews に[index-1, index, index+1]が表示されるようにする
        stackView.arrangedSubviews.enumerated().forEach { (index, arrangedSubview) in
            guard let view = arrangedSubview as? StackScrollArrangedSubview else { return }

            var contentIndex: Int = 0
            switch index {
            case 0:
                contentIndex = pageIndex - 1
            case 1:
                contentIndex = pageIndex
            case 2:
                contentIndex = pageIndex + 1
            default:
                fatalError()
            }
            if contentIndex >= contentViews.count {
                contentIndex = contentIndex - contentViews.count
            }else if contentIndex <= -1 {
                contentIndex = contentViews.count + contentIndex
            }
            view.contentView = contentViews[contentIndex]
        }
        layoutIfNeeded()
        scrollView.contentOffset.x = contentSize.width
    }

前/次の動画のタップ領域

動画表示部分の左端と右端に leadingBlindView , trailingBlindView を配置する。
タップされた時に前/次の動画にスクロールしたいので UIControl のサブクラスとしてタップをハンドリングする。

StackScrollView.swift
    override init(frame: CGRect) {
        super.init(frame: frame)
        ...
        leadingBlindView.translatesAutoresizingMaskIntoConstraints = false
        leadingBlindView.gradientColors = [UIColor.black, UIColor.black.withAlphaComponent(0.15)]
        leadingBlindView.addTarget(self, action: #selector(leadingBlindViewSelected(sender:)), for: .touchUpInside)
        addSubview(leadingBlindView)

        trailingBlindView.translatesAutoresizingMaskIntoConstraints = false
        trailingBlindView.gradientColors = [UIColor.black.withAlphaComponent(0.15), UIColor.black]
        trailingBlindView.addTarget(self, action: #selector(trailingBlindViewSelected(sender:)), for: .touchUpInside)
        addSubview(trailingBlindView)
        ...
        leadingBlindView.snp.makeConstraints { (make) in
            make.top.bottom.equalTo(scrollView)
            make.leading.equalToSuperview().priority(.high)
            make.width.equalTo(200).priority(.low)
            make.trailing.equalTo(scrollView.snp.leading).inset(arrangedInsets.left)
        }
        trailingBlindView.snp.makeConstraints { (make) in
            make.top.bottom.equalTo(scrollView)
            make.trailing.equalToSuperview().priority(.high)
            make.width.equalTo(200).priority(.low)
            make.leading.equalTo(scrollView.snp.trailing).inset(arrangedInsets.right)
        }
        ...
    }

内部に CAGradientLayer を配置して外側から内側にグラデーションがかかるようにする。

StackScrollView.swift
private class StackScrollBlindView: UIControl {
    private let gradientLayer = CAGradientLayer()
    var gradientColors: [UIColor] = [] {
        didSet {
            gradientLayer.colors = gradientColors.map { $0.cgColor }
        }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        gradientLayer.backgroundColor = UIColor.clear.cgColor
        gradientLayer.startPoint = .zero
        gradientLayer.endPoint = CGPoint(x: 1.0, y: 0)
        layer.addSublayer(gradientLayer)
    }
    ...
}

プレイヤーの実装

YoutubeKitYTSwiftyPlayer をパラメーターとともにinitializeする。
.videoIDでビデオIDを指定することでYouTube動画を表示させることができる。

他の VideoEmbedParameter の内容については YTSwiftyPlayer のヘッダを参照していただくか、YouTube IFrame Player APIのリファレンスも併用してもらうと理解が早いかもしれない。
https://developers.google.com/youtube/player_parameters?hl=ja

VideoView.swift
    init(videoID: String, contentSize: CGSize) {
        ...
        let parameters: [VideoEmbedParameter] = [.videoID(videoID),
                                                 .showControls(.hidden),
                                                 .showRelatedVideo(false),
                                                 .showInfo(false)]
        player = YTSwiftyPlayer(frame: .zero, playerVars: parameters)
        ...
    }

制御

次(前)にスクロールしようとしたら停止

UIScrollViewDelegatescrollViewWillBeginDragging() が呼ばれたら、一時停止して、最初にシークしておく。

StackScrollView.swift
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        willBeginScroll()
    }
StackScrollView.swift
    private func willBeginScroll() {
        delegate?.stackScrollView(self, willScrollFromIndex: currentIndex)
    }
MainViewController.swift
    func stackScrollView(_ stackScrollView: StackScrollView, willScrollFromIndex index: Int) {
        let videoView = videoViews[index]
        videoView.pause()
        videoView.seekToBegining()
    }

スクロールし終わったら再生

以下のDelegateが呼ばれたら、pageIndex を更新して再生する。
* scrollViewDidEndDragging(willDecelerate)!decelerate
* scrollViewDidEndDecelerating()
* scrollViewDidEndScrollingAnimation()

StackScrollView.swift
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        if decelerate {
            return
        }

        didEndScroll()
    }

    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        didEndScroll()
    }

    func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
        didEndScroll()
    }
StackScrollView.swift
    private func didEndScroll() {
        let contentOffset = scrollView.contentOffset
        if contentOffset.x > contentSize.width {
            pageIndex = (pageIndex + 1) < contentViews.count ? (pageIndex + 1) : 0
        } else if contentOffset.x < contentSize.width {
            pageIndex = (pageIndex - 1) >= 0 ? (pageIndex - 1) : (contentViews.count - 1)
        }
        delegate?.stackScrollView(self, didScrollToIndex: currentIndex)
    }
StackScrollView.swift
    func stackScrollView(_ stackScrollView: StackScrollView, didScrollToIndex index: Int) {
        let videoView = videoViews[index]
        videoView.play()
    }

再生が終わったら次の動画にスクロール

YTSwiftyPlayerDelegateplayer(didChangeState:) をハンドリングして、state == .endedだったら停止して、
次にスクロールするように実装する。

VideoView.swift
extension VideoView: YTSwiftyPlayerDelegate {
    func player(_ player: YTSwiftyPlayer, didChangeState state: YTSwiftyPlayerState) {
        if state == .ended {
            delegate?.videoViewDidEndPlaying(self)
            pause()
            seekToBegining()
        }
    }
    ...
}
MainViewController.swift
extension MainViewController: VideoViewDelegate {
    func videoViewDidEndPlaying(_ videoView: VideoView) {
        stackScrollView.scrollToNextPage(animated: true)
    }
}

改善

実装したら以下の問題があったため改善した。

動画部分のタップ/ジェスチャーイベント

YTSwiftyPlayer をタップすると再生/一時停止が切り替えられるが、1回目のタップが効かない、フリックしても1回目はスクロールされないなどの挙動があった。(WebViewの仕様?!)

タップイベントやジェスチャイベントを YTSwiftyPlayer に任せず、その上に UIControl を置いて肩代わりするようにして解決。
UITapGestureRecognizer だけハンドリングしておく。

VideoView.swift
    init(videoID: String, contentSize: CGSize) {
        ...
        let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(actionViewTapped(sender:)))
        actionView.addGestureRecognizer(tapGestureRecognizer)
        addSubview(actionView)

        snp.makeConstraints { (make) in
            make.size.equalTo(contentSize)
        }
        player.snp.makeConstraints { (make) in
            make.size.equalTo(contentSize)
            make.center.equalToSuperview()
        }
        actionView.snp.makeConstraints { (make) in
            make.size.equalTo(contentSize)
            make.center.equalToSuperview()
        }
        ...
    }
VideoView.swift
    @objc private func actionViewTapped(sender: UITapGestureRecognizer) {
        if player.playerState == .playing {
            pause()
        } else {
            play()
        }
    }

前/次の動画内容がレンダリングされない

現在表示されている動画の左右に前後の動画を少し見せているが、動画内容がレンダリングされない。

UIScrollViewのvisibleRectに動画が見えていないと内容がレンダリングされない模様。(WebViewの仕様?!)
1pxだけかかるように配置して解決。

少しわかりにくいが、
VideoView.contentSize を左右に1px伸ばして生成する。
VideoView とその内部の YTSwiftyPlayer のsizeに適用される。

MainViewController.swift
    override func viewDidLoad() {
        super.viewDidLoad()
        ...
        let contentSize = CGSize(width: 800, height: 600)
        stackScrollView.contentSize = contentSize
        stackScrollView.delegate = self
        view.addSubview(stackScrollView)
        ...
        let arrangedInsets = stackScrollView.arrangedInsets
        videoIDs.enumerated().forEach { (index, videoID) in
            // スクロール表示領域に表示されていないと前/次の動画内容がレンダリングされないため、左右に1pxはみ出すように配置する
            let contentSize = CGSize(width: contentSize.width + arrangedInsets.left + arrangedInsets.right, height: contentSize.height)
            let videoView = VideoView(videoID: videoID, contentSize: contentSize)
            videoViews.append(videoView)
        }
        ...
}
StackScrollView.swift
    let arrangedInsets = UIEdgeInsets(top: 0, left: 1, bottom: 0, right: 1)
VideoView.swift
    var contentSize: CGSize = .zero {
        didSet {
            snp.updateConstraints { (make) in
                make.size.equalTo(contentSize)
            }
            player.snp.updateConstraints { (make) in
                make.size.equalTo(contentSize)
            }
        }
    }

StackScrollView.contentSize は内部の UIStackView.arrangedSubviews のsizeに適用されるが、 StackScrollArrangedSubview.contentView つまり VideoView はcenterに配置されるため、左右に1pxづつはみ出すことになり、両隣にある VideoView の動画内容もレンダリングされる。

StackScrollView.swift
private class StackScrollArrangedSubview: UIView {
    ...
    var contentView: UIView? {
        didSet {
            if let contentView = contentView {
                addSubview(contentView)
                contentView.snp.makeConstraints({ (make) in
                    make.center.equalToSuperview()
                })
            }
        }
    }
    ...
}

動画一覧の指定

plistにYouTubeのビデオIDを定義して読み出している。適当な車のCMが入れてあるので好きな動画に変更してください。

VideoIDs.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
    <string>mQLK6c5vOHM</string>
    <string>-QfRAwg3gkM</string>
    <string>2FGTySDWItY</string>
    <string>AFtUpMTs4vI</string>
    <string>7RqMMBk-Au0</string>
</array>
</plist>
MainViewController.swift
class MainViewController: UIViewController {
    lazy private var videoIDs: [String] = {
        return NSArray(contentsOf: Bundle.main.url(forResource: "VideoIDs", withExtension: "plist")!) as! [String]
    }()
    ...
}

更なる改善

起動直後にすべての動画のロードを済ませておいたり、縦横回転時のレイアウト可変実装も行ったけど説明が非常に長くなるので、今回は割愛させていただいた。


『 Swift 』Article List