前言
在項目新版本中,要實現類似以下的效果:給每個section
區域添加一個卡片裝飾背景以及一個袖標裝飾圖標(卡片在所有的cell
下,袖標在cell
上面)。
這可以通過UICollectionView
的 DecorationView
特性來達到以上效果。本文主要是總結 DecorationView
的實現、重用機制和存在的坑。
DecorationView 的實現(包括坑)
實現原理
- 繼承
UICollectionViewLayoutAttributes
,實現用于描述裝飾視圖的布局屬性的類,如描述卡片裝飾視圖的SectionCardDecorationCollectionViewLayoutAttributes
- 繼承
UICollectionReusableView
,實現自己的裝飾視圖,如卡片裝飾視圖SectionCardDecorationReusableView
- 繼承
UICollectionViewFlowLayout
,實現自己的布局計算:主要是注冊自定義的裝飾視圖和計算管理這些裝飾視圖的布局屬性。如SectionCardDecorationCollectionViewLayout
- 繼承
UICollectionView
,overridelayoutSubviews
方法,解決裝飾視圖的一個坑(關于此坑,請看文章具體描述)
核心代碼
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下面?答案是:使用
UICollectionViewLayoutAttributes
的zIndex
屬性。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
,overridelayoutSubviews
方法,手動調整裝飾視圖和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
的重用隊列屬性即可知道:
在 UICollectionView
里面有2種視圖類型的重用隊列,分別是 Cell 類型(對應cellReuseQueues
) 和 Supplementary 類型(對應supplementaryReuseQueues
)。這2種類型的重用機制是一樣的。其中,DecorationView 是 SupplementaryView 的一種。
結語
最后,附上Demo代碼。具體,請點擊這個 repo。