[iOS] 자동 재생 무한스크롤 영상 배너 만들기 (1/2)

또상·2022년 3월 18일
0

iOS

목록 보기
19/47

기능 제대로 구현하는데 1주일 정도 걸린듯..
버그도 많아서 진짜.. 힘들었네...

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



2. cell 에 영상 넣기

원래는 컬렉션뷰 대신 스크롤뷰로 작업했었는데, 앱을 실행하자마자 현재 안보이는 카드에 있는 영상도 자동 재생 되는 문제가 있어서, 레퍼런스를 찾고 찾다가!!! 발견했다. 최고의 레퍼런스

타이밍을 잘 맞췄으니까, 이제 셀에 영상을!! 넣을 것이다. 영상을 넣기 위해서는 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


붙여 넣는 부분

AVPlayer+Extension

컬렉션뷰가 넘어가면서 원래 재생중이던 동영상은 멈춰버리기 위해서 재생중인지 알 수 있는 변수 isPlaying 을 만든다.

// AVPlayer+Extension
import Foundation
import AVKit

extension AVPlayer {
    
    var isPlaying:Bool {
        get {
            return (self.rate != 0 && self.error == nil)
        }
    }
    
}

PlayerView

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()
    }
}

VideoCell

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)
    }
}

extension ViewController

컬렉션뷰 안에 현재 보이는 비디오 중 첫번째 (이게 페이징이 아닌 스크롤 방식 컬렉션뷰로 구현한 코드를 가져와서 이럼. 커스텀할 생각은? 있지만 너무 오래걸릴 것 같아요..) 비디오를 플레이하고 나머지는 멈춰주는 함수와 영상이 화면 안에 있는지를 체크하는 함수.

// 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)
    }
    
}

ViewController

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
    }
}
profile
0년차 iOS 개발자입니다.

0개의 댓글