UICollectionView 07 - 標籤佈局
文章按照順序寫的,之前文章寫過的很多邏輯都會略過,建議順序閱讀,並下載原始碼結合閱讀。
目錄
專案下載地址: ofollow,noindex">CollectionView-Note
上一篇的瀑布流只針對cell的自定義佈局,這篇為了全面標籤佈局針對了 整體Header 、sectionHeader 和 cell都做了佈局。 SupplementaryView
跟 tableview
的 header和footer不同, 我們一個section可以有任意多個 SupplementaryView
,我們可以自己管理他們的位置。本篇需要實現的效果如下

000
所有顏色都使用隨機色,標籤根據文字大小決定,一行顯示不下自動換行。並添加了可伸縮Header和sectionHeader。 對刪除和新增做了自定義動畫。 算一個比較全面的例子。下面看下實現邏輯過程。
首先我們做一些資料準備,這裡不是重點 提一下,大家可以下載程式碼檢視。
// 1 let randomText = "黑髮不知勤學早白首方悔讀書遲遲日江山麗春風花草香杜甫絕句春色滿園關不住一枝紅杏出牆來葉紹翁遊園不值好雨知時節當春乃發生杜甫春雨夏天小荷才露尖尖角早有蜻蜓立上頭楊萬里小池接天蓮葉無窮碧映日荷花別樣紅" // 2 func genernalText() -> String{ let textCount = randomText.count let randomIndex = arc4random_uniform(UInt32(textCount)) let start = max(0, Int(randomIndex)-7) let startIndex = randomText.startIndex let step = arc4random_uniform(5) + 2 // 2到5個字 let startTextIndex = randomText.index(startIndex, offsetBy: start) let endTexIndex = randomText.index(startIndex, offsetBy: start + Int(step)) let text = String(randomText[startTextIndex ..< endTexIndex]) return text } // 3 func generalTags() -> [[String]]{ var tags1: [String] = [] var tags2: [String] = [] var tags3: [String] = [] for i in 0..<50 { if i%3 == 0 { tags1.append(genernalText()) } if i%2 == 0{ tags2.append(genernalText()) } tags3.append(genernalText()) } return [tags1,tags2,tags3] }
- 宣告一長串文字
- 從長文中隨機產生一個2-5個字的文字
- 因為是分組這裡生成三組不同長度的陣列 組成一個二維陣列 作為資料來源。
然後像之前章節一樣 Storyboard
中建立一個 TagViewController
, 宣告 collectionView
, 新建一個 TagLayout
, 替換自帶的 flowLayout
(具體替換方法參照之前文章)
在我們的 TagLayout
有一個變數就是文字的長度,這個我們可以根據文字的字型和Text內容計算出。為了使用便捷這裡提供一個代理方法 (建議下載原始碼結合檢視)
protocol TagLayoutDelegate: class { func collectionView(_ collectionView: UICollectionView, TextForItemAt indexPath: IndexPath) -> String }
根據 indexPath
返回對應的文字 。
在 TagLayout
頂部新增一個列舉
enum Element { case cell case header case sectionHeader }
包含了 後面我們要自定義位置的三個元素
新增一個變數和常量
// 標籤的內邊距 var tagInnerMargin: CGFloat = 25 // 元素間距 var itemSpacing: CGFloat = 10 // 行間距 var lineSpacing: CGFloat = 10 // 標籤的高度 var itemHeight: CGFloat = 25 // 標籤的字型 var itemFont: UIFont = UIFont.systemFont(ofSize: 12) // header的高度 var headerHeight: CGFloat = 150 // sectionHeader 高度 var sectionHeaderHeight: CGFloat = 50 // header的型別 let headerKind = "ElementTagHeader" weak var delegate: TagLayoutDelegate?
頂部的header為了區分系統的 elementKindSectionHeader
我們自定義了一種kind。
然後定義一些私有變數。
// 快取 private var cache = [Element: [IndexPath: UICollectionViewLayoutAttributes]]() // 可見區域 private var visibleLayoutAttributes = [UICollectionViewLayoutAttributes]() // 內容高度 private var contentHeight: CGFloat = 0 // 用來記錄新增的元素 private var insertIndexPaths = [IndexPath]() // 用來記錄刪除的元素 private var deleteIndexPaths = [IndexPath]()} // MARK: - 一些計算屬性 防止編寫冗餘程式碼 private var collectionViewWidth: CGFloat { return collectionView!.frame.width }
本篇中的快取按照列舉型別進行了區分,但是實質還是差不多的。
下面開始具體的佈局資訊計算和快取 。
override func prepare() { // 1 guard let collectionView = self.collectionView , let delegate = delegate else { return } let sections = collectionView.numberOfSections // 2 prepareCache() contentHeight = 0 // 3 // 可伸縮header let headerIndexPath = IndexPath(item: 0, section: 0) let headerAttribute = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: headerKind, with: headerIndexPath) let frame = CGRect(x: 0, y: 0, width: collectionViewWidth, height: headerHeight) headerAttribute.frame = frame cache[.header]?[headerIndexPath] = headerAttribute contentHeight = frame.maxY // 4 for section in 0 ..< sections { // 處理sectionHeader let sectionHeaderIndexPath = IndexPath(item: 0, section: section) // 5 let sectionHeaderAttribute = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, with: sectionHeaderIndexPath) var sectionOriginY = contentHeight if section != 0 { sectionOriginY += lineSpacing } let sectionFrame = CGRect(x: 0 , y: sectionOriginY , width: collectionViewWidth , height: sectionHeaderHeight) sectionHeaderAttribute.frame = sectionFrame cache[.sectionHeader]?[sectionHeaderIndexPath] = sectionHeaderAttribute contentHeight = sectionFrame.maxY // 6 // 處理tag let rows = collectionView.numberOfItems(inSection: section) var frame = CGRect(x: 0, y: contentHeight + lineSpacing, width: 0, height: 0) for item in 0 ..< rows { let indexPath = IndexPath(item: item, section: section) // 7 let text = delegate.collectionView(collectionView, TextForItemAt: indexPath) let tagWidth = self.textWidth(text) + tagInnerMargin // 8 // 其他 if frame.maxX + tagWidth + itemSpacing*2 > collectionViewWidth { // 需要換行 frame = CGRect(x: itemSpacing , y: frame.maxY + lineSpacing , width: tagWidth, height: itemHeight) }else{ frame = CGRect(x: frame.maxX + itemSpacing, y: frame.origin.y , width: tagWidth , height: itemHeight) } // 9 let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath) attributes.frame = frame cache[.cell]?[indexPath] = attributes } // 10 contentHeight = frame.maxY } } private func prepareCache() { cache.removeAll(keepingCapacity: true) cache[.sectionHeader] = [IndexPath: UICollectionViewLayoutAttributes]() cache[.cell] = [IndexPath: UICollectionViewLayoutAttributes]() cache[.header] = [IndexPath: UICollectionViewLayoutAttributes]() } // 根據文字 確定label的寬度 private func textWidth(_ text: String) -> CGFloat { let rect = (text as NSString).boundingRect(with: .zero, options: .usesLineFragmentOrigin, attributes: [.font: self.itemFont], context: nil) return rect.width }
這段程式碼有點長,我們一一解釋。
- 可選繫結,並獲取section的數量
- 一些初始化
- 處理頂部的可伸縮header然後加入快取並更新
contentHeight
, 這裡使用了我們的自定義型別headerKind
。 - 遍歷section 準備處理每個section中的內容
- 處理sectionHeader 以系統
elementKindSectionHeader
作為kind 。 並加入快取更新contentHeight
- 獲取到某個section對用的cell個數。初始化一個frame以之前的
contentHeight
+ 行間距lineSpacing
起步 - 獲取每個元素的text , 然後計算出對應的寬度,加上內邊距得到元素的寬度
tagWidth
- 如果frame的最大x左邊加上此元素的寬度和兩個元素邊距大於
collectionViewWidth
,需要換行顯示。否則追加在此行。 重置frame的值 - 將frame值賦值給
UICollectionViewLayoutAttributes
並快取 - 某個section的cell計算完之後用最後一個元素的frame更新
contentHeight
雖然程式碼多,但是邏輯並不複雜。都是一些加減計算。 ok,快取準備好了 之後的變很輕鬆了。
// 1 override var collectionViewContentSize: CGSize { return CGSize(width: collectionViewWidth, height: contentHeight) } // 2 override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { return cache[.cell]?[indexPath] } // 3 override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { switch elementKind { case UICollectionView.elementKindSectionHeader: return cache[.sectionHeader]?[indexPath] case headerKind: return cache[.header]?[indexPath] default: return nil } }
- 返回可滾動區域
collectionViewContentSize
- 返回cell對應indexPath的
UICollectionViewLayoutAttributes
- 返回
SupplementaryView
對應 kind 和 indexPath的UICollectionViewLayoutAttributes
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { visibleLayoutAttributes.removeAll(keepingCapacity: true) for (type , elementInfos) in cache { for (_ , attributes) in elementInfos where attributes.frame.intersects(rect){ // 為可伸縮header if let deltalY = self.calculateDeltalY() , type == .header { var headerRect = attributes.frame headerRect.size.height = headerRect.height + deltalY headerRect.origin.y = headerRect.origin.y - deltalY attributes.frame = headerRect } visibleLayoutAttributes.append(attributes) } } return visibleLayoutAttributes } // 計算可伸縮高度 private func calculateDeltalY() -> CGFloat?{ guard let collectionView = self.collectionView else { return nil } let insets = collectionView.contentInset let offset = collectionView.contentOffset let minY = -insets.top if offset.y < minY { let deltalY = abs(offset.y - minY) return deltalY } return nil }
layoutAttributesForElements
在前幾篇已經用過好多次了,就是item將要展示的時候將他們的UICollectionViewLayoutAttributes返回。這裡結合了可伸縮Header那篇對header進行了處理。
ok到這裡,整個佈局篇已經寫好了。我們回到 TagViewController
中,新建一個cell TagCell
。 只有一個label在充滿整個cell。設定字型和layout中保持一致 。
class TagCell: UICollectionViewCell { static let reuseID = "tagCell" @IBOutlet weak var tagLabel: UILabel! var value: String = "" { didSet{ tagLabel.text = value } } override func awakeFromNib() { super.awakeFromNib() backgroundColor = UIColor.randomColor() tagLabel.font = UIFont.systemFont(ofSize: 12) tagLabel.textColor = UIColor.white } }
header沿用了之前的。 在 viewDidLoad
中進行註冊
collectionView.register(UINib(nibName: "ImageHeaderView", bundle: nil), forSupplementaryViewOfKind: headerKind , withReuseIdentifier: ImageHeaderView.reuseID) collectionView.register(UINib(nibName: "BasicsHeaderView", bundle: nil), forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: BasicsHeaderView.reuseID)
這裡的 ImageHeaderView
所使用的kind是layout中定義的,在Controller中新增計算屬性
var headerKind: String { return layout?.headerKind ?? "" }
然後再使用Header的時候也要區分對待
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { switch kind { case UICollectionView.elementKindSectionHeader: let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: BasicsHeaderView.reuseID, for: indexPath) as! BasicsHeaderView view.titleLabel.text = "HEADER -- \(indexPath.section)" view.backgroundColor = UIColor.randomColor() return view case headerKind: let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ImageHeaderView.reuseID, for: indexPath) as! ImageHeaderView return view default: fatalError("No such kind") } }
別忘了實現Layout的代理
// MARK: - TagLayoutDelegate extension TagViewController: TagLayoutDelegate { func collectionView(_ collectionView: UICollectionView, TextForItemAt indexPath: IndexPath) -> String { return tags[indexPath.section][indexPath.row] } }
其他的基礎程式碼和之前無差,只是換了資料來源。
這時候執行所有佈局已經完成了。
那麼如果新增動畫呢?也是非常簡單的,記得我們之前聲明瞭兩個變數,儲存新增和刪除的元素
在 TagLayout
中,新增如下方法 用於記錄新增和刪除的元素。
override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) { super.prepare(forCollectionViewUpdates: updateItems) self.insertIndexPaths.removeAll() self.deleteIndexPaths.removeAll() for update in updateItems { switch update.updateAction { case .insert: if let indexPath = update.indexPathAfterUpdate { self.insertIndexPaths.append(indexPath) } case .delete: if let indexPath = update.indexPathBeforeUpdate { self.deleteIndexPaths.append(indexPath) } default:break } } }
然後 用另外兩個方法去執行動畫
/// MARK: 動畫相關 override func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? { guard let attribute = super.initialLayoutAttributesForAppearingItem(at: itemIndexPath) else { return nil } if self.insertIndexPaths.contains(itemIndexPath) { attribute.transform = CGAffineTransform.identity.scaledBy(x: 4, y: 4).rotated(by: CGFloat(Double.pi/2)) } return attribute } override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? { guard let attribute = super.finalLayoutAttributesForDisappearingItem(at: itemIndexPath) else { return nil } if self.deleteIndexPaths.contains(itemIndexPath) { attribute.transform = CGAffineTransform.identity.scaledBy(x: 4, y: 4).rotated(by: CGFloat(Double.pi/2)) } return attribute }
initial 和 final的方法還有針對 SupplementaryView
的,本篇並不打算演示,大家可以自行嘗試。
然後再 Storyboard
中拖出兩個控制元件處理新增和刪除
@IBAction func addTag(_ sender: Any) { // 隨機新增一個tag let text = DataManager.shared.genernalText() tags[0].append(text) let indexPath = IndexPath(item: tags[0].count - 1, section: 0) collectionView.insertItems(at: [indexPath]) } @IBAction func deleteTag(_ sender: Any) { let count = tags[0].count if count == 0 { return } let indexPath = IndexPath(item: count - 1, section: 0) self.tags[0].remove(at: indexPath.row) collectionView.performBatchUpdates({ [ weak self] in guard let `self` = self else { return } self.collectionView.deleteItems(at: [indexPath]) }, completion: nil) }
collectionView
可以使用 performBatchUpdates
處理一系列的操作,比如新增刪除移動等。並可以處理回撥。
ok , 執行 不出意外應該完美。 有問題的仔細下載程式碼檢視。或者評論交流。

000
本系列完結。