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

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

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

![youtubescrollview.png](https://qiita-image-store.s3.amazonaws.com/0/156085/8c279c6b-56ef-6f5a-2f1e-82661f07ba14.png)


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

* Xcode 9.4.1
* Swift 4.1


# 外部ライブラリ
Carthageを使って **SnapKit** と **YoutubeKit** をインストールする。

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

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

```
$ vi Cartfile
```
```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` を配置してスクロールさせます。

```Swift: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つだけ配置する。

```Swift: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()
        }
    }
```

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

```Swift: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` のサブクラスとしてタップをハンドリングする。

```Swift: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` を配置して外側から内側にグラデーションがかかるようにする。

```Swift: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)
    }
    ...
}
```

# プレイヤーの実装

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

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

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

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

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

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

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

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

```Swift:StackScrollView.swift
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        if decelerate {
            return
        }
        
        didEndScroll()
    }
    
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        didEndScroll()
    }
    
    func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
        didEndScroll()
    }
```
```Swift: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)
    }
```
```Swift:StackScrollView.swift
    func stackScrollView(_ stackScrollView: StackScrollView, didScrollToIndex index: Int) {
        let videoView = videoViews[index]
        videoView.play()
    }
```

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

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

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

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

## 動画部分のタップ/ジェスチャーイベント
`YTSwiftyPlayer` をタップすると再生/一時停止が切り替えられるが、１回目のタップが効かない、フリックしても１回目はスクロールされないなどの挙動があった。（WebViewの仕様？！）

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

```Swift: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()
        }
        ...
    }
```
```Swift: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に適用される。

```Swift: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)
        }
        ...
}
```
```Swift:StackScrollView.swift
    let arrangedInsets = UIEdgeInsets(top: 0, left: 1, bottom: 0, right: 1)
```
```Swift: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` の動画内容もレンダリングされる。

```Swift: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が入れてあるので好きな動画に変更してください。

```xml: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>
```

```Swift:MainViewController.swift
class MainViewController: UIViewController {
    lazy private var videoIDs: [String] = {
        return NSArray(contentsOf: Bundle.main.url(forResource: "VideoIDs", withExtension: "plist")!) as! [String]
    }()
    ...
}
```

# 更なる改善

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