一直觉得自己写的不是技术,而是情怀,一个个的教程是自己这一路走来的痕迹。靠专业技能的成功是最具可复制性的,希望我的这条路能让你们少走弯路,希望我能帮你们抹去知识的蒙尘,希望我能帮你们理清知识的脉络,希望未来技术之巅上有你们也有我。

Swift-ImageCarouselView(图片轮播).zip代码下载


banner本地图片

效果

在这里插入图片描述

说明

图片提供一下功能

  • 是否自动轮播
  • 轮播时间
  • 显示 pageControl
  • 设置圆角
  • 设置占位图
  • 加载网络图片
  • 点击事件
  • 监听滚动事件

使用

在这里插入图片描述

import UIKit
import SnapKit

class ViewController: UIViewController {
    
    lazy private var bannerView: ImageCarouselView = {
        let view = ImageCarouselView()
        /// 是否自动轮播
        view.isAutoScroll = true
        
        /// 是否显示 pageControl
        view.showsPageControl = true
        
        /// 自动轮播时间间隔
        view.autoScrollInterval = 1.0
        
        /// 占位图片
        view.placeholderImageName = "placeholder_2"
        
        /// 每次滚动结束后返回当前图片数据
        view.didScrollToIndexBlock = { [weak self] index, value in
            guard let self = self else { return }
            print("滑动到: \(index) -- 图片数据: \(value)")
        }
        
        ///  点击事件
        view.didTapImageBlock = { [weak self] value in
            guard let self = self else { return }
            print(value)
        }
        
        view.layer.cornerRadius = 8.0
        view.layer.masksToBounds = true
        
        return view
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .white
        
        view.addSubview(bannerView)
        bannerView.snp.makeConstraints { make in
            make.center.equalToSuperview()
            make.width.equalTo(200)
            make.height.equalTo(100)
        }
        
        bannerView.list = [
            .remote("https://picsum.photos/id/1015/800/400"),
            .remote("https://picsum.photos/id/1016/800/400"),
            .remote("https://picsum.photos/id/1018/800/400"),
            .remote("https://picsum.photos/id/1020/800/400")
        ]
    }
}

源码

import UIKit
import Kingfisher

class ImageCarouselView: UIView {
    
    enum QRCodeImageSource {
        /// 网络图片
        case remote(String)
        /// 本地图片
        case local(String)
    }
    
    /// 每次滚动结束后返回当前图片数据
    /// 本地图片 -> 图片名称
    /// 网络图片 -> url
    var didScrollToIndexBlock: ((Int, String) -> Void)?
    
    ///  点击事件
    var didTapImageBlock: ((String) -> Void)?
    
    /// 是否自动轮播
    var isAutoScroll: Bool = false {
        didSet {
            isAutoScroll ? startTimer() : stopTimer()
        }
    }

    /// 自动轮播时间间隔
    var autoScrollInterval: TimeInterval = 3.0

    /// 占位图片
    var placeholderImageName: String = "placeholder_2"
    
    /// URL图片数组
    var list: [QRCodeImageSource] = [] {
        
        didSet {
            
            guard list.count > 0 else { return }
            
            // 只有1张不需要无限轮播
            if list.count == 1 {
                
                dataSource = list
                
            } else {
                
                var temp = list
                
                // 前面插入最后一张
                temp.insert(list.last!, at: 0)
                
                // 后面插入第一张
                temp.append(list.first!)
                
                dataSource = temp
            }
            
            collectionView.reloadData()
            
            pageControl.numberOfPages = list.count
            pageControl.currentPage = 0
            pageControl.isHidden = !showsPageControl
            
            DispatchQueue.main.async {
                
                // 默认滚到真正第一张
                if self.list.count > 1 {
                    
                    self.collectionView.scrollToItem(
                        at: IndexPath(item: 1, section: 0),
                        at: .centeredHorizontally,
                        animated: false
                    )
                }
            }
            
            if isAutoScroll {
                startTimer()
            }
        }
    }
    
    /// 是否显示 pageControl
    var showsPageControl: Bool = true {
        didSet {
            pageControl.isHidden = !showsPageControl
        }
    }
    
    lazy private var layout: UICollectionViewFlowLayout = {
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .horizontal
        //layout.itemSize = CGSize(width: screenWidth - 160, height: screenWidth - 160)
        layout.minimumLineSpacing = .zero
        layout.minimumInteritemSpacing = .zero
        layout.sectionInset = .zero
        layout.footerReferenceSize = CGSize.zero
        layout.headerReferenceSize = CGSize.zero
        return layout
    }()
    
    lazy private var collectionView: UICollectionView = {
        let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.showsVerticalScrollIndicator = false
        collectionView.isPagingEnabled = true
        collectionView.bounces = false
        collectionView.backgroundColor = .white
        collectionView.register(ImageCarouselCell.self, forCellWithReuseIdentifier: ImageCarouselCell.identifier)
        //collectionView.frame = CGRect(x: 0, y: 0, width: screenWidth - 160, height: screenWidth - 160)
        return collectionView
    }()
    
    lazy private var pageControl: UIPageControl = {
        let view = UIPageControl()
        // 当前页颜色
        view.currentPageIndicatorTintColor = .white
        // 未选中颜色(灰色半透明)
        view.pageIndicatorTintColor = UIColor.gray.withAlphaComponent(0.5)
        view.hidesForSinglePage = true
        view.isUserInteractionEnabled = false
        return view
    }()
    
    /// 用于显示的数据源
    private var dataSource: [QRCodeImageSource] = []
    
    /// 定时器
    private var timer: Timer?

    override init(frame: CGRect) {
        super.init(frame: frame)
        
        backgroundColor = .white
        
        addSubview(collectionView)
        collectionView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
        
        addSubview(pageControl)
        pageControl.snp.makeConstraints { make in
            make.centerX.equalToSuperview()
            make.bottom.equalToSuperview().offset(-10)
            make.height.equalTo(20)
        }
        
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()

        if layout.itemSize != bounds.size {
            layout.itemSize = bounds.size
            layout.invalidateLayout() //布局失效,并重新计算布局
        }
    }
    
    private func startTimer() {
        stopTimer() // 防止重复创建

        guard dataSource.count > 1 else { return }

        timer = Timer.scheduledTimer(withTimeInterval: autoScrollInterval, repeats: true) { [weak self] _ in
            guard let self = self else { return }
            self.scrollNext(animated: true)
        }

        RunLoop.main.add(timer!, forMode: .common)
    }

    private func stopTimer() {
        timer?.invalidate()
        timer = nil
    }
    
    private func adjustIfNeeded() {

        guard list.count > 1 else { return }

        let index = currentIndex()

        // 到了最前面的假cell
        if index == 0 {

            collectionView.scrollToItem(
                at: IndexPath(item: list.count, section: 0),
                at: .centeredHorizontally,
                animated: false
            )
        }

        // 到了最后面的假cell
        else if index == dataSource.count - 1 {

            collectionView.scrollToItem(
                at: IndexPath(item: 1, section: 0),
                at: .centeredHorizontally,
                animated: false
            )
        }
    }
    
    // 滚动完,block出去显示当前页数
    private func notifyCurrentPage() {

        guard list.count > 0 else { return }

        let index = currentIndex()
        let realIndex = realIndex(from: index)
        
        // 更新 pageControl
        pageControl.currentPage = realIndex

        // 当前真实数据
        let source = list[realIndex]

        switch source {
        case .remote(let urlString):
            // 返回 (当前页数, url)
            didScrollToIndexBlock?(realIndex + 1, urlString)
        case .local(let imageName):
            // 返回 (当前页数, imageName)
            didScrollToIndexBlock?(realIndex + 1, imageName)
        }
    }

}

extension ImageCarouselView: UIScrollViewDelegate {
    
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        // 用户手动拖拽时暂停
        stopTimer()
    }
    
    // 修复“无限轮播的边界跳转”,避免用户看到断层
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {

        adjustIfNeeded()
        notifyCurrentPage()
        
        // 拖拽结束恢复
        if isAutoScroll {
            startTimer()
        }
    }

    // 修复“无限轮播的边界跳转”,避免用户看到断层
    func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {

        adjustIfNeeded()
        notifyCurrentPage()
    }
    
}

extension ImageCarouselView: UICollectionViewDelegate,UICollectionViewDataSource {
    
    func collectionView(_ collectionView: UICollectionView,
                        numberOfItemsInSection section: Int) -> Int {
        return dataSource.count
    }

    func collectionView(_ collectionView: UICollectionView,
                        cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell:ImageCarouselCell  = collectionView.dequeueReusableCell(withReuseIdentifier: ImageCarouselCell.identifier, for: indexPath) as! ImageCarouselCell
        
        let source = dataSource[indexPath.item]
        
        switch source {
        case .remote(let urlString):
            guard let url = URL(string: urlString) else {
                return cell
            }
            
            cell.imageView.kf.setImage(
                with: url,
                placeholder: UIImage(named: placeholderImageName)
            )
        case .local(let imageName):
            cell.imageView.image = UIImage(named: imageName)
        }
        
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let source = dataSource[indexPath.item]
        
        switch source {
        case .remote(let urlString):
            didTapImageBlock?(urlString)
        case .local(let imageName):
            didTapImageBlock?(imageName)
        }
    }
    
}

// MARK: - Public Control API

extension ImageCarouselView {
    
    /// 下一张
    func scrollNext(animated: Bool = true) {
        guard dataSource.count > 1 else { return }

        let nextIndex = currentIndex() + 1
        safeScroll(to: nextIndex, animated: animated)
    }
    
    /// 上一张
    func scrollPrev(animated: Bool = true) {
        guard dataSource.count > 1 else { return }

        let prevIndex = currentIndex() - 1
        safeScroll(to: prevIndex, animated: animated)
    }
    
    // dataSource的index, 并不是滑动到那一张图片的index
    private func currentIndex() -> Int {
        let width = max(collectionView.bounds.width, 1)
        let offset = collectionView.contentOffset.x + width * 0.5
        return Int(offset / width)
    }
    
    // 因为无限图片轮播器的index 跟 真实滑动到那一张图片不对应,所以需要重新计算真实的index  [4,  1,2,3,4,  1]
    private func realIndex(from index: Int) -> Int {

        if list.count <= 1 {
            return 0
        }

        if index == 0 {
            return list.count - 1
        }

        if index == dataSource.count - 1 {
            return 0
        }

        return index - 1
    }
    
    // 前一张,下一张都用这个统一滚动方法, 有处理防止快速滚动越界问题
    private func safeScroll(to index: Int, animated: Bool) {

        guard dataSource.count > 1 else { return }

        var targetIndex = index

        //  允许“逻辑越界”,但不允许 scroll 越界
        if targetIndex < 0 {
            targetIndex = dataSource.count - 1
        }

        if targetIndex >= dataSource.count {
            targetIndex = 0
        }

        collectionView.scrollToItem(
            at: IndexPath(item: targetIndex, section: 0),
            at: .centeredHorizontally,
            animated: animated
        )
    }

}

class ImageCarouselCell: UICollectionViewCell {
    
    static let identifier = "ImageCarouselCellID"
    
    lazy var imageView: UIImageView = {
        let view = UIImageView()
        view.contentMode = .scaleAspectFill
        view.clipsToBounds = true
        return view
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        contentView.addSubview(imageView)
        
        imageView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
}

问题

封装CollectionView使用AutoLayout约束的常见问题?

AutoLayout约束宽高的问题.mov.zip视频解说

图片轮播器的封装用的是CollectionView,我们在初始化图片轮播器使用懒加载时候,内部对CollectionView进行初始化,图片轮播器构造的时候并不知道宽高是多少,所以CollectionView的效也不知道宽高是多少,

在这里插入图片描述

但图片轮播器构造完后,我们使用auto layout约束时,才知道图片轮播器的大小,
在这里插入图片描述

那个时候CollectionView的构造已经完成了,所以CollectionView当时的宽高和cell的宽高是零的,会出现图片轮播器的大嫂跟CollectionView的大小不一致的问题。那么我们的解决办法是在layoutSubviews的方法中,执行一个重新布局的方式来去解决这个问题的
在这里插入图片描述

更多推荐