YouTube動画一覧をNetflix風にスクロール表示し、表示されたら自動再生する実装方法を記載します。
仕様は以下の通り。
- スクロールしたら自動的に再生が始まる
- 再生が終わったら次の動画にスクロールして再生する
- 動画の左右をタップしたら前/次の動画にスクロールする
サンプルコードは以下にアップ。
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 は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) // プレイヤー
- contentView (VideoView)
- subview (StackScrollArrangedSubview)
- stackView (UIStackView)
- leadingBlindView (StackScrollBlindView) // 前へ
- trailingBlindView (StackScrollBlindView) // 次へ
- scrollView (UIScrollView)
スクロールの実装
UIScrollView
の中に UIStackView
を配置してスクロールさせます。
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つだけ配置する。
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
を真ん中にしておく。
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
のサブクラスとしてタップをハンドリングする。
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
を配置して外側から内側にグラデーションがかかるようにする。
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
init(videoID: String, contentSize: CGSize) {
...
let parameters: [VideoEmbedParameter] = [.videoID(videoID),
.showControls(.hidden),
.showRelatedVideo(false),
.showInfo(false)]
player = YTSwiftyPlayer(frame: .zero, playerVars: parameters)
...
}
制御
次(前)にスクロールしようとしたら停止
UIScrollViewDelegate
の scrollViewWillBeginDragging()
が呼ばれたら、一時停止して、最初にシークしておく。
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
willBeginScroll()
}
private func willBeginScroll() {
delegate?.stackScrollView(self, willScrollFromIndex: currentIndex)
}
func stackScrollView(_ stackScrollView: StackScrollView, willScrollFromIndex index: Int) {
let videoView = videoViews[index]
videoView.pause()
videoView.seekToBegining()
}
スクロールし終わったら再生
以下のDelegateが呼ばれたら、pageIndex
を更新して再生する。
-
scrollViewDidEndDragging(willDecelerate)
で!decelerate
scrollViewDidEndDecelerating()
scrollViewDidEndScrollingAnimation()
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if decelerate {
return
}
didEndScroll()
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
didEndScroll()
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
didEndScroll()
}
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)
}
func stackScrollView(_ stackScrollView: StackScrollView, didScrollToIndex index: Int) {
let videoView = videoViews[index]
videoView.play()
}
再生が終わったら次の動画にスクロール
YTSwiftyPlayerDelegate
の player(didChangeState:)
をハンドリングして、state == .ended
だったら停止して、
次にスクロールするように実装する。
extension VideoView: YTSwiftyPlayerDelegate {
func player(_ player: YTSwiftyPlayer, didChangeState state: YTSwiftyPlayerState) {
if state == .ended {
delegate?.videoViewDidEndPlaying(self)
pause()
seekToBegining()
}
}
...
}
extension MainViewController: VideoViewDelegate {
func videoViewDidEndPlaying(_ videoView: VideoView) {
stackScrollView.scrollToNextPage(animated: true)
}
}
改善
実装したら以下の問題があったため改善した。
動画部分のタップ/ジェスチャーイベント
YTSwiftyPlayer
をタップすると再生/一時停止が切り替えられるが、1回目のタップが効かない、フリックしても1回目はスクロールされないなどの挙動があった。(WebViewの仕様?!)
タップイベントやジェスチャイベントを YTSwiftyPlayer
に任せず、その上に UIControl
を置いて肩代わりするようにして解決。
UITapGestureRecognizer
だけハンドリングしておく。
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()
}
...
}
@objc private func actionViewTapped(sender: UITapGestureRecognizer) {
if player.playerState == .playing {
pause()
} else {
play()
}
}
前/次の動画内容がレンダリングされない
現在表示されている動画の左右に前後の動画を少し見せているが、動画内容がレンダリングされない。
UIScrollViewのvisibleRectに動画が見えていないと内容がレンダリングされない模様。(WebViewの仕様?!)
1pxだけかかるように配置して解決。
少しわかりにくいが、
VideoView.contentSize
を左右に1px伸ばして生成する。
VideoView
とその内部の YTSwiftyPlayer
のsizeに適用される。
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)
}
...
}
let arrangedInsets = UIEdgeInsets(top: 0, left: 1, bottom: 0, right: 1)
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
の動画内容もレンダリングされる。
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 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>
class MainViewController: UIViewController {
lazy private var videoIDs: [String] = {
return NSArray(contentsOf: Bundle.main.url(forResource: "VideoIDs", withExtension: "plist")!) as! [String]
}()
...
}
更なる改善
起動直後にすべての動画のロードを済ませておいたり、縦横回転時のレイアウト可変実装も行ったけど説明が非常に長くなるので、今回は割愛させていただいた。