Swift 無限輪播器-兼容視頻和圖片

背景

無限圖片輪播器我已經(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)效果:

視頻和圖片共存的輪播器.gif
  • 實現(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)會導致線程阻塞莱没,原因是AVPlayerreplaceCurrentItemWithPlayerItem方法在切換視頻時底層會調(diào)用信號量等待然后導致當前線程卡頓初肉。解決方法可以使用AVQueuePlayer來按順序執(zhí)行播放。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末饰躲,一起剝皮案震驚了整個濱河市牙咏,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌属铁,老刑警劉巖眠寿,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異焦蘑,居然都是意外死亡,警方通過查閱死者的電腦和手機盒发,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進店門例嘱,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人宁舰,你說我怎么就攤上這事拼卵。” “怎么了蛮艰?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵腋腮,是天一觀的道長。 經(jīng)常有香客問我壤蚜,道長即寡,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任袜刷,我火速辦了婚禮聪富,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘著蟹。我一直安慰自己墩蔓,他們只是感情好梢莽,可當我...
    茶點故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著奸披,像睡著了一般昏名。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上阵面,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天轻局,我揣著相機與錄音,去河邊找鬼膜钓。 笑死嗽交,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的颂斜。 我是一名探鬼主播夫壁,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼沃疮!你這毒婦竟也來了盒让?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤司蔬,失蹤者是張志新(化名)和其女友劉穎邑茄,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體俊啼,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡肺缕,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了授帕。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片同木。...
    茶點故事閱讀 38,137評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖跛十,靈堂內(nèi)的尸體忽然破棺而出彤路,到底是詐尸還是另有隱情,我是刑警寧澤芥映,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布洲尊,位于F島的核電站,受9級特大地震影響奈偏,放射性物質(zhì)發(fā)生泄漏坞嘀。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一霎苗、第九天 我趴在偏房一處隱蔽的房頂上張望姆吭。 院中可真熱鬧,春花似錦唁盏、人聲如沸内狸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽昆淡。三九已至锰瘸,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間昂灵,已是汗流浹背避凝。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留眨补,地道東北人管削。 一個月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓,卻偏偏與公主長得像撑螺,于是被迫代替她去往敵國和親含思。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,901評論 2 345

推薦閱讀更多精彩內(nèi)容