要实现的布局效果
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
}
}