UICollectionView 自定制布局: 顶部吸附效果

4,995 阅读4分钟

要实现的布局效果

UICollectionView 自定制布局,就是自个来实现 UICollectionViewLayout,

而不是使用,系统给的 UICollectionViewFlowLayout

上图所示的布局效果:

往下拉动,顶部两块都吸附,格子也都吸附了

往上拉动,顶部第二块吸附


布局效果的实现思路

实现效果: 往下拉动,顶部两块都吸附,格子也都吸附

常规思路是

控制手势的方向,只能向下,不可向上

这里采用布局控制

UICollectionView 是 UIScrollView 的子类,

UIScrollView 滚动的时候,有一个 contentOffset,

contentOffset 是一个点, CGPoint

下拉的时候,给每一个视图 (两个顶部与格子视图)加一个平移变换,CGAffineTransform.translation ,就好了

手指往下拉,界面看上去没有动,实际上是两个顶部与格子视图,和 scrollView 的容器视图,同步滚动

实现效果: 往上拉动,顶部第二块吸附

常规思路是

顶部第二块有两个,一个是 UICollectionView 的补充视图 SupplementaryView,一个是独立的控件,

往上拉动的时候,补充视图顶部第二块拉出界面,独立的顶部第二块默认隐藏,这时候显示

这里直接采用布局控制

顶部第二块只有一份,作为补充视图 SupplementaryView

往上拉动的时候,使用 UICollectionView 的 contentOffset,得到目前的位置,

再添加一个平移放射变换,来保持位置

顶部第二块要在格子视图的上方,顶部第二块 zIndex 大于格子视图们的 zIndex, OK

布局效果的具体实现

自定制 layout ,系统给了我们三个有用的入口

  • 给补充视图的布局信息

全部补充视图的布局信息

func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?

  • 给格子视图的布局信息

全部格子视图的布局信息

func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?

  • 屏幕内可视区域的视图布局信息

布局信息汇总

func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?

一般的布局很简单,搞定这三个方法,收工

该布局效果的特别之处是:滚动起来,有不同的表现形式,上面说了两种

常规思路是

使用 func scrollViewDidScroll(_ scrollView: UIScrollView) 监听 contentOffset ,来触发

监听系统的布局,用 func scrollViewDidScroll(_ scrollView: UIScrollView) 回调,

在此回调中,作用系统的布局,效果一般

这里当然布局控制

重写系统的 func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool,

因为,用户一滚动界面的时候,collectionView 的 bounds 属性就会改变

shouldInvalidateLayout(forBoundsChange:) 这个方法, 里面可以写判断规则,决定去刷新布局的时机。

格子视图 collectionView 的 bounds 属性改变,shouldInvalidateLayout(forBoundsChange:) 判断通过,再次进入 prepare() 方法,计算位置。

override public func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
    if oldBounds.size != newBounds.size {
      cache.removeAll(keepingCapacity: true)
    }
    return true
  }

这酱紫,实现了滚动刷新布局

布局信息,重新计算,又调用 func prepare()

func prepare() 中,布局信息初始化

override public func prepare() {
    guard let collectionView = collectionView, collectionView.numberOfItems(inSection: 0) > 0 else {
        return
    }
    // 细节方法,见 github 
    prepareCache()
    contentHeight = 0
    zIndex = 0
    oldBounds = collectionView.bounds
    let itemSize = CGSize(width: collectionViewWidth, height: cellHeight)
    // 顶部第一块的布局信息
    let headerAttributes = CustomLayoutAttributes(
      forSupplementaryViewOfKind: Element.header.kind,
      with: IndexPath(item: 0, section: 0)
    )
    prepareElement(size: headerSize, type: .header, attributes: headerAttributes)
     // 顶部第 2 块的布局信息
    let menuAttributes = CustomLayoutAttributes(
      forSupplementaryViewOfKind: Element.menu.kind,
      with: IndexPath(item: 0, section: 0))
    prepareElement(size: menuSize, type: .menu, attributes: menuAttributes)
    
       // 格子视图的布局信息
      for item in 0 ..< collectionView.numberOfItems(inSection: 0) {
        let cellIndexPath = IndexPath(item: item, section: 0)
        let attributes = CustomLayoutAttributes(forCellWith: cellIndexPath)
        let lineInterSpace = settings.minimumLineSpacing
        attributes.frame = CGRect(
          x: settings.minimumInteritemSpacing,
          y: contentHeight + lineInterSpace,
          width: itemSize.width,
          height: itemSize.height
        )
        attributes.zIndex = zIndex
        contentHeight = attributes.frame.maxY
        cache[.cell]?[cellIndexPath] = attributes
        zIndex += 1
      }
    // 保证顶部第 2 块的布局 zIndex, > 格子视图的
    cache[.menu]?.first?.value.zIndex = zIndex
  }

布局信息汇总,并根据偏移 contentOffset 动态调节


override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    guard let collectionView = collectionView else { return nil }
    
    visibleLayoutAttributes.removeAll(keepingCapacity: true)
    for (type, elementInfos) in cache {
      for (indexPath, attributes) in elementInfos {
        
        
        attributes.transform = .identity
        //  动态调节, 顶部两块的布局信息
        updateSupplementaryViews(type, attributes: attributes, collectionView: collectionView, indexPath: indexPath)
        
        // 只关心屏幕上显示了的,布局信息
        // 不在屏幕上的布局信息 attributes ,不用管
        if attributes.frame.intersects(rect) {
          if type == .cell{
            // 动态调节, 格子视图的布局信息
            updateCells(attributes, collectionView: collectionView, indexPath: indexPath)
          }
          visibleLayoutAttributes.append(attributes)
        }
      }
    }
    return visibleLayoutAttributes
  }

动态调节, 顶部两块 SupplementaryView 的布局信息

即根据偏移 contentOffset.y,设置相应的平移仿射变换

顶部第一块,还加了一个 alpha 控制

  private func updateSupplementaryViews(_ type: Element, attributes: CustomLayoutAttributes, collectionView: UICollectionView, indexPath: IndexPath) {
    switch type {
    case .header:
        attributes.transform = CGAffineTransform(translationX: 0, y: contentOffset.y)
        attributes.headerOverlayAlpha = min(settings.headerOverlayMaxAlphaValue, contentOffset.y / headerSize.height)
    case .menu:
      print(contentOffset.y)
      if contentOffset.y < 0{
        attributes.transform = CGAffineTransform(translationX: 0, y: attributes.initialOrigin.y - headerSize.height + contentOffset.y)
      }
      else{
        attributes.transform = CGAffineTransform(translationX: 0, y: max(attributes.initialOrigin.y, contentOffset.y) - headerSize.height)
      }
        
    default:
      break
    }
  }

动态调节, 格子视图的布局信息

即根据偏移 contentOffset.y,设置相应的平移仿射变换

 private func updateCells(_ attributes: CustomLayoutAttributes, collectionView: UICollectionView, indexPath: IndexPath) {
     if contentOffset.y < 0{
       attributes.transform = CGAffineTransform(translationX: 0, y: attributes.initialOrigin.y + contentOffset.y)
     }

 }

最后补充,

系统的, UICollectionViewFlowLayout ,标准的书架,

每一个 section 只能搭配一个有效的补充视图 header 和 footer

( 因为 header 和 footer 的 size ,是按 section 来的 )

这里的布局效果,一个 section 搭配了两个 header

实际上,自己写布局 UICollectionViewFlow,

不但每一个补充视图和格子视图的位置,随意摆放

补充视图的数量也可以任意分配了,

可这么理解,补充视图的最大数量 = Kind * IndexPath

多少个 Kind,自由设置

该布局中,这么处理的

先初始化,


override public func prepare() {
    // ...
	let headerAttributes = CustomLayoutAttributes(
      forSupplementaryViewOfKind: Element.header.kind,
      with: IndexPath(item: 0, section: 0)
    )
	// ...
    let menuAttributes = CustomLayoutAttributes(
      forSupplementaryViewOfKind: Element.menu.kind,
      with: IndexPath(item: 0, section: 0))
    prepareElement(size: menuSize, type: .menu, attributes: menuAttributes)
    // ...
}

再注册进去,

public override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
    switch elementKind {
    case Element.header.kind:
      return cache[.header]?[indexPath]
    case CustomLayout.Element.menu.kind:
      return cache[.menu]?[indexPath]
    default:
      return nil
    }
  }

自定制布局,套路明确,更加灵活

github 链接