文章按照顺序写的,之前文章写过的很多逻辑都会略过,建议顺序阅读,并下载源码结合阅读。
目录
项目下载地址: CollectionView-Note
UICollectionView 01 - 基础布局篇
UICollectionView 02 - 布局和代理篇
UICollectionView 03 - 自定义布局原理篇
UICollectionView 04 - 卡片布局
UICollectionView 05 - 可伸缩Header
UICollectionView 06 - 瀑布流布局
UICollectionView 07 - 标签布局
上一篇的瀑布流只针对cell的自定义布局,这篇为了全面标签布局针对了 整体Header 、sectionHeader 和 cell都做了布局。 SupplementaryView
跟 tableview
的 header和footer不同, 我们一个section可以有任意多个SupplementaryView
,我们可以自己管理他们的位置。本篇需要实现的效果如下
所有颜色都使用随机色,标签根据文字大小决定,一行显示不下自动换行。并添加了可伸缩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 , 运行 不出意外应该完美。 有问题的仔细下载代码查看。或者评论交流。
本系列完结。