
前言
因?yàn)楣卷?xiàng)目中使用了大量的 CollectionView 來顯示列表,平常使用 UICollectionView + UICollectionViewFlowLayout 基本能夠滿足需求,但是針對(duì)一些特殊頁面,我們會(huì)可能需要一些特殊的列表布局,這個(gè)時(shí)候我們就需要 UICollectionView + 自定義 Layout 的形式進(jìn)行實(shí)現(xiàn),下面就以比較簡(jiǎn)單的自定義瀑布流布局為例。
自定義 Layout
UICollectionViewLayout 是一個(gè)抽象基類,要使用必須進(jìn)行子類化,用來生成 CollectionView 的布局信息。注意,該類只負(fù)責(zé) CollectionView 的布局,不負(fù)責(zé)具體視圖的創(chuàng)建。
UICollectionViewLayout有 3 種布局元素:
- Cell(列表視圖)
- SupplementaryView(追加視圖)
- DecorationView(裝飾視圖)【不常用,可忽略】
我們先來看下 UICollectionViewLayout 里面都有哪些常用方法和屬性:
// 返回當(dāng)前 CollectionView 的滾動(dòng)范圍,要重寫
var collectionViewContentSize: CGSize { get }
// 預(yù)布局方法 所有的布局應(yīng)該寫在這里面,要重寫
func prepare()
// 此方法應(yīng)該返回當(dāng)前屏幕正在顯示的視圖的布局屬性集合,要重寫
func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]
// 獲取 Cell 視圖的布局,要重寫【在移動(dòng)/刪除的時(shí)候會(huì)調(diào)用該方法】
func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
// 獲取 SupplementaryView 視圖的布局
func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
// 是否重新布局,默認(rèn)是 false,當(dāng)返回 true 時(shí),prepare 會(huì)被頻繁調(diào)用
func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool
// 這4個(gè)方法用來處理插入、刪除和移動(dòng) Cell 時(shí)的一些動(dòng)畫
func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem])
func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?
func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes?
func finalizeCollectionViewUpdates()
然后我們會(huì)注意到一個(gè)非常重要的類UICollectionViewLayoutAttributes,這個(gè)就是用來存儲(chǔ)視圖的布局信息,很簡(jiǎn)單,主要屬性和方法如下:
@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 // 中心點(diǎn)位置
open var size: CGSize // 大小
open var transform3D: CATransform3D // 形變
open var alpha: CGFloat // 透明度
open var zIndex: Int // 視圖層級(jí),用來實(shí)現(xiàn)視圖分層,默認(rèn)是 0
open var isHidden: Bool // 是否隱藏,少用,一般使用 alpha
open var indexPath: IndexPath // 列表的索引
open var representedElementCategory: UICollectionElementCategory { get } // 是個(gè)枚舉,表示 3 種布局類型
open var representedElementKind: String? { get } //當(dāng)是 cell 時(shí),該值為 nil,該屬性用來區(qū)分 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)
}
下面是瀑布流實(shí)戰(zhàn)代碼:
import UIKit
/// 定義瀑布流代理,用于計(jì)算每個(gè)瀑布流卡片高度、頂部視圖大小、底部視圖大小,配置行間距、列間距、縮進(jìn)屬性
@objc protocol CollectionViewDelegateWaterLayout {
// cell 大小
func collectionView(_ collectionView: UICollectionView, limitSize: CGSize, sizeForItemAt indexPath: IndexPath) -> CGSize
// 組縮進(jìn)
@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
/// 初始化傳入瀑布流的數(shù)量
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)
}
/// 布局后的內(nèi)容大小
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 后,系統(tǒng)在布局前會(huì)調(diào)用
override func prepare() {
super.prepare()
guard let collectionView = collectionView else { return }
// 清空瀑布流長(zhǎng)度和布局?jǐn)?shù)據(jù)
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 }
// 設(shè)置行間距、列間距、組縮進(jìn)
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)
}
}
}
/// 計(jì)算各個(gè) cell 的布局
fileprivate func getCellLayoutAttribute(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let collectionView = collectionView else { return nil }
let layoutAttribute = UICollectionViewLayoutAttributes(forCellWith: indexPath)
// 計(jì)算瀑布流 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 的位置放到該條后面,并更新瀑布流長(zhǎng)度
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
}
/// 計(jì)算的頂部/底部視圖的布局
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 視圖的布局,要重寫【在移動(dòng)/刪除的時(shí)候會(huì)調(diào)用該方法】
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
}
// 此方法應(yīng)該返回當(dāng)前屏幕正在顯示的視圖的布局屬性集合,要重寫
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return layoutAttributes.filter({ rect.intersects($0.frame) })
}
// collectionView 調(diào)用 performBatchUpdates 觸發(fā)動(dòng)畫開始
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:
// 保存將要?jiǎng)h除的列表索引
if let indexPathBeforeUpdate = updateItem.indexPathBeforeUpdate {
willUpdateIndexPaths.append(indexPathBeforeUpdate)
}
default:
break
}
}
self.updateIndexPaths = willUpdateIndexPaths
}
// 動(dòng)畫插入 cell 時(shí)調(diào)用
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
}
// 動(dòng)畫刪除 cell 時(shí)調(diào)用
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
}
// 結(jié)束動(dòng)畫
override func finalizeCollectionViewUpdates() {
super.finalizeCollectionViewUpdates()
self.updateIndexPaths = []
}
}
在控制器 ViewController 內(nèi)進(jìn)行使用,邏輯和一般用法一致:
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)

我寫的這個(gè) Demo 的代碼地址:WaterFlowLayoutDemo