기능 제대로 구현하는데 1주일 정도 걸린듯..
버그도 많아서 진짜.. 힘들었네...
일단, 비디오는 넣지 않고 이미지만 넣어서 만들고 마지막에 영상을 넣어보려고 한다.
import UIKit
import SnapKit
class ViewController: UIViewController {
typealias VideoCell = VideoCollectionViewCell
typealias ImageCell = ImageCollectionViewCell
// 카드에 들어갈 이미지를 넣은 배열.
private var cardContents: [String] = ["0.jpg", "1.jpg", "2.jpg"]
lazy var collectionView: UICollectionView = {
// collection view layout setting
let layout = UICollectionViewFlowLayout.init()
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
layout.footerReferenceSize = .zero
layout.headerReferenceSize = .zero
// collection view setting
let v = UICollectionView(frame: .zero, collectionViewLayout: layout)
v.isScrollEnabled = true
v.isPagingEnabled = true
v.showsHorizontalScrollIndicator = false
v.register(VideoCell.self, forCellWithReuseIdentifier: "VideoCell")
v.register(ImageCell.self, forCellWithReuseIdentifier: "ImageCell")
v.delegate = self
v.dataSource = self
// UI setting
v.backgroundColor = UIColor.black
v.layer.cornerRadius = 16
return v
}()
lazy var pageControl = UIPageControl()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
self.view.backgroundColor = UIColor(red: 227/255, green: 219/255, blue: 235/255, alpha: 1)
view.addSubview(collectionView)
view.addSubview(pageControl)
let edge = view.frame.width - 40
collectionView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.height.equalTo(edge)
}
pageControl.snp.makeConstraints { make in
make.top.equalTo(collectionView.snp.bottom).offset(10)
make.left.right.equalToSuperview()
}
}
}
extension ViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// page control 설정.
if scrollView.frame.size.width != 0 {
let value = (scrollView.contentOffset.x / scrollView.frame.width)
pageControl.currentPage = Int(round(value))
}
}
}
extension ViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
pageControl.numberOfPages = cardContents.count
return self.cardContents.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCell", for: indexPath) as! ImageCell
cell.configure(image: cardContents[indexPath.item])
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: collectionView.frame.width, height: collectionView.frame.height)
}
}
Image Cell
import UIKit
class ImageCollectionViewCell: UICollectionViewCell {
private let imageView: UIImageView = {
let v = UIImageView()
v.contentMode = .scaleAspectFit
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
self.contentView.addSubview(imageView)
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
func configure(image: String) {
if let image = UIImage(named: image) {
imageView.image = image
}
}
}
여기까지 구현하면, 왼쪽 오른쪽은 스크롤이 불가능한 영역이 된다.
현재 3개인 배열 맨 앞에 원래 맨 마지막 이미지를,
맨 뒤에 원래 맨 처음 이미지를 넣어준다.
private var cardContents: [String] = ["2.jpg", "0.jpg", "1.jpg", "2.jpg", "0.jpg"]
이렇게만 하면 그냥 아까와 같이 작동하되, 사진만 다섯개로 늘어난 view 가 되는데...
우리가 원하는건 피츄부터 시작하는 뷰니까, 처음 시작 시 1번에서 시작하고,
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
collectionView.scrollToItem(at: [0, 1], at: .left, animated: false)
}
피츄에서 왼쪽으로 넘겼을 때 새로 추가한 index 0번 라이츄로 가는데!!! 그 다음에 슈슉 하고 index 3번 라이츄로 움직여주면!!! 왼쪽으로 무한 스크롤 구현이 가능하다. (이미지가 같기 때문에 움직인 걸 아무도 눈치챌 수 없어서 페이지 컨트롤은 3개로 수정하지 않고 5개로 두었다.)
현재 index 3번인 라이츄에서 오른쪽으로 갔을때는? 반대로 하면 되겠죠? 4번 피츄가 나오고 슈슉.슉.슈슈슉 1번 피츄로 움직여주면 오른쪽으로 무한 스크롤 구현이 가능하다.
// 스크롤 뷰의 감속이 끝났을 때 == 스크롤뷰가 멈출 때 == 다음 페이지로 넘어갔을 때!!
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let value = (scrollView.contentOffset.x / scrollView.frame.width)
switch Int(round(value)) {
case 0:
let last = cardContents.count - 2
self.collectionView.scrollToItem(at: [0, last], at: .left, animated: false)
case cardContents.count - 1:
self.collectionView.scrollToItem(at: [0, 1], at: .left, animated: false)
default:
break
}
}
참고
https://medium.com/swlh/swift-make-infinite-scrolling-view-with-uicollectionview-cell-eedd2f9997a8
원래는 컬렉션뷰 대신 스크롤뷰로 작업했었는데, 앱을 실행하자마자 현재 안보이는 카드에 있는 영상도 자동 재생 되는 문제가 있어서, 레퍼런스를 찾고 찾다가!!! 발견했다. 최고의 레퍼런스
타이밍을 잘 맞췄으니까, 이제 셀에 영상을!! 넣을 것이다. 영상을 넣기 위해서는 AVPlayer 를 이용한다. 처음 사용해보는거라 엄청 헤맸음...
동영상 파일을 폴더에 집어넣고, 피츄 영상 피카츄 영상 라이츄 영상 이 나오게 자료를 만들어보았다.
private var cardContents: [String] = ["picka.mov", "0.jpg", "picka.mov", "1.jpg", "picka.mov", "2.jpg", "picka.mov", "0.jpg"]
PlayerView / AVPlayer+Extension / VideoCollectionViewCell 만들어서
여기 있는 파일대로 붙여 넣어주었다. https://github.com/mobiraft/AutoPlayVideoInListExample
컬렉션뷰가 넘어가면서 원래 재생중이던 동영상은 멈춰버리기 위해서 재생중인지 알 수 있는 변수 isPlaying 을 만든다.
// AVPlayer+Extension
import Foundation
import AVKit
extension AVPlayer {
var isPlaying:Bool {
get {
return (self.rate != 0 && self.error == nil)
}
}
}
videoIsMuted - 매너모드 / 소리모드 에 따라 영상 소리를 켜고 끌 수 있게
assetPlayer - 영상을 재생하고 처음으로 돌아가고 멈추고 하는데 필요한 Player
url - 영상 주소
나머지는 읽어보면 대강 이해가 가므로, 이해하고 싶다면 천천히 읽어보자! (사실 나도 다는 이해 못했음...)
// PlayerView
import UIKit
import AVKit
class PlayerView: UIView {
static var videoIsMuted: Bool = true
override class var layerClass: AnyClass {
return AVPlayerLayer.self
}
private var assetPlayer:AVPlayer? {
didSet {
DispatchQueue.main.async {
if let layer = self.layer as? AVPlayerLayer {
layer.player = self.assetPlayer
}
}
}
}
private var playerItem:AVPlayerItem?
private var urlAsset: AVURLAsset?
var isMuted: Bool = true {
didSet {
self.assetPlayer?.isMuted = isMuted
}
}
var url: URL?
init() {
super.init(frame: .zero)
initialSetup()
}
required init?(coder: NSCoder) {
super.init(frame: .zero)
initialSetup()
}
private func initialSetup() {
if let layer = self.layer as? AVPlayerLayer {
layer.videoGravity = AVLayerVideoGravity.resizeAspect
}
}
func prepareToPlay(withUrl url:URL, shouldPlayImmediately: Bool = false) {
guard !(self.url == url && assetPlayer != nil && assetPlayer?.error == nil) else {
if shouldPlayImmediately {
play()
}
return
}
cleanUp()
self.url = url
let options = [AVURLAssetPreferPreciseDurationAndTimingKey : true]
let urlAsset = AVURLAsset(url: url, options: options)
self.urlAsset = urlAsset
let keys = ["tracks"]
urlAsset.loadValuesAsynchronously(forKeys: keys, completionHandler: { [weak self] in
guard let strongSelf = self else { return }
strongSelf.startLoading(urlAsset, shouldPlayImmediately)
})
NotificationCenter.default.addObserver(self, selector: #selector(self.playerItemDidReachEnd), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: nil)
}
private func startLoading(_ asset: AVURLAsset, _ shouldPlayImmediately: Bool) {
var error:NSError?
let status:AVKeyValueStatus = asset.statusOfValue(forKey: "tracks", error: &error)
if status == AVKeyValueStatus.loaded {
let item = AVPlayerItem(asset: asset)
self.playerItem = item
self.assetPlayer = AVPlayer(playerItem: item)
self.didFinishLoading(self.assetPlayer, shouldPlayImmediately)
}
}
private func didFinishLoading(_ player: AVPlayer?, _ shouldPlayImmediately: Bool) {
guard let player = player, shouldPlayImmediately else { return }
DispatchQueue.main.async {
player.play()
}
}
@objc private func playerItemDidReachEnd(_ notification: Notification) {
guard notification.object as? AVPlayerItem == self.playerItem else { return }
DispatchQueue.main.async {
guard let videoPlayer = self.assetPlayer else { return }
videoPlayer.seek(to: .zero)
// videoPlayer.play() // 내가 생각한 카드뷰는 한번 재생하고 끝나면서 다음 카드로 넘어가고 하는거라 play 를 또 하면 영상이 겹쳐 들리는 문제가 발생해서 뺐다.
}
}
func play() {
guard self.assetPlayer?.isPlaying == false else { return }
DispatchQueue.main.async {
self.assetPlayer?.play()
}
}
func pause() {
guard self.assetPlayer?.isPlaying == true else { return }
DispatchQueue.main.async {
self.assetPlayer?.pause()
self.assetPlayer?.seek(to: .zero) // 여기도 셀을 떠났다가 해당 셀에 다시 들어가면 영상이 처음부터 실행되도록 하기 위해 변경.
}
}
func cleanUp() {
pause()
urlAsset?.cancelLoading()
urlAsset = nil
assetPlayer = nil
removeObservers()
}
func removeObservers() {
NotificationCenter.default.removeObserver(self, name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: nil)
}
deinit {
cleanUp()
}
}
playerView 를 넣고, playerView 를 조종하기 위한 메서드들을 만든다.
import UIKit
class VideoCollectionViewCell: UICollectionViewCell {
private let playerView = PlayerView()
var url: URL?
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
self.contentView.addSubview(playerView)
playerView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@objc
func volumeAction(_ sender:UIButton) {
sender.isSelected = !sender.isSelected
playerView.isMuted = sender.isSelected
PlayerView.videoIsMuted = sender.isSelected
}
func play() {
if let url = url {
playerView.prepareToPlay(withUrl: url, shouldPlayImmediately: true)
}
}
func pause() {
playerView.pause()
}
// 우리는 로컬 비디오를 재생할 것이므로, 이렇게!
func configure(_ file: String) {
let file = file.components(separatedBy: ".")
guard let path = Bundle.main.path(forResource: file[0], ofType: file[1]) else {
debugPrint( "\(file.joined(separator: ".")) not found")
return
}
let url = URL(fileURLWithPath: path)
self.url = url
playerView.prepareToPlay(withUrl: url, shouldPlayImmediately: false)
}
}
컬렉션뷰 안에 현재 보이는 비디오 중 첫번째 (이게 페이징이 아닌 스크롤 방식 컬렉션뷰로 구현한 코드를 가져와서 이럼. 커스텀할 생각은? 있지만 너무 오래걸릴 것 같아요..) 비디오를 플레이하고 나머지는 멈춰주는 함수와 영상이 화면 안에 있는지를 체크하는 함수.
// ViewController
extension ViewController {
func playFirstVisibleVideo(_ shouldPlay:Bool = true) {
let cells = collectionView.visibleCells.sorted {
collectionView.indexPath(for: $0)?.item ?? 0 < collectionView.indexPath(for: $1)?.item ?? 0
}
let videoCells = cells.compactMap({ $0 as? VideoCollectionViewCell })
if videoCells.count > 0 {
let firstVisibileCell = videoCells.first(where: { checkVideoFrameVisibility(ofCell: $0) })
for videoCell in videoCells {
if shouldPlay && firstVisibileCell == videoCell {
videoCell.play()
}
else {
videoCell.pause()
}
}
}
}
func checkVideoFrameVisibility(ofCell cell: VideoCollectionViewCell) -> Bool {
var cellRect = cell.containerView.bounds
cellRect = cell.containerView.convert(cell.containerView.bounds, to: collectionView.superview)
return collectionView.frame.contains(cellRect)
}
}
videoCell 이 추가되었으니, cellForItemAt 을 수정한다.
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if cardContents[indexPath.item].hasSuffix(".mov") {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "VideoCell", for: indexPath) as! VideoCell
cell.configure(video: cardContents[indexPath.item])
return cell
} else {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCell", for: indexPath) as! ImageCell
cell.configure(image: cardContents[indexPath.item])
return cell
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView.frame.size.width != 0 {
let value = (scrollView.contentOffset.x / scrollView.frame.width)
pageControl.currentPage = Int(round(value))
}
playFirstVisibleVideo()
}
여기까지 했을 때 문제점, 구현이 잘 되는 것 처럼 보이나...
첫번째 에서 왼쪽 스크롤 해서 영상으로 가면 0번 영상이 재생되려다 scrollToItem 으로 7번 영상으로 넘어가면서 scrollViewDidScroll > playFirstVisibleVideo 가 안잡혀서 7번 영상에 넘어가서 재생이 안되고 멈춰있는 현상이 발생한다.
그래서 scrollViewDidEndDeclarating 에서 scrollToItem 이 일어난 뒤에 play 를 해봤으나 되지 않아서... 애니메이션을 이용해서 끝난 시점에 실행할 수 있도록 구현했다.
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let value = (scrollView.contentOffset.x / scrollView.frame.width)
switch Int(round(value)) {
case 0:
let last = cardContents.count - 2
UIView.animate(withDuration: 0.01, animations: { [weak self] in
self?.collectionView.scrollToItem(at: [0, last], at: .left, animated: false)
}, completion: { [weak self] _ in
self?.playFirstVisibleVideo()
})
case cardContents.count - 1:
self.collectionView.scrollToItem(at: [0, 1], at: .left, animated: false)
default:
break
}
}