iOS Swift5从0到1系列(十四):走入 UICollectionView(三):自定义组件(二):无限轮播图(BannerView)

一、前言

上篇,我们学习了如何利用 UICollectionView 来制作一个普通的轮播图(BannerView);在一般的产品中,普通的 BannerView 除了能显示图片,还需要具备以下几个小功能:

  • 支持左右无限循环轮播;
  • 支持 PageIndicator ,即我们说的指示器(是一个非常小的 View组件,通常配合 BannerView 来使用);
  • 支持定时切换(含动画);
  • 支持用户手动触摸时,停时定时,并在手指松开后,重新开启定时;

废话不多说,直接开干。

二、左右无限循环轮播

细心的小伙伴在读上篇时,可能会发现,在初始化时(便利构造器)中的第二个参数是 loop: Bool ,从字面意思上就可以看出,是否需要循环;我在写上篇分享时,只是留了一个『口子』,并没有实现具体的逻辑,不过,上篇文章给出的源码已有,如果有小伙伴已经看过。

2.1、添加成员变量 loop

public class BannerPageView: UICollectionView, UICollectionViewDelegate, UICollectionViewDataSource {
    // 是否支持左右无限循环,默认为 true
    fileprivate var loop: Bool = true
}

2.2、便利构造器赋值

extension BannerPageView {
    // 便利构造器,调用方只需给出 frame,layout 由该 BannerView 内部实现
    public convenience init(frame: CGRect, loop: Bool = true) {
        ......
        // 必需调用 self.init,详见
        //《iOS Swift5 构造函数分析(一):关键字 designated、convenience、required》
        // https://juejin.cn/post/6932885089546141709
        self.init(frame: frame, collectionViewLayout: layout)
        
        // 是否无限循环,默认 = true
        self.loop = loop
        ......
    }
}

2.3、入参时调整数据源

无限循环示意图.png

上图稍微讲解一下,如何使数据能够无限循环:

  1. 传入源始数据 N ;
  2. 修改源始数据,在第 0 个位置,插入 源始数据[N-1] 的数据,在最后一个位置,插入 源始数据[0] 的数据;
  3. 将调整后的数据作为 UICollectionView 的 dataSource;
  4. 当数据滚动到第 0 个位置时,将其下标调整至倒数第 2 个位置(无动画切换);
  5. 当数据滚动到最后一个位置时,将其下标调整至正数第 2 个位置(无动画切换);

这样,我们就能来回在 [ 1 ~ N-2 ] 之间来回浏览,以达到无限循环的目的;代码如下:

public class BannerPageView: UICollectionView, UICollectionViewDelegate, UICollectionViewDataSource {
    ......
    public func setUrls(_ urls: [String]) {
        // 原始数据:[a, b, c]
        self.urls = urls
        reData()
    }
    
    public func setLoop(_ loop: Bool) {
        self.loop = loop
    }
    
    func reData() {
        // 如果支持无限循环,数据变为:[c, a, b, c, a]
        if loop {
            urls!.insert(urls!.last!, at: 0)
            urls!.append(urls![1])
        }
        
        reloadData()
        layoutIfNeeded()
        
        if loop {
            // 如果无限循环,因为数据前、后都额外添加了两项,所以,原来下标为 0 的现在变成了 1
            scrollToItem(at: IndexPath(row: loop ? 1 : 0, section: 0),
                         at: UICollectionView.ScrollPosition(rawValue: 0),
                         // 重新定位下标时,不要动画,否则用户会觉得很奇怪
                         animated: false)
        }
    }
    ......
}

上面的代码,是在数据初始传入时,进行的调整;或者,之后数据发生更新时调整;同时,上面我也说了,数据每次滚动后,我们需要判断是否达到位置 0 或者位置 N-1,如果达到,就要调整;在 UICollectionView(一)中,我说过,UICollectionView 的操作是由委托(UICollectionViewDelegate)来负责,因此,我们还需要实现委托,代码如下:

public class BannerPageView: UICollectionView, UICollectionViewDelegate, UICollectionViewDataSource {
    ......
    // MARK: UICollectionViewDelegate
    
    public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        // 计算 page 下标 = 水平滚动偏移值 / 宽度
        var idx = Int(contentOffset.x / frame.size.width)
        
        // 如果开启了无限循环,则需要在每次滚动结束后,判断是否需要重新定位
        if loop {
            // 以 [c, a, b, c, a] 为例
            if idx == 0 {
                // 如果 idx == 0,表明已经滑到最左侧的 c,我们需要将其滚动到【倒数第2位】
                scrollToItem(at: IndexPath(row: urls!.count - 2, section: 0), 
                             at: UICollectionView.ScrollPosition(rawValue: 0), 
                             animated: false)
            } else if idx == urls!.count - 1 {
                // 如果 idx == 最后,表明已经滑到最右侧的 a,我们需要将其滚动到【第1位】
                scrollToItem(at: IndexPath(row: 1, section: 0), 
                             at: UICollectionView.ScrollPosition(rawValue: 0), 
                             animated: false)
            }
        }
    }
    ......
}

三、PageIndicator(指示器)

PageIndicator 很好理解,就是告诉用户当前轮播图滚动到第几个,如下图:

page-indicator.png

红色框框中的小圆点:

  • 个数代表轮播图中图片的数量;
  • 纯白色实心小圆点代表当前下标;
  • 半透明小圆点则代表非选中状态;

PageIndicator 同样也是一个自定义的小组件(我们之前在广告页学过如何绘制圆及着色),这里就直接给出代码:

import UIKit

fileprivate let kGap: CGFloat = 5.0
// 半透明白色背景
fileprivate let kBgColor = UIColor(red: 1, green: 1, blue: 1, alpha: 0.5).cgColor
// 纯实心白色背景
fileprivate let kFgColor = UIColor(red: 1, green: 1, blue: 1, alpha: 1).cgColor

class BannerPageIndicator: UIView {
    var indicators: [CAShapeLayer] = []
    var curIdx: Int = 0

    // 添加小圆点
    public func addCircleLayer(_ nums: Int) {
        if nums > 0 {
            for _ in 0..<nums {
                let circle = CAShapeLayer()
                circle.fillColor = kBgColor
                indicators.append(circle)
                layer.addSublayer(circle)
            }
        }
    }
    
    // 计算并居中排列
    public override func layoutSubviews() {
        super.layoutSubviews()
        
        let count = indicators.count
        let d = bounds.height
        let totalWidth = d * CGFloat(count) + kGap * CGFloat(count)
        let startX = (bounds.width - totalWidth) / 2
        
        for i in 0..<count {
            let x = (d + kGap) * CGFloat(i) + startX
            let circle = indicators[i]
            circle.path = UIBezierPath(roundedRect: CGRect(x: x, y: 0, width: d, height: d), cornerRadius: d / 2).cgPath
        }
        
        setCurIdx(0)
    }
    
    // 设置当前展示的图的下标
    public func setCurIdx(_ idx: Int) {
        // 先修改当前小圆点背景(半透明)
        indicators[curIdx].fillColor = kBgColor
        
        // 修改下标
        curIdx = idx
        
        // 再修改实际对应图片的下标的小圆点背景(纯白色)
        indicators[curIdx].fillColor = kFgColor
    }
}

四、BannerPageView 与 BannerPageIndicator 关联

我们已经有了两个小组件,它们的关系如下图:

BannerViewArch.png

当我们的 BannerPageView 切换时,需要回调通知 BannerView,BannerView 再去设置指示器的小圆点;在 iOS 中,无论是 OC 还是 Swift ,都是通过 Delegate(Protocol)来实现,这里,我们自定义了一个 BannerDelegate :

import Foundation

public protocol BannerDelegate: NSObjectProtocol {
    func didPageChange(idx: Int)
}

4.1、BannerView 实现委托

import UIKit

public class BannerView: UIView, BannerDelegate {
    fileprivate var banner: BannerPageView?
    fileprivate var indicators: BannerPageIndicator?
    
    public override init(frame: CGRect) {
        super.init(frame: frame)

        banner = BannerPageView(frame: frame, loop: true)
        // 设置委托为自己
        banner?.bannerDelegate = self
        addSubview(banner!)
        
        indicators = BannerPageIndicator(frame: CGRect.zero)
        indicators?.translatesAutoresizingMaskIntoConstraints = false
        addSubview(indicators!)
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    public func setData(_ urls: [String], _ loop: Bool) {
        banner?.setLoop(loop)
        banner?.setUrls(urls)
        adjustIndicator(urls.count)
    }
    
    // MARK: BannerDelegate
    public func didPageChange(idx: Int) {
        indicators?.setCurIdx(idx)
    }
    
    func adjustIndicator(_ count: Int) {
        indicators?.addCircleLayer(count)
        NSLayoutConstraint.activate([
            indicators!.widthAnchor.constraint(equalToConstant: frame.width),
            indicators!.heightAnchor.constraint(equalToConstant: 8),
            indicators!.centerXAnchor.constraint(equalTo: centerXAnchor),
            indicators!.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10)
        ])
    }
}

4.2、修改 BannerPageView(委托回调)

public class BannerPageView: UICollectionView, UICollectionViewDelegate, UICollectionViewDataSource {
    var bannerDelegate: BannerDelegate?
    ......
    
    // 如果是循环滚动,要在滚动结束后计算是否需要重新定位
    func redirectPosition() {
        // 计算 page 下标 = 水平滚动偏移值 / 宽度
        var idx = Int(contentOffset.x / frame.size.width)
        
        // 如果开启了无限循环,则需要在每次滚动结束后,判断是否需要重新定位
        if loop {
            // 以 [c, a, b, c, a] 为例
            if idx == 0 {
                // 如果 idx == 0,表明已经滑到最左侧的 c,我们需要将其滚动到【倒数第2位】
                scrollToItem(at: IndexPath(row: urls!.count - 2, section: 0), at: UICollectionView.ScrollPosition(rawValue: 0), animated: false)
                idx = urls!.count - 3
            } else if idx == urls!.count - 1 {
                // 如果 idx == 最后,表明已经滑到最右侧的 a,我们需要将其滚动到【第1位】
                scrollToItem(at: IndexPath(row: 1, section: 0), at: UICollectionView.ScrollPosition(rawValue: 0), animated: false)
                idx = 0
            } else {
                idx -= 1
            }
        }

        bannerDelegate?.didPageChange(idx: idx)
    }
    
    // MARK: UICollectionViewDelegate

    // 用户手指触摸产生的滚动才会调用该方法
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        redirectPosition()
    }
    
    ......
}

五、定时切换(含动画)

定时切换,顾名思义,就需要用到定时器,在广告页时,我们用了 GCD 定时器,今天,我们将使用另一种定时器:Timer(Swift)/ NSTimer(OC);给 Banner 添加定时器很简单(这里就要赞一下 Swift 的 extension,方便代码拆分):

class BannerPageView: UICollectionView, UICollectionViewDelegate, UICollectionViewDataSource {
    fileprivate var timer: Timer?
    
    ......
    
    public func setUrls(_ urls: [String]) {
        ......
        startTimer()
    }
    
    // MARK: UICollectionViewDelegate
    
    // 当执行 setContentOffset 或者 scrollRectVisible 完成时,且 animated = true 时,该方法会被执行
    // 注:如果 animated = false 该方法是不会被调用的
    func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
        redirectPosition()
    }
    ......
}

// 扩展:处理定时器
extension BannerPageView {
    func startTimer() {
        endTimer()
        timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: { [weak self] _ in
            self?.next()
        })
    }
    
    // 结束定时器
    func endTimer() {
        timer?.invalidate()
        timer = nil
    }
    
    func next() {
        let idx = Int(contentOffset.x / frame.size.width)
        scrollToItem(at: IndexPath(row: idx + 1, section: 0), 
                     at: UICollectionView.ScrollPosition(rawValue: 0), 
                     animated: true)
    }
}

定时器我们已经有了,然而,这里有点用户体验问题:当用户手指触摸时,由于定时器不断触发,仍旧会触发翻页,因此,我们需要处理:

  • 用户触摸时,停止定时器;
  • 用户松开时,重启定时器;

实现也很简单,我们只需要处理 UIScrollViewDelegate 中的两个方法即可,如下:

// 扩展:处理定时器
extension BannerPageView {
    // 用户手指触摸停止定时器
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        endTimer()
    }
    
    // 松开后重启定时器
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        startTimer()
    }
}

六、处理点击事件

Banner 点击处理这个就很简单了,我们只需要在 BannerView 中添加 Tap 就行:

public class BannerView: UIView, BannerDelegate {
    ......
    public override init(frame: CGRect) {
        super.init(frame: frame)
        
        isUserInteractionEnabled = true
        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.handleTap)))
        ......
    }
    
    @objc func handleTap() {
        print("handleTap ==== \(String(describing: indicators?.curIdx))")
    }
}

七、总结

Banner 就这么多,总结一下:

  • 本篇分享,我们是继承于 UICollectionView,实际开发中,也可以直接使用 UICollectionView 来作为 BannerView;
  • 因为我们用的是双window,因此,广告页在倒计时(5s)结束的时候,我们的 BannerView 正好已经完成了一次翻页;如果是单window的话,是不会有这问题;(实际开发中,还涉及到网络请求,所以单/双 window 各有各的好处);

我们通过 Banner 来学习 UICollectionView,这只是最基本的用法,后面的『楼层』我们会使用更为复杂的场景。

目前为止所有源码:《传递门》

有任何问题,欢迎交流,谢谢!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容