自定义UICollectionViewFlowLayout实现分组颜色或背景设置

在开发中,由于业务不同、产品不同、时间不同、亦或是为了美观等原因,为分组设置颜色、图片等情况屡见不鲜,因此如何实现比较通用的模版提高开发效率就变得尤为重要。

要实现这些效果的方法很多,由于经验限制的原因,这里只给出笔者认为比较好的实现方式:

通过自定义 UICollectionViewFlowLayout 计算 DecorationView 的布局Attributes 返回给父类调用实现

实现思路:

  1. 继承 UICollectionViewFlowLayout,重写 prepare 方法,获取到所有的分组 sections;
  2. 通过遍历分组,获取每个分组的起始位置 Attributes 和 结束位置 Attributes;
  3. 利用起始位置 Attributes frame 和 结束位置 Attributes frame 得出装饰视图 DecorationView 的 frame;
  4. 根据计算的 frame 创建 DecorationView Attributes,存储该 Attributes;
  5. 重写 layoutAttributesForDecorationView 方法,将存储的 DecorationView Attributes 返回给父类调用;
  6. 重写 layoutAttributesForElements 方法,拼接 DecorationView Attributes 后返回即可。

Note: 在获取结束位置的 Attributes 时,需要考虑到是否开启footerView的悬停效果,开启悬停效果后,获取到的 footerView Attributes 得到的 frame 并非真实的frame。

具体实现步骤如下:

一、定义装饰视图需要展示的类型

/*
 装饰视图展示类型
 */
public enum CollectionViewDecorationViewLayoutType {
   /* 装饰视图对items、 headerView、footerView 有效 */
    case `default`
    
    /* 装饰视图只对items有效,不包含 headerView 和 footerView*/
    case onlyItems
    
    /* 装饰视图对items 和 headerView 有效,不包含 footerView */
    case headerAndItems
    
    /* 装饰视图对items 和 footerView 有效,不包含 headerView */
    case footerAndItems
}

二、定义装饰视图 DecorationView 配置协议

/*
 装饰视图的一些基本配置,提供 delegate, block
 */
public protocol DecorationViewConfigurationDelegate : AnyObject {
    
    /// 返回 不同分组的装饰视图具体类 根据 section 选择设置对应的 装饰视图具体类
    /// - Parameters:
    ///   - section: 分组索引
    func decorationViewReusableClass(at section: Int) -> (UICollectionReusableView.Type)?
    
    /// 返回 分组装饰视图Inset default 5
    /// - Parameters:
    ///   - section: 分组索引
    func decorationViewExtendEdgeInsets(at section: Int) -> UIEdgeInsets
    
    /// 返回 装饰视图布局类型 default .default
    /// - Parameters:
    ///   - section: 分组索引
    func decorationViewLayoutType(at section: Int) -> CollectionDecorationViewLayoutType
}

// MARK: 默认设置

extension DecorationViewConfigurationDelegate {
    
    /// 装饰视图Edge default 5
    func decorationViewExtendEdgeInsets(at section: Int) -> UIEdgeInsets {
        return UIEdgeInsets(top: -5, left: 5, bottom: -5, right: 5)
    }
    
    /// 装饰视图布局类型
    func decorationViewLayoutType(at section: Int) -> CollectionDecorationViewLayoutType {
        return .default
    }
}

三、自定义 CollectionDecorationViewFlowLayout 类,具体实现如下:

对于DecorationView的一些基本配置,提供 delegate 方法和 closure 方法

// MARK: 自定义flowLayout

public class CollectionDecorationViewFlowLayout : UICollectionViewFlowLayout {
    /// 装饰视图代理
    weak var delegate: DecorationViewConfigurationDelegate?
    
    /// 返回 不同分组的装饰视图具体类 根据 section 选择设置对应的 装饰视图具体类
    /// - Parameters:
    ///   - section: 分组索引
    var reusableClass: ((_ section: Int) -> (UICollectionReusableView.Type)?)?
    
    /// 返回 分组装饰视图Inset default .zero
    /// - Parameters:
    ///   - section: 分组索引
    var extendInsets: ((_ section: Int) -> UIEdgeInsets)?
    
    /// 返回 装饰视图布局类型 default .default
    /// - Parameters:
    ///   - section: 分组索引
    var layoutType: ((_ section: Int) -> CollectionDecorationViewLayoutType)?
    
    /// header footer 悬停效果开启 default  false
    var isPinToSectionHeaders: Bool {
        didSet { sectionHeadersPinToVisibleBounds = isPinToSectionHeaders }
    }

    var isPinToSectionFooters: Bool {
        didSet { sectionFootersPinToVisibleBounds = isPinToSectionFooters }
    }
    
    /// 装饰视图布局[section:UICollectionViewLayoutAttributes]
    fileprivate var decorationLayoutAttrs: [Int: UICollectionViewLayoutAttributes] = [:]

    // MARK: 初始化

    override init() {
        self.isPinToSectionHeaders = false
        self.isPinToSectionFooters = false
        super.init()
    }
    
    
    required init?(coder: NSCoder) {
        fatalError()
    }
}

重写父类方法:

// MARK: Override Methods

public extension CollectionDecorationViewFlowLayout {
    
    override func prepare() {
        super.prepare()
        
        /// 布局准备
        layoutDecorationView()
    }
    
    /// 返回装饰视图布局属性
    override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        let section = indexPath.section
        if elementKind == decorationViewOfKind(at: section) {
            return decorationLayoutAttrs[section]
        }
        return super.layoutAttributesForDecorationView(ofKind: elementKind, at: indexPath)
    }

    /// 返回rect范围所有元素的布局属性
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var attrs = super.layoutAttributesForElements(in: rect)
        attrs?.append(contentsOf: decorationLayoutAttrs.values.filter {
            rect.intersects($0.frame)
        })
        return attrs
    }
}

计算主要方法:

// MARK: Main Methods
private extension CollectionDecorationViewFlowLayout {
    
    func layoutDecorationView() {
        
        /// 清除原有配置
        if !decorationLayoutAttrs.isEmpty {
            decorationLayoutAttrs.removeAll()
        }
        
        /// 布局控件是否可见
        guard let collectSize = collectionView?.bounds.size, collectSize.isValidated else { return }
        
        /// 是否有分组数据
        guard let sections = collectionView?.numberOfSections, sections > 0 else { return }
        
        /// 分组配置
        for section in 0 ..< sections {
            
            /// 分组未设置装饰视图 继续下一组
            guard let cls = decorationViewClass(at: section) else { continue }
            
            /// 注册装饰视图
            register(cls, forDecorationViewOfKind: cls.identifier)
            
            /// 配置装饰视图
            configDecorationView(at: section, kind: cls.identifier, layoutWidth: collectSize.width)
        }
    }
    
    func configDecorationView(at section: Int, kind: String, layoutWidth: CGFloat) {
        
        /// 根据装饰视图布局类型获取 布局视图起始点attrs
        let firstAttr = firstLayoutAttributes(at: section)
        /// 根据装饰视图布局类型获取 布局视图终点attr
        let items = collectionView?.numberOfItems(inSection: section) ?? -1
        let lastAttr = lastLayoutAttributes(at: .lastIdx(at: section, items: items))
        
        /// 分组中无数据 继续下一组
        guard let first = firstAttr, let last = lastAttr else { return }
        
        /// 分组边距
        let contentInsets = collectionView!.contentInset
        let secInsets = sectionInsets(at: section)
        
        /// 装饰视图边距
        let decInsets = decorationViewInsets(at: section)
        
        /// 该分组对应的类型,是否有header和footer
        /// 根据布局类型决定是否需要以 header footer 作为布局的起始点与终点
        /// 需要注意的是:当设置了 footerView 且 sectionFootersPinToVisibleBounds 为true时,footer具有悬停效果,此时footer的最终位置并不准确
        /// decorationInsets: left,right 决定 decorationView 的 origin.x 以及 width
        ///                   top,bottom 决定 decorationView 的 origin.y 以及 height
        
        /// 改分组是否存在 header footer
        let hasHeader = hasHaderAttr(at: section)
        let hasFooter = hasFooterAttr(at: section)
        
        /// 根据 header footer 设置垂直方向的增量
        let topAdd = hasHeader ? 0 : (-secInsets.top)
        var bottomAdd = hasFooter ? 0 : secInsets.bottom
        
        /// 对于悬停footer 最终位置偏移需更改
        if isPinToSectionFooters {
            bottomAdd += sectionFooterViewSize(in: section).height
        }
        
        /// 承接装饰视图布局frame计算的临时变量
        var frame: CGRect = .zero
        
        /// 起始位置
        frame.origin.y = first.frame.minY + topAdd
        /// 结束位置
        frame.size.height = last.frame.maxY - frame.minY + bottomAdd
        
        /// 更新x,y
        frame.origin.x = decInsets.left - contentInsets.left
        frame.origin.y -= decInsets.top
        
        /// 更新height, width
        frame.size.height += (decInsets.top + decInsets.bottom)
        frame.size.width = layoutWidth - decInsets.left - decInsets.right
        
        /// 获取分组装饰视图 完成单组配置
        let decAttr = UICollectionViewLayoutAttributes(forDecorationViewOfKind: kind, with: .firstIdx(at: section))
        decAttr.frame = frame
        decAttr.zIndex = -1
        
        /// 添加到配置组里
        decorationLayoutAttrs[section] = decAttr
    }
}

辅助计算的一些私有方法:

// MARK: Private Methods

private extension CollectionDecorationViewFlowLayout {
    
    /// 返回 当前分组装饰视图布局类型
    /// - Parameters:
    ///   - section: 当前分组
    func decorationViewLayoutType(at section: Int) -> CollectionDecorationViewLayoutType {
        guard let type = delegate?.decorationViewLayoutType(at: section) else { return layoutType?(section) ?? .default }
        return type
    }
    
    /// 返回 当前分组是否存在 header attr
    /// - Parameters:
    ///   - section: 当前分组
    func hasHaderAttr(at section: Int) -> Bool {
        return headerViewLayoutAttributes(at: section)?.visible ?? false
    }
    
    /// 返回 当前分组 headerView attr
    /// - Parameters:
    ///   - section: 当前分组
    func headerViewLayoutAttributes(at section: Int) -> UICollectionViewLayoutAttributes? {
        return layoutAttributesForSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, at: .firstIdx(at: section))
    }
    
    /// 返回 当前分组是否存在 footer attr
    /// - Parameters:
    ///   - section: 当前分组
    func hasFooterAttr(at section: Int) -> Bool {
        return footerViewLayoutAttributes(at: section)?.visible ?? false
    }
    
    /// 返回 当前分组footerView attr
    /// - Parameters:
    ///   - section: 当前分组
    func footerViewLayoutAttributes(at section: Int) -> UICollectionViewLayoutAttributes? {
        return layoutAttributesForSupplementaryView(ofKind: UICollectionView.elementKindSectionFooter, at: .firstIdx(at: section))
    }
    
    /// 返回 根据当前分组装饰视图布局类型获取起始位置的attr
    /// - Parameters:
    ///   - section: 当前分组
    func firstLayoutAttributes(at section: Int) -> UICollectionViewLayoutAttributes? {
        
        switch decorationViewLayoutType(at: section) {
        // contain header
        case .default, .headerAndItems:
            guard let header = headerViewLayoutAttributes(at: section), header.visible else {
                return layoutAttributesForItem(at: .firstIdx(at: section))
            }
            return header
        default:
            return layoutAttributesForItem(at: .firstIdx(at: section))
        }
    }
    
    /// 返回 根据当前分组装饰视图布局类型获取最终位置的attr
    /// - Parameters:
    ///   - section: 当前分组
    func lastLayoutAttributes(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        
        /// 开启footer悬停效果
        if isPinToSectionFooters {
            return layoutAttributesForItem(at: indexPath)
        }
        
        switch decorationViewLayoutType(at: indexPath.section) {
        // contain footer
        case .default, .footerAndItems:
            guard let footer = footerViewLayoutAttributes(at: indexPath.section), footer.visible else {
                return layoutAttributesForItem(at: indexPath)
            }
            return footer
        default:
            return layoutAttributesForItem(at: indexPath)
        }
    }
    
    /// 返回 当前分组装饰视图类
    /// - Parameters:
    ///   - section: 当前分组
    func decorationViewClass(at section: Int) -> (UICollectionReusableView.Type)? {
        guard let cls = delegate?.decorationViewReusableClass(at: section) else {
            return reusableClass?(section)
        }
        return cls
    }
    
    /// 返回 当前分组装饰视图 注册类
    /// - Parameters:
    ///   - section: 当前分组
    private func decorationViewOfKind(at section: Int) -> String? {
        return decorationViewClass(at: section)?.identifier
    }
    
    /// 返回 当前分组装饰视图 insets
    /// left: 决定origin.x, left+right 决定 decorationView 宽度width
    /// top: 决定origin.y, top+bottom 决定 decorationView 高度height
    /// - Parameters:
    ///   - section: 当前分组
    func decorationViewInsets(at section: Int) -> UIEdgeInsets {
        guard let insets = delegate?.decorationViewExtendEdgeInsets(at: section) else {
            return extendInsets?(section) ?? .zero
        }
        return insets
    }
    
    /// 返回 当前分组 insets
    /// - Parameters:
    ///   - section: 当前分组
    func sectionInsets(in section: Int) -> UIEdgeInsets {
        var sectionInsets: UIEdgeInsets = sectionInset
        if let delegate = collectionView?.delegate,
           delegate.responds(to: #selector(UICollectionViewDelegateFlowLayout.collectionView(_:layout:insetForSectionAt:)))
        {
            sectionInsets = (delegate as? UICollectionViewDelegateFlowLayout)?.collectionView?(collectionView!, layout: self, insetForSectionAt: section) ?? .zero
        }
        return sectionInsets
    }
    
    /// 返回 footerView size
    /// - Parameters:
    ///   - section: 当前分组
    func sectionFooterViewSize(in section: Int) -> CGSize {
        var size: CGSize = footerReferenceSize
        
        if let delegate = collectionView?.delegate,
           delegate.responds(to: #selector(UICollectionViewDelegateFlowLayout.collectionView(_:layout:referenceSizeForFooterInSection:)))
        {
            size = (delegate as? UICollectionViewDelegateFlowLayout)?.collectionView?(collectionView!, layout: self, referenceSizeForFooterInSection: section) ?? .zero
        }
        return size
    }
}

private extension IndexPath {
    static func lastIdx(at section: Int, items: Int) -> IndexPath {
        IndexPath(item: items - 1, section: section)
    }

    static func firstIdx(at section: Int) -> IndexPath {
        IndexPath(item: 0, section: section)
    }
}


private extension UICollectionViewLayoutAttributes {
    var visible: Bool {
        return self.size.isValidated
    }
}

private extension NSObject {
    static var identifier: String {
        return String(describing: "ObjcIdentifier.\(Self.self)")
    }
}

private extension CGSize {
    /// 判断一个 CGSize 是否存在 NaN
    var isNaN: Bool {
        return self.width.isNaN || self.height.isNaN
    }
    /// 判断一个 CGSize 是否存在 infinite
    var isInf: Bool {
        return self.width.isInfinite || self.height.isInfinite
    }

    /// 判断一个 CGSize 是否为空(宽或高为0)
    var isEmpty: Bool {
        return self.width <= 0 || self.height <= 0
    }
    
    /// 判断一个 CGSize 是否合法(例如不带无穷大的值、不带非法数字)
    var isValidated: Bool {
        return !self.isEmpty && !self.isInf && !self.isNaN
    }
}

到这里,自定义CollectionDecorationViewFlowLayout实现不同分组使用不同装饰视图的实现已经完成,其余一些细节,代码里以详细说明,在此不再赘述。

四、使用案例:使用颜色和图片作为分组作为装饰视图

  1. 创建DecorationResuableView, 此类必须继承于UICollectionReusableView, 如果只使用颜色作为装饰视图,只需设置背景颜色即可,如下:
/*
 颜色背景
 */
class SectionDecorationColorReuseableView : UICollectionReusableView {
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        self.backgroundColor = .cyan
        self.layer.cornerRadius = 10
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
}
  1. 创建 colletionView 并使用 CollectionDecorationViewFlowLayout 作为 布局类:
let layout = CollectionDecorationViewFlowLayout()
lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
    
  1. 设置 layout 代理 或 closure:
layout.reusableClass = {section in
           
           switch section {
           case 0: return SectionDecorationColorReuseableView.self
           case 1 : return nil
           
           default:
               return SectionDecorationColorReuseableView.self
           }
       }
       layout.extendInsets = {section in
           switch section {
           case 0: return UIEdgeInsets(top: 15, left: 15, bottom: 15, right: 15)
           case 1 : return .zero
           
           default:
               return UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
           }
           
       }
       layout.layoutType = {section in
           switch section {
           case 0: return .headerAndItems
           case 1 : return .default
           
           default:
               return .footerAndItems
           }
       }
  1. 实现colletionView的delegate和dataSource方法即可,详细的代码就不贴出来了。

最终可实现效果如下(颜色及图片背景):


Simulator Screen Shot - iPhone 14 Pro - 2022-12-29 at 16.28.46.png
Simulator Screen Shot - iPhone 14 Pro - 2022-12-29 at 16.30.15.png

更多复杂效果,可通过自定义DecorationViewReusable类实现!

经验有限,存在的不足在所难免,还请大佬们不吝赐教,感激不尽!!!

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

推荐阅读更多精彩内容