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閱讀 227,533評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,702評論 2 370

推薦閱讀更多精彩內容

  • 翻譯自“Collection View Programming Guide for iOS” 0 關于iOS集合視...
    lakerszhy閱讀 3,901評論 1 22
  • 概述 UICollectionView是iOS開發中最常用的UI控件之一,可以用它來管理一組有序的不同尺寸的視圖,...
    漸z閱讀 2,982評論 0 3
  • 原文地址:http://www.lxweimin.com/p/db55bd5f5aeb[https://www.j...
    移動端_小剛哥閱讀 2,909評論 7 18
  • 無論失去什么也不放棄對爸媽的愛,即使面對生活的打擊也不忘給自己一個微笑,正是這樣一個小女孩用她的行動訴說著她對爸...
    SoulCasualness閱讀 327評論 0 1
  • 宮門深似海,萬丈深淵莫回首 紅顏未老恩先斷,最是無情帝王家。從進宮的那刻起,所有人的命運都進入了既定的軌道,在這期...
    原版穆川閱讀 935評論 0 1