使用UICollectionViewFlowLayout实现瀑布流

实现瀑布流的使用的关键类是 UICollectionViewFlowLayout,如果我们不继承直接使用的话,系统已经帮我们实现了一些效果,比如横向或者竖向滑动,然后配置一些属性或者遵循 UICollectionViewDelegateFlowLayout,来显示个性化的效果.但是有些布局需要我们去实现,比如瀑布流的效果. UICollectionViewFlowLayout非常强大,我们基本上可以任何我们想要的效果,在这里只说一下瀑布流的实现,其他效果可以根据这个来进行不同的变形和修改.

下面对 UICollectionViewFlowLayout 类的必要方法做简单介绍

go 复制代码
当第一次加载布局或者布局失效的时候,会调用该方法,我们要在这里实现具体的布局计算.
 func prepare() 
swift 复制代码
父类需要根据返回的contentsize大小,控制uicollectionview的显示
 override public var collectionViewContentSize: CGSize
swift 复制代码
计算每个item的布局属性,我们将要调用该方法去计算每个item的布局,在增加,刷新item的时候,该方法也会调用,如果我们需要实现自定义的动画效果,需要在计算中做些调整,下面讲到刷新和增加的时候会具体看一下方法的影响.
public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
swift 复制代码
如果我们需要支持头视图和脚视图,那么需要重写该方法 
public override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
swift 复制代码
装饰视图的布局计算
public override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? 

其实上面三个返回布局的方法原理一样,就是根据在UICollectionViewFlowLayout属性配置或者代理方法中返回的属性系统所做的最原始计算,我们需要根据系统所计算的结果来修改成我们想要的结果,如果不适用系统的结果,直接使用自己计算的也是可以的.

swift 复制代码
这个方法比较关键,我们需要将计算法的UICollectionViewLayoutAttributes数组返回给显示的rect,系统会根据属性数组来计算cell的复用和布局的显示.
override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? 

下面说一下布局方面具体的使用:

swift 复制代码
创建自定义类,继承自 UICollectionViewFlowLayout
@objc public class FlowLayout: UICollectionViewFlowLayout {
    // 分区的内容信息,用来做布局处理
    private lazy var sectionInfos: [Int: UsedCarSectionInfo] = [:]
   
}

//创建私有类,用于布局计算
private class UsedCarSectionInfo{
    typealias LayoutAttribute = UICollectionViewLayoutAttributes
    private var linesLastValue:[Int:CGRect] = [:]
    var headerAttribute:LayoutAttribute?
    var itemAttribute:[LayoutAttribute] = []
    var footerAttribute:LayoutAttribute?
    var decorAttribute:LayoutAttribute?
    
    let colum:Int
    let origin:CGPoint
    let itemWidth:CGFloat
    let minimumInteritemSpacing:CGFloat
    let celledgeInset:UIEdgeInsets
}

我们的计算布局支持多分区,这里用字典sectionInfos储存多分区的计算信息.

ini 复制代码
  /**
     当集合视图第一次显示其内容时,以及当由于视图的更改而显式或隐式地使布局失效时,就会发生布局更新。在每次布局更新期间,集合视图首先调用这个方法,让布局对象有机会为即将到来的布局操作做准备。
     这个方法的默认实现不做任何事情。子类可以覆盖它,并使用它来设置数据结构或执行后续执行布局所需的任何初始计算。
     */
override public func prepare() {
    super.prepare()
   sectionInfos.removeAll()
   let sectionNum = collectionView.numberOfSections
        /// 获取到分区
        for sectionIndex in 0 ..< sectionNum {
            let section = IndexPath(row: 0, section: sectionIndex)
            let cellEdge = delegate.collectionView(collectionView, layout: self, sectionInsetForItems: sectionIndex)
            ///获取section的列间距
            let lineSpace = delegate.collectionView(collectionView, layout: self, minimumLineSpacing: sectionIndex)
            /// 查看布局中存在几列
            let colum = delegate.collectionView(collectionView, layout: self, colum: sectionIndex)
            let sectionInfo = UsedCarSectionInfo(colum: colum, itemWidth: getItemWidth(for: sectionIndex), minimumInteritemSpacing: minimumInteritemSpacing, edgeInset: cellEdge)
            
            sectionInfos[sectionIndex] = sectionInfo
            /// 处理header数据
            if let att = layoutAttributesForSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, at: section)?.copy() as? LayoutAttribute {
                var maxY: CGFloat = 0
                if section.section > 0, let preInfo = sectionInfos[section.section - 1] { maxY = preInfo.maxY() }
                var frame = att.frame
                frame.origin = CGPoint(x: frame.origin.x, y: maxY)
                att.frame = frame
                sectionInfo.headerAttribute = att
            }
            /// 处理cell数据
            let cellNumForSection = collectionView.numberOfItems(inSection: sectionIndex)
            for index in 0 ..< cellNumForSection {
                let indexPath = IndexPath(row: index, section: sectionIndex)
                if let att = layoutAttributesForItem(at: indexPath)?.copy() as? LayoutAttribute {
                    var frame = att.frame
                    let height = delegate.collectionView(collectionView, layout: self, itemWidth: sectionInfo.itemWidth, caculateHeight: indexPath)
                    frame.size = .init(width: sectionInfo.itemWidth, height: height)
                    var newOrigin = CGPoint.zero
                    if indexPath.row == 0 {
                        newOrigin = .init(x: sectionInfo.origin.x, y: maxY() + sectionInfo.celledgeInset.top)
                        frame.origin = newOrigin
                        sectionInfo.initLinesLastValue(frame)
                    } else {
                        ///查找当前section中哪列最短
                        let tuple = sectionInfo.findExtremeValue(false)
                        let caluteMinimumLineSpacing = tuple.1.size.height < 0 ? 0 : lineSpace
                        newOrigin = CGPoint(x: tuple.1.minX, y: tuple.1.maxY + caluteMinimumLineSpacing)
                        frame.origin = newOrigin
                        sectionInfo.updateRect(colum: tuple.0, value: frame)
                    }
                    att.frame = frame
                    sectionInfo.itemAttribute.append(att)
                }
            }
            // 处理footer数据
            if let att = layoutAttributesForSupplementaryView(ofKind: UICollectionView.elementKindSectionFooter, at: section)?.copy() as? LayoutAttribute {
                var maxY: CGFloat = 0
                maxY = sectionInfo.maxY()
                var frame = att.frame
                frame.origin = CGPoint(x: frame.origin.x, y: maxY)
                att.frame = frame
                sectionInfo.footerAttribute = att
            }
            if section.section == 0{
                if let att = layoutAttributesForDecorationView(ofKind: "UCCateDecorationView", at: section)?.copy() as? LayoutAttribute{
                    let offsetX:CGFloat = 400
                    let newOrigin = CGPoint.init(x: collectionView.bounds.origin.x, y: sectionInfo.minY() - offsetX)
                    let newSize = CGSize.init(width: collectionView.bounds.width, height: sectionInfo.maxY() - sectionInfo.minY() + offsetX)
                    att.frame = CGRect.init(origin: newOrigin, size: newSize)
                    sectionInfo.decorAttribute = att
                }
            }
        }
}

计算原理如下,我们需要获取到存在几个分区,然后布局该分区内的每个item的信息

如果我们从上自下依次布局显示的话,那么应该是:

头视图->分区内每个item的信息->脚视图->然后装饰视图

装饰视图可以根据具体需求来计算,不一定在最后.在该效果中,我用绿色的背景来实现装饰视图,由于覆盖当前的分区,所以需要知道footer的计算结果,因此装饰视图的计算放在了最后,用来知道当前分区的Y轴最大值.

如果只需要实现这种布局,那么每个条目对应的系统可以就不可以不用重写.

在返回contentsize的方法中返回具体的大小

swift 复制代码
 override public var collectionViewContentSize: CGSize {
        if let collectionView = self.collectionView {
            let contentSize = CGSize(width: collectionView.bounds.width, height: max(maxY(), collectionView.bounds.height))
            return contentSize
        }
        return .zero
    }
swift 复制代码
 /// 返回当前rect中包含的布局信息
    override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        return sectionInfos.values.flatMap { (info) -> [LayoutAttribute] in
            var arr = [UICollectionViewLayoutAttributes]()
            if let header = info.headerAttribute, header.frame.intersects(rect) {
                arr.append(header)
            }
            arr.append(contentsOf: info.itemAttribute.filter { $0.frame.intersects(rect) })
            if let footer = info.footerAttribute, footer.frame.intersects(rect) {
                arr.append(footer)
            }
            if let att = info.decorAttribute,att.frame.intersects(rect){
                arr.append(att)
            }
            return arr
        }
    }
swift 复制代码
@objc public protocol UICollectionViewDelegateWaterFlowLayout: UICollectionViewDelegateFlowLayout {
    /**
     返回当前section中的列数
     */
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, colum section: Int) -> Int
    /**
     返回当前section中cell的行间距
     */
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacing section: Int) -> CGFloat
    
    /**
     返回当前section中cell的内间距
     */
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sectionInsetForItems section: Int) -> UIEdgeInsets
    /**
     返回当前indexpath的高度,可以根据宽度来计算
     */
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, itemWidth: CGFloat, caculateHeight indexPath: IndexPath) -> CGFloat
}

让我们的collectionview实现上面的代理方法,用来实现不同的布局配置,这样我们就可以像系统的布局代理一样,方便调用.下面看一下控制器中的实现,实现不同的代理方法,用来配置不同分区的内容显示

swift 复制代码
extension MyCollectionViewController:UICollectionViewDelegate,UICollectionViewDataSource,UICollectionViewDelegateWaterFlowLayout{
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacing section: Int) -> CGFloat {
        return 10
    }
    
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, colum section: Int)
    -> Int {
        if section == 0 {
            return 1
        }
        if section == 1 {
            return 2
        }
        return 3
    }
    
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
        return CGSize.init(width: collectionView.bounds.width, height: 200)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
        .init(width: collectionView.bounds.width, height: 100)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sectionInsetForItems section: Int) -> UIEdgeInsets{
        
        return UIEdgeInsets.init(top: 20, left: 10, bottom: 20, right: 10)
    }
    
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        if kind == UICollectionView.elementKindSectionHeader {
            let view =   collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "FlowCollectionReusableView", for: indexPath)
            return view
        }else{
            let view =   collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "CollectionReusableFooterView", for: indexPath)
            return view
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, itemWidth:CGFloat ,caculateHeight indexPath: IndexPath) -> CGFloat{
        
        return CGFloat(indexPath.row * 40 + 170)
    }
    
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 3
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        if section == 1 {
            return dataCount
        }
        return self.otherDataCount
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! FlowlayoutCell
        
        cell.textLab.text = "(indexPath)"
        return cell
    }
}

实现上面的方法,那么瀑布流的效果已经可以实现了.

下面说一下如何滑动到具体的分区,或者item位置.

swift 复制代码
/// 滑动代理事件
extension FlowLayout {
   //滑动到分区的头视图,传入分区信息
    @objc public func scrollToHeader(with section: Int) {
        let indexPath = IndexPath(row: 0, section: section)
        scrollWith(indexPath, isHeader: true, isFooter: false)
    }
    //滑动到分区的脚视图,传入分区信息
    @objc public func scrollToFooter(with section: Int) {
        let indexPath = IndexPath(row: 0, section: section)
        scrollWith(indexPath, isHeader: false, isFooter: true)
    }
   // 滑动到具体的indexpath
    @objc public func scrolllToIndex(index: IndexPath) {
        scrollWith(index, isHeader: false, isFooter: false)
    }
    
    private func scrollWith(_ indexPath: IndexPath, isHeader: Bool, isFooter: Bool) {
        let sectionInfo = sectionInfos[indexPath.section]
        if isHeader, let att = sectionInfo?.headerAttribute {
            collectionView?.setContentOffset(CGPoint(x: 0, y: att.frame.origin.y), animated: true)
            return
        }
        if isHeader, let att = sectionInfo?.footerAttribute {
            collectionView?.setContentOffset(CGPoint(x: 0, y: att.frame.origin.y), animated: true)
            return
        }
        if let att = sectionInfo?.itemAttribute[indexPath.row] {
            collectionView?.setContentOffset(CGPoint(x: 0, y: att.frame.origin.y), animated: true)
        }
    }
}

实现上面的方法,我们可以灵活的滑动到任何元素的位置.

在控制器中调用,这里我们写死的第二个分区的第4个条目,方便测试

less 复制代码
    @objc func scrollAction(){
        if let layout = collectionView.collectionViewLayout as? FlowLayout{
            if dataCount > 4 {
                layout.scrolllToIndex(index: IndexPath.init(row: 4, section: 1))
            }
        }
    }

可以看到这里很精确的滑动到输入的位置.

下面说一下我们优化增加,删除和刷新效果

添加三个数组,用来实现不同的操作,系统有四种不同的操作事件.

ini 复制代码
 public enum Action : Int {
        case insert = 0

        case delete = 1

        case reload = 2

        case move = 3

        case none = 4
    }
swift 复制代码
    // 插入的条目 --- 操作数组 ---
    private lazy var insertingIndexPaths = [IndexPath]()
    // 刷新的条目
    private lazy var reloadIndexPaths    = [IndexPath]()
    // 删除的条目
    private lazy var deletingIndexPaths  = [IndexPath]()
    // --- 操作数组 ---
swift 复制代码
/// 监听视图内容item变化操作,如果item有变化操作会执行该方法
override public func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
}

  /// item将要显示的时候调用,处理相关动画
    override public func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
}

  /// 删除item会执行此代理方法,处理删除相关的动画
    override public func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
}

 /// 视图变化完成调用
    override public func finalizeCollectionViewUpdates() {
}

下面看一下具体的实现:

swift 复制代码
   /// 监听视图内容item变化操作
    override public func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
        super.prepare(forCollectionViewUpdates: updateItems)
        for update in updateItems {
            if let indexPath = update.indexPathAfterUpdate,update.updateAction == .insert {
                insertingIndexPaths.append(indexPath)
            }
            if let indexPath = update.indexPathAfterUpdate, update.updateAction == .reload {
                reloadIndexPaths.append(indexPath)
            }
            if let indexPath = update.indexPathBeforeUpdate, update.updateAction == .delete {
                deletingIndexPaths.append(indexPath)
            }
        }
    /// item将要显示的时候调用,处理相关动画
    override public func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        let attributes = super.initialLayoutAttributesForAppearingItem(at: itemIndexPath)
        if insertingIndexPaths.contains(itemIndexPath), let copyModel = attributes?.copy() as? LayoutAttribute {
            if let sectionInfo = sectionInfos[itemIndexPath.section], sectionInfo.itemAttribute.count > itemIndexPath.row {
                let att = sectionInfo.itemAttribute[itemIndexPath.row]
                copyModel.alpha = 0
                copyModel.frame = att.frame
                copyModel.transform = CGAffineTransform(scaleX: 0.3, y: 0.3)
            }
            return copyModel
        }
        if reloadIndexPaths.contains(itemIndexPath), let copyModel = attributes?.copy() as? LayoutAttribute {
            if let sectionInfo = sectionInfos[itemIndexPath.section], sectionInfo.itemAttribute.count > itemIndexPath.row {
                let att = sectionInfo.itemAttribute[itemIndexPath.row]
                copyModel.alpha = 0
                copyModel.frame = att.frame
            }
            return copyModel
        }
        return attributes
    }
    
    /// 视图变化完成调用
    override public func finalizeCollectionViewUpdates() {
        super.finalizeCollectionViewUpdates()
        insertingIndexPaths.removeAll()
        deletingIndexPaths.removeAll()
        reloadIndexPaths.removeAll()
    }
    
    /// 删除item会执行此代理方法,处理删除相关的动画
    override public func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        let attributes = super.finalLayoutAttributesForDisappearingItem(at: itemIndexPath)
        if deletingIndexPaths.contains(itemIndexPath), let copyModel = attributes?.copy() as? LayoutAttribute {
            copyModel.alpha = 0.0
            copyModel.transform = CGAffineTransform(scaleX: 0.2, y: 0.2)
            return copyModel
        }
        return attributes
    }

这里我们实现了增加,删除和刷新条目的动画

这里要说下面,在增加条目的时候会调用的layoutAttributesForItem,返回的不是我们计算好的attribute,会导致显示动画异常,所以在这我们做额外的操作,如果已经有计算好的布局,那么执行使用,然后在增加的代理方法中实现具体的变化操作.现在我们的增加,删除实现可CGAffineTransform和alpha变化的效果,刷新实现了alpha变化的效果.如果需要实现其他的动画效果,可以根据这个来进行变化.

swift 复制代码
   /// 没有直接返回super调用,是因为在增加,删除,刷新等操作中,会再次执行该方法,布局计算是以当前的item的下一个做变化操作,和要求动画不符
    override public func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        if let sectionInfo = sectionInfos[indexPath.section], sectionInfo.itemAttribute.count > indexPath.row {
            return sectionInfo.itemAttribute[indexPath.row]
        }
        return super.layoutAttributesForItem(at: indexPath)
    }

参考:juejin.cn/post/684490...

相关推荐
用户0910 分钟前
如何避免写垃圾代码:iOS开发篇
ios·swiftui·swift
HarderCoder14 小时前
iOS 知识积累第一弹:从 struct 到 APP 生命周期的全景复盘
ios
叽哥1 天前
Flutter Riverpod上手指南
android·flutter·ios
用户092 天前
SwiftUI Charts 函数绘图完全指南
ios·swiftui·swift
YungFan2 天前
iOS26适配指南之UIColor
ios·swift
权咚3 天前
阿权的开发经验小集
git·ios·xcode
用户093 天前
TipKit与CloudKit同步完全指南
ios·swift
法的空间3 天前
Flutter JsonToDart 支持 JsonSchema
android·flutter·ios
2501_915918413 天前
iOS 上架全流程指南 iOS 应用发布步骤、App Store 上架流程、uni-app 打包上传 ipa 与审核实战经验分享
android·ios·小程序·uni-app·cocoa·iphone·webview
00后程序员张3 天前
iOS App 混淆与加固对比 源码混淆与ipa文件混淆的区别、iOS代码保护与应用安全场景最佳实践
android·安全·ios·小程序·uni-app·iphone·webview