背景
無限圖片輪播器我已經(jīng)記不清自己寫了多少次了,不管是用 UIScrollView + Timer
方式實現(xiàn),還是UICollectionView + Timer
方式實現(xiàn)皱卓,其本質(zhì)是一樣的,核心點都是: 讓第一個Item重復添加到最后位置,最后個Item重復添加為第0個位置家乘。比如:輪播數(shù)組的順序本來是 [0, 1, 2], 需要將數(shù)組改為[2, 0, 1, 2, 0]藏澳,傳入自定義bannerView
仁锯。
實現(xiàn)目標:
輪播器需要兼容視頻和圖片共存,當顯示視頻時翔悠,移除定時器业崖,要求視頻正常播放完成后自動到下一頁,若下一頁是圖片開啟定時器蓄愁,6秒后劃到下一頁双炕,手動拖動輪播器,也需要判斷是否是視頻頁撮抓,若是視頻頁則關(guān)閉定時器妇斤,若是圖片頁則正常在6秒后跳轉(zhuǎn)(滑動的時候需要移除定時器,所以當手勢完成后再判斷是否是視頻頁丹拯,若是不要開啟定時器站超。若是圖片,則開啟定時器)乖酬。代碼倉庫: 去小專欄搜索
Alexanderwg
實現(xiàn)效果:
實現(xiàn)步驟:
1死相、提供幾種實現(xiàn)輪播器的方式,純圖片輪播咬像、視頻和圖片共存輪播算撮,純視頻輪播
// 方式一 : 視頻和圖片共存双肤,純視頻,這里傳入的數(shù)組是一個單獨的View數(shù)組钮惠。
public init(frame: CGRect, bannerViews: [ATBannerPlayerView], banners: [ATHomeBannerModel], timeInterval: CGFloat, isVideo: Bool? = false) {
super.init(frame: frame)
NotificationCenter.default.addObserver(self, selector: #selector(restart(_:)), name: Notification.Name(rawValue: NotificationNameHomeBannerVideoStopName), object: nil)
self.numberOfPages = bannerViews.count - 2
self.timeInterval = timeInterval
self.bannerViews = bannerViews
self.banners = banners
self.isOnlyImages = false
size = Size(width: frame.size.width, height: frame.size.height)
setupBannerView()
setupContentView()
}
// 方式二 純圖片
public init(frame: CGRect, images: [String], timeInterval: CGFloat) {
super.init(frame: frame)
self.numberOfPages = images.count
self.timeInterval = timeInterval
size = Size(width: frame.size.width, height: frame.size.height)
setupBannerView()
setupBannerContent(images)
setupTimer()
}
// 方式三 純圖片
public init(frame: CGRect, images: [String], placeholderImage placeholder: UIImage?, timeInterval: CGFloat) {
super.init(frame: frame)
self.placeholderImage = placeholder
self.numberOfPages = images.count
self.timeInterval = timeInterval
size = Size(width: frame.size.width, height: frame.size.height)
setupBannerView()
setupBannerContent(images)
setupTimer()
}
- 2茅糜、添加輪播內(nèi)容,這里將內(nèi)容添加到
UIScrollView
上素挽,因為要實現(xiàn)手勢左右滑動蔑赘,使用它則是最簡便的方式。
// 這種方式是添加View數(shù)組(視頻和圖片共存)
fileprivate func setupContentView() {
if self.bannerViews.count <= 0 {
return
}
for i in 0..<self.bannerViews.count {
let playerView = self.bannerViews[I]
playerView.frame = CGRect(x: CGFloat(i) * size.width, y: 0, width: size.width, height: size.height)
playerView.backgroundColor = .clear
let tap = UITapGestureRecognizer(target: self, action: #selector(tapAction))
playerView.addGestureRecognizer(tap)
self.urls.append(playerView.urlString)
self.images.append(playerView.imageString)
self.scrollView.addSubview(playerView)
}
self.currentBannerView = self.bannerViews.first!
if self.currentBannerView.isVideo == true {
removeTimer()
}else {
setupTimer()
}
setupScrollView(count: self.bannerViews.count)
}
// 這種方式是純圖片
fileprivate func setupBannerContent(_ images: [String]) {
let count = images.count + 2
var index = 0
while index < count {
var tempIndex = index - 1
if index == count - 1 {
tempIndex = 0
}else if index == 0 {
tempIndex = count - 3;
}
let frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height)
let imageView = UIImageView(frame: frame)
imageView.contentMode = .scaleToFill
if images[tempIndex].hasPrefix("http") {
imageView.sd_setImage(with: URL(string: images[tempIndex]), placeholderImage: UIImage(named: ""))
}else {
imageView.image = UIImage(named: images[tempIndex])
}
let tap = UITapGestureRecognizer(target: self, action: #selector(tapAction))
imageView.addGestureRecognizer(tap)
self.scrollView.addSubview(imageView)
index += 1
}
setupScrollView(count: count)
}
- 3预明、添加定時器缩赛,要求6秒后輪播到下一頁
// 因為在輪播過程中,定時器可能會移除后再添加撰糠,所以想做了一層是否為空的判斷
fileprivate func setupTimer() {
if self.timer == nil {
self.timer = Timer(timeInterval: self.timeInterval, target: self, selector: #selector(nextBannerAction), userInfo: nil, repeats: true)
RunLoop.main.add(self.timer!, forMode: .common)
}
}
- 4酥馍、事件交互相關(guān)
// MARK: 定時器執(zhí)行到下一頁
@objc func nextBannerAction() {
let page = self.pageControl.currentPage + 2
self.scrollView.setContentOffset(CGPoint(x: CGFloat(page) * size.width, y: 0), animated: true)
if self.isOnlyImages == false{
let view = self.bannerViews[page]
self.currentBannerView = view
if view.isVideo == true {
removeTimer()
ATBannerPlayerView.share.rePlay()
} else {
setupTimer()
}
}
}
// MARK: 手勢左右滑動
@objc func swipeBannerAction() {
let page = self.pageControl.currentPage + 1
if self.isOnlyImages == false {
let view = self.bannerViews[page]
self.currentBannerView = view
if view.isVideo == true {
removeTimer()
ATBannerPlayerView.share.rePlay()
}else {
setupTimer()
}
}
}
- 3、輪播內(nèi)容的傳入, 既然傳入的數(shù)組是View類型的數(shù)組阅酪,每個view就是一頁輪播旨袒。單獨的View就是一個圖片或者視頻播放器。
deinit {
NotificationCenter.default.removeObserver(self)
for pla in playerLayers {
pla.removeFromSuperlayer()
}
print("banner 釋放了")
}
public func play(_ urlString: String?, _ imageName: String, _ index: Int) {
if let urlPath = urlString {
if urlPath.hasPrefix("http") { // 網(wǎng)絡(luò)視頻
guard let url = URL(string: urlPath) else {
return
}
playerLayer(url, imageName, index)
return
}
if #available(iOS 16.0, *) { // 本地視頻
let url = URL.init(filePath: urlPath)
playerLayer(url, imageName, index)
}else {
let url = URL(fileURLWithPath: urlPath)
playerLayer(url, imageName, index)
}
}
}
private func isAvaiableUrl(_ url: URL) -> Bool {
let asset = AVAsset(url: url)
return asset.isPlayable
}
private func playerLayer(_ url: URL, _ imageName: String, _ index: Int) {
coverImageView.isUserInteractionEnabled = true
coverImageView.frame = self.bounds
coverImageView.sd_setImage(with: URL(string: imageName), placeholderImage: UIImage(named: "placeholderImageView"))
coverImageView.contentMode = .scaleToFill
self.addSubview(coverImageView)
stop()
let item = AVPlayerItem(url: url)
player.isMuted = true
player.automaticallyWaitsToMinimizeStalling = true
player.actionAtItemEnd = .none
player.replaceCurrentItem(with: item)
let playerLayer = AVPlayerLayer(player: player)
playerLayer.videoGravity = .resizeAspectFill
playerLayer.frame = self.bounds
self.layer.addSublayer(playerLayer)
playerLayers.append(playerLayer)
NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { [weak self] Notification in
guard let self = self else { return }
self.player.seek(to: CMTime.zero)
self.rePlay()
NotificationCenter.default.post(name: NSNotification.Name(rawValue: NotificationNameHomeBannerVideoStopName), object: nil, userInfo: ["currentIndex" : self.currentIndex])
}
self.rePlay()
}
public func rePlay() {
if player.timeControlStatus == .paused {
player.play()
}
}
public func stop() {
if player.timeControlStatus == .playing {
player.pause()
}
}
public func updateBannerContent(_ index: Int? = 0, _ urlString: String = "", _ imageString: String = "", _ urls: [String], _ fileType: Int?) {
if urlString.isEmpty {
return
}
self.currentIndex = index!
self.urlString = urlString
self.imageString = imageString
self.isVideo = fileType == 2 ? true : false
self.isLocalVideo = urlString.hasPrefix("http") ? false : true
if fileType == 2 {
play(urlString, imageString, index!)
return
}
let imageView = UIImageView()
imageView.isUserInteractionEnabled = true
imageView.frame = self.bounds
imageView.contentMode = .scaleToFill
imageView.sd_setImage(with: URL(string: urlString), placeholderImage: UIImage(named: "placeholderImageView"))
self.addSubview(imageView)
}
從代碼中可以出术辐,視頻播放完成后是用一個通知
NSNotification.Name.AVPlayerItemDidPlayToEndTime
來進行處理監(jiān)聽的砚尽,我在這里做了一個處理,完成后辉词,重復播放必孤,并且發(fā)出通知,告知自定義的輪播器瑞躺,視頻已播放完成敷搪,需要劃到下一頁并開啟定時器。
- 4幢哨、 視頻播放完成后的處理邏輯
@objc func restart(_ sender: Notification) {
guard let dict = sender.userInfo else { return}
let index = dict["currentIndex"] as! Int
print("【AVPlayer】索引值相同才能跳轉(zhuǎn):\(self.pageControl.currentPage) - playerIndex: \(index)")
if self.pageControl.currentPage != index {
return
}
nextBannerAction()
}
從代碼中可以看出來赡勘,我在這里做了一次攔截,只有當前播放的視頻索引值和
pageControl
的索引值對應(yīng)的才能開啟定時器嘱么,并進入下一頁狮含。 因為我這里設(shè)計的時候可能有多個視頻,多個視頻就意味著有多個輪播View曼振,視頻播放時長不一樣几迄,執(zhí)行播放完成的通知的時間就不一樣,因為用的是同一個通知冰评,所以需要做一次攔截映胁,只有當前顯示的視頻播放完成后才會繼續(xù)往下執(zhí)行。
- 5甲雅、手勢切換輪播內(nèi)容
extension ATBannerView: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let page = NSInteger((scrollView.contentOffset.x + size.width * 0.5) / size.width)
if page == self.numberOfPages + 1 {
self.pageControl.currentPage = 0
}else if page == 0 {
self.pageControl.currentPage = self.numberOfPages - 1
}else {
self.pageControl.currentPage = page - 1
}
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
removeTimer()
setupOffset()
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
let page = NSInteger((scrollView.contentOffset.x + size.width * 0.5) / size.width)
if page == self.numberOfPages + 1 {
self.scrollView.contentOffset = CGPoint(x: size.width, y: 0)
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
setupOffset()
if self.currentBannerView.isVideo == false {
setupTimer()
}
swipeBannerAction()
}
}
- 6解孙、當前控制器出棧后需要銷毀定時器和通知
deinit {
NotificationCenter.default.removeObserver(self)
removeTimer()
}
- 7坑填, 外層調(diào)用,創(chuàng)建輪播器
override func viewDidLoad() {
super.viewDidLoad()
self.title = "首頁"
let model1 = ATHomeBannerModel()
model1.bannerUrl = "https://q3.itc.cn/images01/20240313/5d18f1f3b27d47ad8262bacc72642a35.jpeg"
model1.fileType = 1
model1.videoUrl = ""
let model0 = ATHomeBannerModel()
model0.videoUrl = "https://dh2.v.netease.com/2017/cg/fxtpty.mp4"
model0.fileType = 2
model0.bannerUrl = "https://burnlab-app-default.oss-accelerate.aliyuncs.com/ikier/ikier/public/test/project/1726880793163.jpg"
let model2 = ATHomeBannerModel()
model2.bannerUrl = "https://i1.hdslb.com/bfs/archive/18d674f89850c59a2371894a9c432ff6caf6586f.jpg"
model2.fileType = 1
model2.videoUrl = ""
let model3 = ATHomeBannerModel()
model3.videoUrl = "https://vd2.bdstatic.com/mda-ibtfrfq2agf2216r/hd/mda-ibtfrfq2agf2216r.mp4?v_from_s=tc_videoui_4135&auth_key=1611284615-0-0-e2902fb9f17bb70ca87e881a32a37a27&bcevod_channel=searchbox_feed&pd=1&pt=3&abtest="
model3.fileType = 2
model3.bannerUrl = "https://pic.rmb.bdstatic.com/bjh/down/606d54ffe1b7dfab5ed1235959d69c9e.jpeg"
let model4 = ATHomeBannerModel()
model4.bannerUrl = "https://pic.rmb.bdstatic.com/bjh/down/606d54ffe1b7dfab5ed1235959d69c9e.jpeg"
model4.fileType = 1
model4.videoUrl = ""
let model5 = ATHomeBannerModel()
model5.bannerUrl = "https://pic.rmb.bdstatic.com/bjh/down/606d54ffe1b7dfab5ed1235959d69c9e.jpeg"
model5.fileType = 2
model5.videoUrl = "https://vd4.bdstatic.com/mda-jg3pp0t2atgbjh5d/sc/mda-jg3pp0t2atgbjh5d.mp4?auth_key=1601173151-0-0-260509c2cb8752744f1c2b5652747ad1&bcevod_channel=searchbox_feed&pd=1&pt=3"
let banners = [model1, model0, model2, model3, model4, model5]
updateBanner(banners)
}
// banner
public func updateBanner(_ banners: [ATHomeBannerModel]) {
// 思路:無限循環(huán)的話
// 1弛姜、需要將第0個同時放在第0個和最后一個
// 2脐瑰、需要將最后一個放在第0個
// 3、因為可能有視頻廷臼,那么單獨給個view來同時處理視頻和圖片
// 4苍在、如果只是圖片,那么直接傳圖片即可
// 視頻可能不是第一索引值荠商,
var originalArray = [ATHomeBannerModel]()
var lists = [ATBannerPlayerView]()
var firstArr = [ATBannerPlayerView]()
var isContainsVideo = false
var urls = [String]()
var images = [String]()
// 判斷是否包含視頻
for model in banners {
if model.fileType == 2 {
urls.append(model.videoUrl)
isContainsVideo = true
originalArray.append(model)
}else {
originalArray.append(model)
images.append(model.bannerUrl)
}
}
let frame = CGRect(x: 0, y: 200, width: UIScreen.main.bounds.size.width, height: UIScreen.main.bounds.size.width * (9 / 16))
if isContainsVideo == true {
for (index, item) in originalArray.enumerated() {
let view = ATBannerPlayerView(frame: frame)
view.updateBannerContent(index, (item.fileType == 2 ? item.videoUrl : item.bannerUrl), item.bannerUrl, urls, item.fileType)
lists.append(view)
if index == 0 {
let view0 = ATBannerPlayerView(frame: frame)
view0.updateBannerContent(index, (item.fileType == 2 ? item.videoUrl : item.bannerUrl), item.bannerUrl, urls, item.fileType)
firstArr.append(view0)
}else if index == banners.count - 1 {
let view4 = ATBannerPlayerView(frame: frame)
view4.updateBannerContent(index, (item.fileType == 2 ? item.videoUrl : item.bannerUrl), item.bannerUrl, urls, item.fileType)
lists.insert(view4, at: 0)
}
}
lists.append(firstArr.first!)
let bannerView = ATBannerView(frame: frame, bannerViews: lists, banners: banners, timeInterval: 6)
bannerView.backgroundColor = UIColor(hexString: "#F2F2F2")
self.view.addSubview(bannerView)
}else {
let bannerView = ATBannerView(frame: frame, images: images, timeInterval: 6)
bannerView.backgroundColor = UIColor(hexString: "#F2F2F2")
self.view.addSubview(bannerView)
}
}
總結(jié):
在實現(xiàn)的時候寂恬,出現(xiàn)很多細枝末節(jié)的小問題,比如這句代碼player.replaceCurrentItem(with: item)
會導致線程阻塞莱没,原因是AVPlayer
的replaceCurrentItemWithPlayerItem
方法在切換視頻時底層會調(diào)用信號量等待然后導致當前線程卡頓初肉。解決方法可以使用AVQueuePlayer
來按順序執(zhí)行播放。