UICollectionView 07 - 标签布局

文章按照顺序写的,之前文章写过的很多逻辑都会略过,建议顺序阅读,并下载源码结合阅读。

目录

项目下载地址: CollectionView-Note

UICollectionView 01 - 基础布局篇
UICollectionView 02 - 布局和代理篇
UICollectionView 03 - 自定义布局原理篇
UICollectionView 04 - 卡片布局
UICollectionView 05 - 可伸缩Header
UICollectionView 06 - 瀑布流布局
UICollectionView 07 - 标签布局

上一篇的瀑布流只针对cell的自定义布局,这篇为了全面标签布局针对了 整体Header 、sectionHeader 和 cell都做了布局。 SupplementaryViewtableview 的 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]
  }
  1. 声明一长串文字
  2. 从长文中随机产生一个2-5个字的文本
  3. 因为是分组这里生成三组不同长度的数组 组成一个二维数组 作为数据源。

然后像之前章节一样 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
}

这段代码有点长,我们一一解释。

  1. 可选绑定,并获取section的数量
  2. 一些初始化
  3. 处理顶部的可伸缩header然后加入缓存并更新contentHeight , 这里使用了我们的自定义类型headerKind
  4. 遍历section 准备处理每个section中的内容
  5. 处理sectionHeader 以系统elementKindSectionHeader 作为kind 。 并加入缓存更新 contentHeight
  6. 获取到某个section对用的cell个数。初始化一个frame以之前的 contentHeight + 行间距 lineSpacing 起步
  7. 获取每个元素的text , 然后计算出对应的宽度,加上内边距得到元素的宽度 tagWidth
  8. 如果frame的最大x左边加上此元素的宽度和两个元素边距大于 collectionViewWidth ,需要换行显示。否则追加在此行。 重置frame的值
  9. 将frame值赋值给UICollectionViewLayoutAttributes 并缓存
  10. 某个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
    }
}
  1. 返回可滚动区域 collectionViewContentSize
  2. 返回cell对应indexPath的 UICollectionViewLayoutAttributes
  3. 返回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

本系列完结。

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

推荐阅读更多精彩内容