UICollectionView-DecorationView 开发总结

前言

在项目新版本中,要实现类似以下的效果:给每个section区域添加一个卡片装饰背景以及一个袖标装饰图标(卡片在所有的cell下,袖标在cell上面)。

image.png
image.png

这可以通过UICollectionViewDecorationView 特性来达到以上效果。本文主要是总结 DecorationView 的实现、重用机制和存在的坑。

DecorationView 的实现(包括坑)

实现原理

  1. 继承 UICollectionViewLayoutAttributes,实现用于描述装饰视图的布局属性的类,如描述卡片装饰视图的SectionCardDecorationCollectionViewLayoutAttributes
  2. 继承 UICollectionReusableView,实现自己的装饰视图,如卡片装饰视图 SectionCardDecorationReusableView
  3. 继承 UICollectionViewFlowLayout,实现自己的布局计算:主要是注册自定义的装饰视图和计算管理这些装饰视图的布局属性。如 SectionCardDecorationCollectionViewLayout
  4. 继承 UICollectionView,override layoutSubviews 方法,解决装饰视图的一个坑(关于此坑,请看文章具体描述)

核心代码

1. 自定义装饰图的布局属性

/// section卡片装饰图的布局属性
class SectionCardDecorationCollectionViewLayoutAttributes: UICollectionViewLayoutAttributes {

    //背景色
    var backgroundColor = UIColor.white

    //所定义属性的类型需要遵从 NSCopying 协议
    override func copy(with zone: NSZone? = nil) -> Any {
        let copy = super.copy(with: zone) as! SectionCardDecorationCollectionViewLayoutAttributes
        copy.backgroundColor = self.backgroundColor
        return copy
    }

    //所定义属性的类型还要实现相等判断方法(isEqual)
    override func isEqual(_ object: Any?) -> Bool {
        guard let rhs = object as? SectionCardDecorationCollectionViewLayoutAttributes else {
            return false
        }

        if !self.backgroundColor.isEqual(rhs.backgroundColor) {
            return false
        }
        return super.isEqual(object)
    }
}

2. 自定义装饰图

/// Section卡片装饰视图
class SectionCardDecorationReusableView: UICollectionReusableView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.customInit()
    }

    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)!
        self.customInit()
    }

    func customInit() {
        self.backgroundColor = UIColor.white

        self.layer.cornerRadius = 6.0
        self.layer.borderColor = UIColor.clear.cgColor
        self.layer.borderWidth = 1.0
        // SketchShadow: color-(0,0,0,0.17),x-0,y-1,blur-2,spread-0
        self.layer.shadowColor = UIColor.black.cgColor
        self.layer.shadowOpacity = 0.17
        self.layer.shadowOffset = CGSize.init(width: 0, height: 1.0)
        self.layer.shadowRadius = 1
    }

    //通过apply方法让自定义属性生效
    override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
        super.apply(layoutAttributes)

        guard let attr = layoutAttributes as? SectionCardDecorationCollectionViewLayoutAttributes else {
            return
        }

        self.backgroundColor = attr.backgroundColor
    }
}

let SectionCardDecorationViewKind = "SectionCardDecorationReuseIdentifier"

3. 自定义 UICollectionViewFlowLayout

自定义 UICollectionViewFlowLayout,主要是实现自己的布局计算。主要的计算操作有:

  • 初始化时进行装饰视图的注册操作(对应 setup 方法)
  • override prepare 方法,计算生成装饰视图的布局属性
  • override layoutAttributesForElements 方法,返回可视范围下装饰视图的布局属性
/// 卡片式背景CollectionViewLayout
class SectionCardDecorationCollectionViewLayout: UICollectionViewFlowLayout {

    //保存所有自定义的section背景的布局属性
    private var cardDecorationViewAttrs: [Int:UICollectionViewLayoutAttributes] = [:]
    private var armbandDecorationViewAttrs: [Int:UICollectionViewLayoutAttributes] = [:]

    public weak var decorationDelegate: SectionCardDecorationCollectionViewLayoutDelegate?

    override init() {
        super.init()
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override func awakeFromNib() {
        super.awakeFromNib()

        setup()
    }

    //初始化时进行一些注册操作
    func setup() {
        //注册DecorationView
        self.register(SectionCardDecorationReusableView.self,
                      forDecorationViewOfKind: SectionCardDecorationViewKind)

        self.register(SectionCardArmbandDecorationReusableView.self,
                      forDecorationViewOfKind: SectionCardArmbandDecorationViewKind)
    }

    override func prepare() {
        super.prepare()

        // 如果collectionView当前没有分区,则直接退出
        guard let numberOfSections = self.collectionView?.numberOfSections
            else {
                return
        }

        let flowLayoutDelegate: UICollectionViewDelegateFlowLayout? = self.collectionView?.delegate as? UICollectionViewDelegateFlowLayout

        // 不存在cardDecorationDelegate就退出
        guard let strongCardDecorationDelegate = decorationDelegate else {
            return
        }

        // 删除旧的装饰视图的布局数据
        self.cardDecorationViewAttrs.removeAll()
        self.armbandDecorationViewAttrs.removeAll()

        //分别计算每个section的装饰视图的布局属性
        for section in 0..<numberOfSections {
            //获取该section下第一个,以及最后一个item的布局属性
            guard let numberOfItems = self.collectionView?.numberOfItems(inSection: section),
                numberOfItems > 0,
                let firstItem = self.layoutAttributesForItem(at:
                    IndexPath(item: 0, section: section)),
                let lastItem = self.layoutAttributesForItem(at:
                    IndexPath(item: numberOfItems - 1, section: section))
                else {
                    continue
            }

            //获取该section的内边距
            var sectionInset = self.sectionInset
            if let inset = flowLayoutDelegate?.collectionView?(self.collectionView!,
                                                              layout: self, insetForSectionAt: section) {
                sectionInset = inset
            }

            //计算得到该section实际的位置
            var sectionFrame = firstItem.frame.union(lastItem.frame)
            //计算得到该section实际的尺寸
            if self.scrollDirection == .horizontal {
                sectionFrame.origin.x -= sectionInset.left
                sectionFrame.origin.y = sectionInset.top
                sectionFrame.size.width += sectionInset.left + sectionInset.right
                sectionFrame.size.height = self.collectionView!.frame.height
            } else {
                sectionFrame.origin.x = sectionInset.left
                sectionFrame.origin.y -= sectionInset.top
                sectionFrame.size.width = self.collectionView!.frame.width
                sectionFrame.size.height += sectionInset.top + sectionInset.bottom
            }


            // 想判断卡片是否可见
            let cardDisplayed = strongCardDecorationDelegate.collectionView(self.collectionView!, layout: self, decorationDisplayedForSectionAt: section)
            guard cardDisplayed == true else {
                continue
            }

            // 计算卡片装饰图的属性
            let cardDecorationInset = strongCardDecorationDelegate.collectionView(self.collectionView!, layout: self, decorationInsetForSectionAt: section)
            //计算得到cardDecoration该实际的尺寸
            var cardDecorationFrame = sectionFrame
            if self.scrollDirection == .horizontal {
                cardDecorationFrame.origin.x = sectionFrame.origin.x + cardDecorationInset.left
                cardDecorationFrame.origin.y = cardDecorationInset.top
            } else {
                cardDecorationFrame.origin.x = cardDecorationInset.left
                cardDecorationFrame.origin.y = sectionFrame.origin.y + cardDecorationInset.top
            }
            cardDecorationFrame.size.width = sectionFrame.size.width - (cardDecorationInset.left + cardDecorationInset.right)
            cardDecorationFrame.size.height = sectionFrame.size.height - (cardDecorationInset.top + cardDecorationInset.bottom)

            //根据上面的结果计算卡片装饰图的布局属性
            let cardAttr = SectionCardDecorationCollectionViewLayoutAttributes(
                forDecorationViewOfKind: SectionCardDecorationViewKind,
                with: IndexPath(item: 0, section: section))
            cardAttr.frame = cardDecorationFrame

            // zIndex用于设置front-to-back层级;值越大,优先布局在上层;cell的zIndex为0
            cardAttr.zIndex = -1
            //通过代理方法获取该section卡片装饰图使用的颜色
            let backgroundColor = strongCardDecorationDelegate.collectionView(self.collectionView!, layout: self, decorationColorForSectionAt: section)
            cardAttr.backgroundColor = backgroundColor

            //将该section的卡片装饰图的布局属性保存起来
            self.cardDecorationViewAttrs[section] = cardAttr


            // 先判断袖标是否可见
            let armbandDisplayed = strongCardDecorationDelegate.collectionView(self.collectionView!, layout: self, armbandDecorationDisplayedForSectionAt: section)
            guard armbandDisplayed == true else {
                continue
            }

            // 如果袖标图片名称为nil,就跳过
            guard let imageName = strongCardDecorationDelegate.collectionView(self.collectionView!, layout: self, armbandDecorationImageForSectionAt: section) else {
                continue
            }

            // 计算袖标装饰图的属性
            var armbandDecorationInset = cardDecorationInset
            armbandDecorationInset.left = 1
            armbandDecorationInset.top = 18
            if let topOffset = strongCardDecorationDelegate.collectionView(self.collectionView!, layout: self, armbandDecorationTopOffsetForSectionAt: section) {
                armbandDecorationInset.top = topOffset
            }
            //计算得到armbandDecoration该实际的尺寸
            var armbandDecorationFrame = sectionFrame
            if self.scrollDirection == .horizontal {
                armbandDecorationFrame.origin.x = sectionFrame.origin.x + armbandDecorationInset.left
                armbandDecorationFrame.origin.y = armbandDecorationInset.top
            } else {
                armbandDecorationFrame.origin.x = armbandDecorationInset.left
                armbandDecorationFrame.origin.y = sectionFrame.origin.y + armbandDecorationInset.top
            }
            armbandDecorationFrame.size.width = 80
            armbandDecorationFrame.size.height = 53

            // 根据上面的结果计算袖标装饰视图的布局属性
            let armbandAttr = SectionCardArmbandDecorationCollectionViewLayoutAttributes(
                forDecorationViewOfKind: SectionCardArmbandDecorationViewKind,
                with: IndexPath(item: 0, section: section))
            armbandAttr.frame = armbandDecorationFrame
            armbandAttr.zIndex = 1
            armbandAttr.imageName = imageName
            //将该section的袖标装饰视图的布局属性保存起来
            self.armbandDecorationViewAttrs[section] = armbandAttr
        }
    }

    //返回rect范围下父类的所有元素的布局属性以及子类自定义装饰视图的布局属性
    override func layoutAttributesForElements(in rect: CGRect)
        -> [UICollectionViewLayoutAttributes]? {
            var attrs = super.layoutAttributesForElements(in: rect)
            attrs?.append(contentsOf: self.cardDecorationViewAttrs.values.filter {
                return rect.intersects($0.frame)
            })
            attrs?.append(contentsOf: self.armbandDecorationViewAttrs.values.filter {
                return rect.intersects($0.frame)
            })
            return attrs
    }

    //返回对应于indexPath的位置的装饰视图的布局属性
    override func layoutAttributesForDecorationView(ofKind elementKind: String,
                                                    at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        let section = indexPath.section
        if elementKind == SectionCardDecorationViewKind {
            return self.cardDecorationViewAttrs[section]
        } else if elementKind == SectionCardArmbandDecorationViewKind {
            return self.armbandDecorationViewAttrs[section]
        }
        return super.layoutAttributesForDecorationView(ofKind: elementKind,
                                                       at: indexPath)
    }
}

4. 自定义 UICollectionView,解决装饰视图的坑

在描述这个坑前,需要先普及一个知识点:如何控制UICollectionView的子视图的层级关系,如让卡片装饰视图居于cell下面?

答案是:使用UICollectionViewLayoutAttributeszIndex 属性。 UICollectionView进行布局时,会依据子视图的布局属性的 zIndex 的值的大小来控制子视图的 front-to-back 层级关系(在前或者在后)。cell 的布局属性的 zIndex 的值为0,所以若要卡片装饰视图在 cell 下面,只要设置其布局属性的 zIndex 的值小于0即可。

在知道这个知识点后,让我来具体描述一下的 UICollectionView 的在装饰视图的坑:在iOS10+上,zIndex 会随机失效。具体表现为,卡片装饰视图的布局属性的 zIndex 设置为 -1,比 cell 的小,理论上进行布局时,卡片装饰视图应该总是在 cell 下面;但是实际上,当你的 UICollectionView 比较复杂时,会 随机 出现某些 cell 布局在了卡片装饰视图下面,如图所示(由于这个“随机问题”只出现在具体的项目中,不出现在Demo中,为了方便说明问题,特意“手动”实现这种“随机问题”的效果来生成截图😂):

image.png

对于这个“随机”问题,国外论坛也有对应的讨论

在该讨论的帖子下,有开发者建议通过设置 cell.layer.zPosition 来解决,但是我在尝试后,发现这个方法无效。最后,我使用了另一个方法来解决:自定义 UICollectionView,override layoutSubviews 方法,手动调整装饰视图和cell的层级关系。

class CardCollectionView: UICollectionView {

    override func layoutSubviews() {
        super.layoutSubviews()

        var sectionCardViews: [UIView] = []

        self.subviews.forEach { (subview) in
            if let decorationView = subview as? SectionCardDecorationReusableView {
                sectionCardViews.append(decorationView)
            }
        }

        sectionCardViews.forEach { (decorationView) in
            self.sendSubview(toBack: decorationView)
        }
    }
}

DecorationView 的重用机制

UICollectionView 里,DecorationView 的重用机制和 Cell 的重用机制是一致的:使用前,先注册(只不过 DecorationView 的注册是由UICollectionViewFlowLayout来发起——实际还是 UICollectionView 进行最终的注册操作);使用时,由UICollectionView根据上下文创建新的 DecorationView 或者返回旧的 DecorationView。

那么以上结论的依据是什么呢?请看下面的UICollectionView的重用队列属性即可知道:

image.png

UICollectionView 里面有2种视图类型的重用队列,分别是 Cell 类型(对应cellReuseQueues) 和 Supplementary 类型(对应supplementaryReuseQueues)。这2种类型的重用机制是一样的。其中,DecorationView 是 SupplementaryView 的一种。

结语

最后,附上Demo代码。具体,请点击这个 repo

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

推荐阅读更多精彩内容

  • 翻译自“Collection View Programming Guide for iOS” 0 关于iOS集合视...
    lakerszhy阅读 3,788评论 1 22
  • 概述 UICollectionView是iOS开发中最常用的UI控件之一,可以用它来管理一组有序的不同尺寸的视图,...
    渐z阅读 2,940评论 0 3
  • 原文地址://www.greatytc.com/p/db55bd5f5aeb[https://www.j...
    移动端_小刚哥阅读 2,897评论 7 18
  • 无论失去什么也不放弃对爸妈的爱,即使面对生活的打击也不忘给自己一个微笑,正是这样一个小女孩用她的行动诉说着她对爸...
    SoulCasualness阅读 313评论 0 1
  • 宫门深似海,万丈深渊莫回首 红颜未老恩先断,最是无情帝王家。从进宫的那刻起,所有人的命运都进入了既定的轨道,在这期...
    原版穆川阅读 881评论 0 1