一、前言
上篇,我们学习了如何利用 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、入参时调整数据源
上图稍微讲解一下,如何使数据能够无限循环:
- 传入源始数据 N ;
- 修改源始数据,在第 0 个位置,插入 源始数据[N-1] 的数据,在最后一个位置,插入 源始数据[0] 的数据;
- 将调整后的数据作为 UICollectionView 的 dataSource;
- 当数据滚动到第 0 个位置时,将其下标调整至倒数第 2 个位置(无动画切换);
- 当数据滚动到最后一个位置时,将其下标调整至正数第 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 很好理解,就是告诉用户当前轮播图滚动到第几个,如下图:
红色框框中的小圆点:
- 个数代表轮播图中图片的数量;
- 纯白色实心小圆点代表当前下标;
- 半透明小圆点则代表非选中状态;
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 关联
我们已经有了两个小组件,它们的关系如下图:
当我们的 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,这只是最基本的用法,后面的『楼层』我们会使用更为复杂的场景。
目前为止所有源码:《传递门》
有任何问题,欢迎交流,谢谢!