美文网首页
iOS Swift5从0到1系列(十四):走入 UICollec

iOS Swift5从0到1系列(十四):走入 UICollec

作者: 青叶小小 | 来源:发表于2021-04-06 10:55 被阅读0次

    一、前言

    上篇,我们学习了如何利用 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,这只是最基本的用法,后面的『楼层』我们会使用更为复杂的场景。

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

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

    相关文章

      网友评论

          本文标题:iOS Swift5从0到1系列(十四):走入 UICollec

          本文链接:https://www.haomeiwen.com/subject/mujskltx.html