美文网首页瀑布流
iOS 使用UICollectionViewFlowLayout

iOS 使用UICollectionViewFlowLayout

作者: MiniCoder | 来源:发表于2021-01-29 17:24 被阅读0次

    最近在开发中需要实现瀑布流效果,便动手实现了下,将使用方法记录下,有不足之处也希望可以和大家沟通交流.

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

    UICollectionViewFlowLayout描述

    A flow layout is a type of collection view layout. Items in the collection view flow from one row or column (depending on the scrolling direction) to the next, with each row containing as many cells as will fit. Cells can be the same sizes or different sizes.
    A flow layout works with the collection view’s delegate object to determine the size of items, headers, and footers in each section and grid. That delegate object must conform to the UICollectionViewDelegateFlowLayout protocol. Use of the delegate allows you to adjust layout information dynamically. For example, you use a delegate object to specify different sizes for items in the grid. If you don’t provide a delegate, the flow layout uses the default values you set in the properties of this class.
    Flow layouts lay out their content using a fixed distance in one direction and a scrollable distance in the other. For example, in a vertically scrolling grid, the width of the grid content is constrained to the width of the corresponding collection view while the height of the content adjusts dynamically to match the number of sections and items in the grid. The layout scrolls vertically by default, but you can configure the scrolling direction using the scrollDirection property.
    Each section in a flow layout can have its own custom header and footer. To configure the header or footer for a view, configure the size of the header or footer to be non-zero. Implement the appropriate delegate methods or assign appropriate values to the headerReferenceSize and footerReferenceSize properties. If the header or footer size is 0, the corresponding view isn’t added to the collection view.

    实现自定义布局的关键方法

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

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

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

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

    创建自定义类,继承自 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储存多分区的计算信息.

      /**
         当集合视图第一次显示其内容时,以及当由于视图的更改而显式或隐式地使布局失效时,就会发生布局更新。在每次布局更新期间,集合视图首先调用这个方法,让布局对象有机会为即将到来的布局操作做准备。
         这个方法的默认实现不做任何事情。子类可以覆盖它,并使用它来设置数据结构或执行后续执行布局所需的任何初始计算。
         */
    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轴最大值.

    截屏2021-01-29 下午4.59.27.png 截屏2021-01-29 下午4.59.33.png 截屏2021-01-29 下午4.59.43.png

    如果只需要实现这种布局,那么每个条目对应的系统可以就不可以不用重写.
    在返回contentsize的方法中返回具体的大小

     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
        }
    
     /// 返回当前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
            }
        }
    
    @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实现上面的代理方法,用来实现不同的布局配置,这样我们就可以像系统的布局代理一样,方便调用.下面看一下控制器中的实现,实现不同的代理方法,用来配置不同分区的内容显示

    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位置.

    /// 滑动代理事件
    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个条目,方便测试

        @objc func scrollAction(){
            if let layout = collectionView.collectionViewLayout as? FlowLayout{
                if dataCount > 4 {
                    layout.scrolllToIndex(index: IndexPath.init(row: 4, section: 1))
                }
            }
        }
    
    截屏2021-01-29 下午5.12.46.png

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

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

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

     public enum Action : Int {
            case insert = 0
    
            case delete = 1
    
            case reload = 2
    
            case move = 3
    
            case none = 4
        }
    
        // 插入的条目 --- 操作数组 ---
        private lazy var insertingIndexPaths = [IndexPath]()
        // 刷新的条目
        private lazy var reloadIndexPaths    = [IndexPath]()
        // 删除的条目
        private lazy var deletingIndexPaths  = [IndexPath]()
        // --- 操作数组 ---
    
    /// 监听视图内容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() {
    }
    

    下面看一下具体的实现:

       /// 监听视图内容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变化的效果.如果需要实现其他的动画效果,可以根据这个来进行变化.

       /// 没有直接返回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)
        }
    

    如有问题可以留言和大家沟通交流~~

    相关文章

      网友评论

        本文标题:iOS 使用UICollectionViewFlowLayout

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