Swift 项目总结 03 - 自定义 CollectionView 布局

前言

因为公司项目中使用了大量的 CollectionView 来显示列表,平常使用 UICollectionView + UICollectionViewFlowLayout 基本能够满足需求,但是针对一些特殊页面,我们会可能需要一些特殊的列表布局,这个时候我们就需要 UICollectionView + 自定义 Layout 的形式进行实现,下面就以比较简单的自定义瀑布流布局为例。

自定义 Layout

UICollectionViewLayout 是一个抽象基类,要使用必须进行子类化,用来生成 CollectionView 的布局信息。注意,该类只负责 CollectionView 的布局,不负责具体视图的创建。

UICollectionViewLayout 有 3 种布局元素:

  • Cell(列表视图)
  • SupplementaryView(追加视图)
  • DecorationView(装饰视图)【不常用,可忽略】

我们先来看下 UICollectionViewLayout 里面都有哪些常用方法和属性:

// 返回当前 CollectionView 的滚动范围,要重写
 var collectionViewContentSize: CGSize { get }

// 预布局方法 所有的布局应该写在这里面,要重写
func prepare()
 
// 此方法应该返回当前屏幕正在显示的视图的布局属性集合,要重写
func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]
 
// 获取 Cell 视图的布局,要重写【在移动/删除的时候会调用该方法】
func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?

// 获取 SupplementaryView 视图的布局
func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
 
// 是否重新布局,默认是 false,当返回 true 时,prepare 会被频繁调用
func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool
 
// 这4个方法用来处理插入、删除和移动 Cell 时的一些动画
func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem])
func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?
func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?
func finalizeCollectionViewUpdates()

然后我们会注意到一个非常重要的类UICollectionViewLayoutAttributes,这个就是用来存储视图的布局信息,很简单,主要属性和方法如下:

@available(iOS 6.0, *)
class UICollectionViewLayoutAttributes : NSObject, NSCopying, UIDynamicItem {

    @available(iOS 7.0, *)
    open var bounds: CGRect // 大小

    @available(iOS 7.0, *)
    open var transform: CGAffineTransform // 形变

    open var frame: CGRect // 位置和大小
    open var center: CGPoint // 中心点位置
    open var size: CGSize  // 大小
    open var transform3D: CATransform3D // 形变
    open var alpha: CGFloat // 透明度
    open var zIndex: Int  // 视图层级,用来实现视图分层,默认是 0
    open var isHidden: Bool // 是否隐藏,少用,一般使用 alpha
    open var indexPath: IndexPath // 列表的索引
    open var representedElementCategory: UICollectionElementCategory { get } // 是个枚举,表示 3 种布局类型 
    open var representedElementKind: String? { get }  //当是 cell 时,该值为 nil,该属性用来区分 header 和 footer

    // 初始化方法
    public convenience init(forCellWith indexPath: IndexPath)
    public convenience init(forSupplementaryViewOfKind elementKind: String, with indexPath: IndexPath)
    public convenience init(forDecorationViewOfKind decorationViewKind: String, with indexPath: IndexPath)
}

下面是瀑布流实战代码:

import UIKit

/// 定义瀑布流代理,用于计算每个瀑布流卡片高度、顶部视图大小、底部视图大小,配置行间距、列间距、缩进属性
@objc protocol CollectionViewDelegateWaterLayout {
    // cell 大小
    func collectionView(_ collectionView: UICollectionView, limitSize: CGSize, sizeForItemAt indexPath: IndexPath) -> CGSize
    // 组缩进
    @objc optional func collectionView(_ collectionView: UICollectionView, insetForSectionAt section: Int) -> UIEdgeInsets
    // 行间距
    @objc optional func collectionView(_ collectionView: UICollectionView, rowSpacingForSectionAt section: Int) -> CGFloat
    // 列间距
    @objc optional func collectionView(_ collectionView: UICollectionView, columnSpacingForSectionAt section: Int) -> CGFloat
    // 顶部视图大小
    @objc optional func collectionView(_ collectionView: UICollectionView, referenceSizeForHeaderInSection section: Int) -> CGSize
    // 底部视图大小
    @objc optional func collectionView(_ collectionView: UICollectionView, referenceSizeForFooterInSection section: Int) -> CGSize
}

/// 自定义瀑布流 CollectionView 布局,支持水平和垂直方向
class CollectionViewWaterFlowLayout: UICollectionViewLayout {

    fileprivate var layoutAttributes = [UICollectionViewLayoutAttributes]()
    fileprivate var waterLengths: [Int: CGFloat] = [:]
    fileprivate var waterCount: Int = 1
    fileprivate var updateIndexPaths: [IndexPath] = []
    weak var delegate: CollectionViewDelegateWaterLayout?
    var rowSpacing: CGFloat = 0
    var columnSpacing: CGFloat = 0
    var scrollDirection: UICollectionViewScrollDirection = .vertical
    var sectionInset: UIEdgeInsets = .zero
    
    /// 初始化传入瀑布流的数量
    init(waterCount: Int = 1) {
        super.init()
        self.waterCount = max(1, waterCount)
        for index in 0..<waterCount {
            waterLengths[index] = 0.0
        }
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    
    /// 布局后的内容大小
    override var collectionViewContentSize: CGSize {
        var totalSize: CGSize = .zero
        if scrollDirection == .vertical {
            totalSize.height = layoutAttributes.map({ $0.frame.origin.y + $0.frame.size.height }).sorted(by: { $0 > $1 }).first ?? 0.0
            if let collectionView = collectionView {
                totalSize.width = collectionView.frame.size.width
            }
        } else {
            totalSize.width = layoutAttributes.map({ $0.frame.origin.x + $0.frame.size.width }).sorted(by: { $0 > $1 }).first ?? 0.0
            if let collectionView = collectionView {
                totalSize.height = collectionView.frame.size.height
            }
        }
        return totalSize
    }
    
    /// reloadData 后,系统在布局前会调用
    override func prepare() {
        super.prepare()
        guard let collectionView = collectionView else { return }
        
        // 清空瀑布流长度和布局数据
        layoutAttributes.removeAll()
        for index in 0..<waterLengths.count {
            waterLengths[index] = 0.0
        }
        
        for sectionIndex in 0..<collectionView.numberOfSections {
            let cellCount = collectionView.numberOfItems(inSection: sectionIndex)
            if cellCount <= 0 { continue }
            // 设置行间距、列间距、组缩进
            rowSpacing = self.delegate?.collectionView?(collectionView, rowSpacingForSectionAt: sectionIndex) ?? 0.0
            columnSpacing = self.delegate?.collectionView?(collectionView, columnSpacingForSectionAt: sectionIndex) ?? 0.0
            sectionInset = self.delegate?.collectionView?(collectionView, insetForSectionAt: sectionIndex) ?? .zero
            // 获取该组的顶部视图布局
            let sectionIndexPath: IndexPath = IndexPath(row: 0, section: sectionIndex)
            if let headerLayoutAttribute = getSupplementaryViewLayoutAttribute(ofKind: UICollectionElementKindSectionHeader, at: sectionIndexPath) {
                layoutAttributes.append(headerLayoutAttribute)
            }
            // 获取该组的所有 cell 布局
            for cellIndex in 0..<cellCount {
                let cellIndexPath: IndexPath = IndexPath(row: cellIndex, section: sectionIndex)
                if let cellLayoutAttribute = getCellLayoutAttribute(at: cellIndexPath) {
                    layoutAttributes.append(cellLayoutAttribute)
                }
            }
            // 获取该组的底部视图布局
            if let footerLayoutAttribute = getSupplementaryViewLayoutAttribute(ofKind: UICollectionElementKindSectionFooter, at: sectionIndexPath) {
                layoutAttributes.append(footerLayoutAttribute)
            }
        }
 
    }
    
    /// 计算各个 cell 的布局
    fileprivate func getCellLayoutAttribute(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        guard let collectionView = collectionView else { return nil }
        let layoutAttribute = UICollectionViewLayoutAttributes(forCellWith: indexPath)
        
        // 计算瀑布流 cell 限制大小
        var limitSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
        if scrollDirection == .vertical {
            let interitemSpacingWidth: CGFloat = CGFloat(waterCount - 1) * columnSpacing
            let columnWidth: CGFloat = (collectionView.frame.size.width - sectionInset.left - sectionInset.right - interitemSpacingWidth) / CGFloat(waterCount)
            limitSize.width = columnWidth
        } else {
            let interitemSpacingHeight: CGFloat = CGFloat(waterCount - 1) * rowSpacing
            let columnHeight: CGFloat = (collectionView.frame.size.height - sectionInset.top - sectionInset.bottom - interitemSpacingHeight) / CGFloat(waterCount)
            limitSize.height = columnHeight
        }
        
        // 通过代理获取瀑布流 cell 大小
        if let layout = self.delegate {
            layoutAttribute.frame.size = layout.collectionView(collectionView, limitSize: limitSize, sizeForItemAt: indexPath)
        }
        
        // 找到最短的那一条,把该 cell 的位置放到该条后面,并更新瀑布流长度
        let minWater = waterLengths.sorted(by: { (first, second) in
            if first.value < second.value {
                return true
            } else if first.value == second.value {
                return first.key < second.key
            }
            return false
        }).first
        
        if let minWater = minWater {
            if scrollDirection == .vertical {
                layoutAttribute.frame.origin.x = sectionInset.left + CGFloat(minWater.key) * (limitSize.width + columnSpacing)
                layoutAttribute.frame.origin.y = minWater.value + rowSpacing
                waterLengths[minWater.key] = layoutAttribute.frame.origin.y + layoutAttribute.frame.size.height
            } else {
                layoutAttribute.frame.origin.y = sectionInset.top + CGFloat(minWater.key) * (limitSize.height + rowSpacing)
                layoutAttribute.frame.origin.x = minWater.value + columnSpacing
                waterLengths[minWater.key] = layoutAttribute.frame.origin.x + layoutAttribute.frame.size.width
            }
        }
        return layoutAttribute
    }
    
    /// 计算的顶部/底部视图的布局
    fileprivate func getSupplementaryViewLayoutAttribute(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        guard let collectionView = collectionView else { return nil }
        let layoutAttribute = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: elementKind, with: indexPath)
        var supplementarySize: CGSize = .zero
        if let delegate = self.delegate {
            if elementKind == UICollectionElementKindSectionHeader {
                supplementarySize = delegate.collectionView?(collectionView, referenceSizeForHeaderInSection: indexPath.section) ?? .zero
            } else if elementKind == UICollectionElementKindSectionFooter {
                supplementarySize = delegate.collectionView?(collectionView, referenceSizeForFooterInSection: indexPath.section) ?? .zero
            }
        }
        layoutAttribute.frame.size = supplementarySize
        
        if scrollDirection == .vertical {
            layoutAttribute.frame.origin.x = self.sectionInset.left
            let lastLayoutBottom: CGFloat = layoutAttributes.map({ $0.frame.origin.y + $0.frame.size.height }).sorted(by: { $0 > $1 }).first ?? 0.0
            if elementKind == UICollectionElementKindSectionHeader {
                layoutAttribute.frame.origin.y = lastLayoutBottom + self.sectionInset.top
            } else if elementKind == UICollectionElementKindSectionFooter {
                layoutAttribute.frame.origin.y = lastLayoutBottom + self.sectionInset.bottom
            }
        } else {
            layoutAttribute.frame.origin.y = self.sectionInset.top
            let lastLayoutRight: CGFloat = layoutAttributes.map({ $0.frame.origin.x + $0.frame.size.width }).sorted(by: { $0 > $1 }).first ?? 0.0
            if elementKind == UICollectionElementKindSectionHeader {
                layoutAttribute.frame.origin.x = lastLayoutRight + self.sectionInset.left
            } else if elementKind == UICollectionElementKindSectionFooter {
                layoutAttribute.frame.origin.x = lastLayoutRight + self.sectionInset.right
            }
        }
        
        return layoutAttribute
    }
    
    // 获取 Cell 视图的布局,要重写【在移动/删除的时候会调用该方法】
    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return layoutAttributes.filter({ $0.indexPath == indexPath && $0.representedElementCategory == .cell }).first
    }
    
    // 获取 SupplementaryView 视图的布局
    override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return layoutAttributes.filter({ $0.indexPath == indexPath && $0.representedElementKind == elementKind }).first
    }
    
    // 此方法应该返回当前屏幕正在显示的视图的布局属性集合,要重写
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        return layoutAttributes.filter({ rect.intersects($0.frame) })
    }
    
    // collectionView 调用 performBatchUpdates 触发动画开始
    override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
        super.prepare(forCollectionViewUpdates: updateItems)
        var willUpdateIndexPaths: [IndexPath] = []
        for updateItem in updateItems {
            switch updateItem.updateAction {
            case .insert:
                // 保存将要插入的列表索引
                if let indexPathAfterUpdate = updateItem.indexPathAfterUpdate {
                    willUpdateIndexPaths.append(indexPathAfterUpdate)
                }
            case .delete:
                // 保存将要删除的列表索引
                if let indexPathBeforeUpdate = updateItem.indexPathBeforeUpdate {
                    willUpdateIndexPaths.append(indexPathBeforeUpdate)
                }
            default:
                break
            }
        }
        self.updateIndexPaths = willUpdateIndexPaths
    }
    
    // 动画插入 cell 时调用
    override func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        if self.updateIndexPaths.contains(itemIndexPath) {
            if let attr = layoutAttributes.filter({ $0.indexPath == itemIndexPath }).first {
                attr.alpha = 0.0
                self.updateIndexPaths = self.updateIndexPaths.filter({ $0 != itemIndexPath })
                return attr
            }
        }
        return nil
    }
    
    // 动画删除 cell 时调用
    override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        if self.updateIndexPaths.contains(itemIndexPath) {
            if let attr = layoutAttributes.filter({ $0.indexPath == itemIndexPath }).first {
                attr.alpha = 0.0
                self.updateIndexPaths = self.updateIndexPaths.filter({ $0 != itemIndexPath })
                return attr
            }
        }
        return nil
    }
    
    // 结束动画
    override func finalizeCollectionViewUpdates() {
        super.finalizeCollectionViewUpdates()
        self.updateIndexPaths = []
    }
}

在控制器 ViewController 内进行使用,逻辑和一般用法一致:

let waterLayout = CollectionViewWaterFlowLayout(waterCount: 2)
waterLayout.scrollDirection = .vertical
waterLayout.delegate = self
collectionView = UICollectionView(frame: self.view.bounds, collectionViewLayout: waterLayout)
collectionView.dataSource = self
collectionView.delegate = self
collectionView.alwaysBounceHorizontal = false
collectionView.alwaysBounceVertical = true
collectionView.showsVerticalScrollIndicator = false
collectionView.showsHorizontalScrollIndicator = false
collectionView.contentInset = UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0)
collectionView.backgroundColor = UIColor.clear
if #available(iOS 11.0, *) {
    collectionView.contentInsetAdjustmentBehavior = .never
} else {
    self.automaticallyAdjustsScrollViewInsets = false
}
self.view.addSubview(collectionView)

我写的这个 Demo 的代码地址:WaterFlowLayoutDemo

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

推荐阅读更多精彩内容