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..

四、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,这只是最基本的用法,后面的『楼层』我们会使用更为复杂的场景。

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

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

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