如何在使用“layoutAttributesForElements”时为集合视图布局更改设置动画?
How to animate collection view layout change while using `layoutAttributesForElements`?
我制作了一个自定义集合视图流布局,可以在“电影片段”和“列表”布局之间切换(带动画)。但是在向边缘单元格添加一些花哨的动画后,切换动画就坏了。这是目前的样子,没有这些变化:
动画很流畅,对吧?这是当前的工作代码 (full demo project here):
enum LayoutType {
case strip
case list
}
class FlowLayout: UICollectionViewFlowLayout {
var layoutType: LayoutType
var layoutAttributes = [UICollectionViewLayoutAttributes]() /// store the frame of each item
var contentSize = CGSize.zero /// the scrollable content size of the collection view
override var collectionViewContentSize: CGSize { return contentSize } /// pass scrollable content size back to the collection view
/// pass attributes to the collection view flow layout
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return layoutAttributes[indexPath.item]
}
// MARK: - Problem is here
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
/// edge cells don't shrink, but the animation is perfect
return layoutAttributes.filter { rect.intersects([=10=].frame) } /// try deleting this line
/// edge cells shrink (yay!), but the animation glitches out
return shrinkingEdgeCellAttributes(in: rect)
}
/// makes the edge cells slowly shrink as you scroll
func shrinkingEdgeCellAttributes(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let collectionView = collectionView else { return nil }
let rectAttributes = layoutAttributes.filter { rect.intersects([=10=].frame) }
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.frame.size) /// rect of the visible collection view cells
let leadingCutoff: CGFloat = 50 /// once a cell reaches here, start shrinking it
let trailingCutoff: CGFloat
let paddingInsets: UIEdgeInsets /// apply shrinking even when cell has passed the screen's bounds
if layoutType == .strip {
trailingCutoff = CGFloat(collectionView.bounds.width - leadingCutoff)
paddingInsets = UIEdgeInsets(top: 0, left: -50, bottom: 0, right: -50)
} else {
trailingCutoff = CGFloat(collectionView.bounds.height - leadingCutoff)
paddingInsets = UIEdgeInsets(top: -50, left: 0, bottom: -50, right: 0)
}
for attributes in rectAttributes where visibleRect.inset(by: paddingInsets).contains(attributes.center) {
/// center of each cell, converted to a point inside `visibleRect`
let center = layoutType == .strip
? attributes.center.x - visibleRect.origin.x
: attributes.center.y - visibleRect.origin.y
var offset: CGFloat?
if center <= leadingCutoff {
offset = leadingCutoff - center /// distance from the cutoff, 0 if exactly on cutoff
} else if center >= trailingCutoff {
offset = center - trailingCutoff
}
if let offset = offset {
let scale = 1 - (pow(offset, 1.1) / 200) /// gradually shrink the cell
attributes.transform = CGAffineTransform(scaleX: scale, y: scale)
}
}
return rectAttributes
}
/// initialize with a LayoutType
init(layoutType: LayoutType) {
self.layoutType = layoutType
super.init()
}
/// make the layout (strip vs list) here
override func prepare() { /// configure the cells' frames
super.prepare()
guard let collectionView = collectionView else { return }
var offset: CGFloat = 0 /// origin for each cell
let cellSize = layoutType == .strip ? CGSize(width: 100, height: 50) : CGSize(width: collectionView.frame.width, height: 50)
for itemIndex in 0..<collectionView.numberOfItems(inSection: 0) {
let indexPath = IndexPath(item: itemIndex, section: 0)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
let origin: CGPoint
let addedOffset: CGFloat
if layoutType == .strip {
origin = CGPoint(x: offset, y: 0)
addedOffset = cellSize.width
} else {
origin = CGPoint(x: 0, y: offset)
addedOffset = cellSize.height
}
attributes.frame = CGRect(origin: origin, size: cellSize)
layoutAttributes.append(attributes)
offset += addedOffset
}
self.contentSize = layoutType == .strip /// set the collection view's `collectionViewContentSize`
? CGSize(width: offset, height: cellSize.height) /// if strip, height is fixed
: CGSize(width: cellSize.width, height: offset) /// if list, width is fixed
}
/// boilerplate code
required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }
override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds) as! UICollectionViewFlowLayoutInvalidationContext
context.invalidateFlowLayoutDelegateMetrics = newBounds.size != collectionView?.bounds.size
return context
}
}
class ViewController: UIViewController {
var data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
var isExpanded = false
lazy var listLayout = FlowLayout(layoutType: .list)
lazy var stripLayout = FlowLayout(layoutType: .strip)
@IBOutlet weak var collectionView: UICollectionView!
@IBOutlet weak var collectionViewHeightConstraint: NSLayoutConstraint!
@IBAction func toggleExpandPressed(_ sender: Any) {
isExpanded.toggle()
if isExpanded {
collectionView.setCollectionViewLayout(listLayout, animated: true)
} else {
collectionView.setCollectionViewLayout(stripLayout, animated: true)
}
}
override func viewDidLoad() {
super.viewDidLoad()
collectionView.collectionViewLayout = stripLayout /// start with the strip layout
collectionView.dataSource = self
collectionViewHeightConstraint.constant = 300
}
}
/// sample data source
extension ViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return data.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ID", for: indexPath) as! Cell
cell.label.text = "\(data[indexPath.item])"
cell.contentView.layer.borderWidth = 5
cell.contentView.layer.borderColor = UIColor.red.cgColor
return cell
}
}
class Cell: UICollectionViewCell {
@IBOutlet weak var label: UILabel!
}
同样,一切都完美无缺,包括动画。因此,我试图让细胞在靠近屏幕边缘时收缩。我否决了 layoutAttributesForElements
来执行此操作。
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return layoutAttributes.filter { rect.intersects([=12=].frame) } /// delete this line
return shrinkingEdgeCellAttributes(in: rect) /// replace with this
}
Film-strip
List
scale/shrink 动画很棒。但是,当我在布局之间切换时,过渡动画被破坏了。
Before (return layoutAttributes.filter...
)
After (return shrinkingEdgeCellAttributes(in: rect)
)
我该如何修复这个动画?我是否应该使用自定义 UICollectionViewTransitionLayout
,如果是,如何使用?
哇!这是一次锻炼。我能够修改您的 FlowLayout
以便动画中没有问题。见下文。
有效!
问题
这就是正在发生的事情。当您更改布局时,如果集合视图的内容偏移不是 (0, 0)
.
,FlowLayout
中的 layoutAttributesForElements
方法将被调用两次
这是因为您已经将 'shouldInvalidateLayout' 覆盖为 return true
而不管它是否真的需要。我相信 UICollectionView
在布局更改之前和之后在布局上调用此方法(根据观察)。
这样做的副作用是您的缩放变换应用了两次——在动画之前和之后应用到可见的布局属性。
不幸的是,缩放变换是基于集合视图的 contentOffset
(link)
let visibleRect = CGRect(
origin: collectionView.contentOffset,
size: collectionView.frame.size
)
在布局更改期间,contentOffset
不一致。动画开始前 contentOffset
适用于之前的布局。动画之后,是相对于新布局的。在这里我还注意到,没有充分的理由,contentOffset 会“跳跃”(见注释 1)
由于您使用 visibleRect 查询要应用比例的布局属性集,因此会引入更多错误。
解决方案
我能够通过应用这些更改找到解决方案。
- 编写辅助方法,将前一个布局留下的内容偏移量(和依赖的 visibleRect)转换为对此布局有意义的值。
- 防止在
prepare
方法中计算冗余布局属性
- 跟踪布局何时和何时不动画
// In Flow Layout
class FlowLayout: UICollectionViewFlowLayout {
var animating: Bool = false
// ...
}
// In View Controller,
isExpanded.toggle()
if isExpanded {
listLayout.reset()
listLayout.animating = true // <--
// collectionView.setCollectionViewLayout(listLayout)
} else {
stripLayout.reset()
stripLayout.animating = true // <--
// collectionView.setCollectionViewLayout(stripLayout)
}
- 重写
targetContentOffset
方法来处理内容偏移变化(防止跳转)
// In Flow Layout
class FlowLayout: UICollectionViewFlowLayout {
var animating: Bool = false
var layoutType: LayoutType
// ...
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
guard animating else {
// return super
}
// Use our 'graceful' content content offset
// instead of arbitrary "jump"
switch(layoutType){
case .list: return transformCurrentContentOffset(.fromStripToList)
case .strip: return transformCurrentContentOffset(.fromListToStrip)
}
}
// ...
内容偏移量变换的实现如下
/**
Transforms this layouts content offset, to the other layout
as specified in the layout transition parameter.
*/
private func transformCurrentContentOffset(_ transition: LayoutTransition) -> CGPoint{
let stripItemWidth: CGFloat = 100.0
let listItemHeight: CGFloat = 50.0
switch(transition){
case .fromStripToList:
let numberOfItems = collectionView!.contentOffset.x / stripItemWidth // from strip
var newPoint = CGPoint(x: 0, y: numberOfItems * CGFloat(listItemHeight)) // to list
if (newPoint.y + collectionView!.frame.height) >= contentSize.height{
newPoint = CGPoint(x: 0, y: contentSize.height - collectionView!.frame.height)
}
return newPoint
case .fromListToStrip:
let numberOfItems = collectionView!.contentOffset.y / listItemHeight // from list
var newPoint = CGPoint(x: numberOfItems * CGFloat(stripItemWidth), y: 0) // to strip
if (newPoint.x + collectionView!.frame.width) >= contentSize.width{
newPoint = CGPoint(x: contentSize.width - collectionView!.frame.width, y: 0)
}
return newPoint
}
}
我在评论中遗漏了一些小细节,作为对 OP 演示项目的拉取请求,因此任何有兴趣的人都可以研究它。
关键 take-aways 是,
当内容偏移发生任意变化以响应布局变化时使用targetContentOffset
。
注意layoutAttributesForElements
布局属性查询错误。调试你的直肠!
记得在 prepare()
方法中清除缓存的布局属性。
注释
“跳跃”行为甚至在您引入 your gif.
中所见的缩放变换之前就已经很明显了
如果回答冗长,我深表歉意。或者,解决方案并不是您想要的。这个问题很有趣,这就是为什么我花了一整天的时间来寻找帮助的方法。
Fork and Pull request.
感谢您的详细调查@Thisura Dodangoda
– 它帮助我解决了类似的问题。对于最终来到这里的人,我想添加一个小细节,以防你 运行 进入我做的另一个问题。
UICollectionViewLayout API 有两个非常相似的方法:
func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint
此方法检索停止滚动的点
和
func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint
此方法检索内容偏移以在动画布局更新或更改后使用
我已经为滚动期间的一些自定义行为实现了第一个,并且我试图在该方法中实现@Thisura Dodangoda 发布的解决方案。
但是,它们用于完全不同的目的。您需要使用第二种方法(不带velocity参数)来实现布局变化的方案。
我制作了一个自定义集合视图流布局,可以在“电影片段”和“列表”布局之间切换(带动画)。但是在向边缘单元格添加一些花哨的动画后,切换动画就坏了。这是目前的样子,没有这些变化:
动画很流畅,对吧?这是当前的工作代码 (full demo project here):
enum LayoutType {
case strip
case list
}
class FlowLayout: UICollectionViewFlowLayout {
var layoutType: LayoutType
var layoutAttributes = [UICollectionViewLayoutAttributes]() /// store the frame of each item
var contentSize = CGSize.zero /// the scrollable content size of the collection view
override var collectionViewContentSize: CGSize { return contentSize } /// pass scrollable content size back to the collection view
/// pass attributes to the collection view flow layout
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return layoutAttributes[indexPath.item]
}
// MARK: - Problem is here
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
/// edge cells don't shrink, but the animation is perfect
return layoutAttributes.filter { rect.intersects([=10=].frame) } /// try deleting this line
/// edge cells shrink (yay!), but the animation glitches out
return shrinkingEdgeCellAttributes(in: rect)
}
/// makes the edge cells slowly shrink as you scroll
func shrinkingEdgeCellAttributes(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let collectionView = collectionView else { return nil }
let rectAttributes = layoutAttributes.filter { rect.intersects([=10=].frame) }
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.frame.size) /// rect of the visible collection view cells
let leadingCutoff: CGFloat = 50 /// once a cell reaches here, start shrinking it
let trailingCutoff: CGFloat
let paddingInsets: UIEdgeInsets /// apply shrinking even when cell has passed the screen's bounds
if layoutType == .strip {
trailingCutoff = CGFloat(collectionView.bounds.width - leadingCutoff)
paddingInsets = UIEdgeInsets(top: 0, left: -50, bottom: 0, right: -50)
} else {
trailingCutoff = CGFloat(collectionView.bounds.height - leadingCutoff)
paddingInsets = UIEdgeInsets(top: -50, left: 0, bottom: -50, right: 0)
}
for attributes in rectAttributes where visibleRect.inset(by: paddingInsets).contains(attributes.center) {
/// center of each cell, converted to a point inside `visibleRect`
let center = layoutType == .strip
? attributes.center.x - visibleRect.origin.x
: attributes.center.y - visibleRect.origin.y
var offset: CGFloat?
if center <= leadingCutoff {
offset = leadingCutoff - center /// distance from the cutoff, 0 if exactly on cutoff
} else if center >= trailingCutoff {
offset = center - trailingCutoff
}
if let offset = offset {
let scale = 1 - (pow(offset, 1.1) / 200) /// gradually shrink the cell
attributes.transform = CGAffineTransform(scaleX: scale, y: scale)
}
}
return rectAttributes
}
/// initialize with a LayoutType
init(layoutType: LayoutType) {
self.layoutType = layoutType
super.init()
}
/// make the layout (strip vs list) here
override func prepare() { /// configure the cells' frames
super.prepare()
guard let collectionView = collectionView else { return }
var offset: CGFloat = 0 /// origin for each cell
let cellSize = layoutType == .strip ? CGSize(width: 100, height: 50) : CGSize(width: collectionView.frame.width, height: 50)
for itemIndex in 0..<collectionView.numberOfItems(inSection: 0) {
let indexPath = IndexPath(item: itemIndex, section: 0)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
let origin: CGPoint
let addedOffset: CGFloat
if layoutType == .strip {
origin = CGPoint(x: offset, y: 0)
addedOffset = cellSize.width
} else {
origin = CGPoint(x: 0, y: offset)
addedOffset = cellSize.height
}
attributes.frame = CGRect(origin: origin, size: cellSize)
layoutAttributes.append(attributes)
offset += addedOffset
}
self.contentSize = layoutType == .strip /// set the collection view's `collectionViewContentSize`
? CGSize(width: offset, height: cellSize.height) /// if strip, height is fixed
: CGSize(width: cellSize.width, height: offset) /// if list, width is fixed
}
/// boilerplate code
required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }
override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds) as! UICollectionViewFlowLayoutInvalidationContext
context.invalidateFlowLayoutDelegateMetrics = newBounds.size != collectionView?.bounds.size
return context
}
}
class ViewController: UIViewController {
var data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
var isExpanded = false
lazy var listLayout = FlowLayout(layoutType: .list)
lazy var stripLayout = FlowLayout(layoutType: .strip)
@IBOutlet weak var collectionView: UICollectionView!
@IBOutlet weak var collectionViewHeightConstraint: NSLayoutConstraint!
@IBAction func toggleExpandPressed(_ sender: Any) {
isExpanded.toggle()
if isExpanded {
collectionView.setCollectionViewLayout(listLayout, animated: true)
} else {
collectionView.setCollectionViewLayout(stripLayout, animated: true)
}
}
override func viewDidLoad() {
super.viewDidLoad()
collectionView.collectionViewLayout = stripLayout /// start with the strip layout
collectionView.dataSource = self
collectionViewHeightConstraint.constant = 300
}
}
/// sample data source
extension ViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return data.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ID", for: indexPath) as! Cell
cell.label.text = "\(data[indexPath.item])"
cell.contentView.layer.borderWidth = 5
cell.contentView.layer.borderColor = UIColor.red.cgColor
return cell
}
}
class Cell: UICollectionViewCell {
@IBOutlet weak var label: UILabel!
}
同样,一切都完美无缺,包括动画。因此,我试图让细胞在靠近屏幕边缘时收缩。我否决了 layoutAttributesForElements
来执行此操作。
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return layoutAttributes.filter { rect.intersects([=12=].frame) } /// delete this line
return shrinkingEdgeCellAttributes(in: rect) /// replace with this
}
Film-strip | List |
---|---|
scale/shrink 动画很棒。但是,当我在布局之间切换时,过渡动画被破坏了。
Before (return layoutAttributes.filter... ) |
After (return shrinkingEdgeCellAttributes(in: rect) ) |
---|---|
我该如何修复这个动画?我是否应该使用自定义 UICollectionViewTransitionLayout
,如果是,如何使用?
哇!这是一次锻炼。我能够修改您的 FlowLayout
以便动画中没有问题。见下文。
有效!
问题
这就是正在发生的事情。当您更改布局时,如果集合视图的内容偏移不是 (0, 0)
.
FlowLayout
中的 layoutAttributesForElements
方法将被调用两次
这是因为您已经将 'shouldInvalidateLayout' 覆盖为 return true
而不管它是否真的需要。我相信 UICollectionView
在布局更改之前和之后在布局上调用此方法(根据观察)。
这样做的副作用是您的缩放变换应用了两次——在动画之前和之后应用到可见的布局属性。
不幸的是,缩放变换是基于集合视图的 contentOffset
(link)
let visibleRect = CGRect(
origin: collectionView.contentOffset,
size: collectionView.frame.size
)
在布局更改期间,contentOffset
不一致。动画开始前 contentOffset
适用于之前的布局。动画之后,是相对于新布局的。在这里我还注意到,没有充分的理由,contentOffset 会“跳跃”(见注释 1)
由于您使用 visibleRect 查询要应用比例的布局属性集,因此会引入更多错误。
解决方案
我能够通过应用这些更改找到解决方案。
- 编写辅助方法,将前一个布局留下的内容偏移量(和依赖的 visibleRect)转换为对此布局有意义的值。
- 防止在
prepare
方法中计算冗余布局属性 - 跟踪布局何时和何时不动画
// In Flow Layout
class FlowLayout: UICollectionViewFlowLayout {
var animating: Bool = false
// ...
}
// In View Controller,
isExpanded.toggle()
if isExpanded {
listLayout.reset()
listLayout.animating = true // <--
// collectionView.setCollectionViewLayout(listLayout)
} else {
stripLayout.reset()
stripLayout.animating = true // <--
// collectionView.setCollectionViewLayout(stripLayout)
}
- 重写
targetContentOffset
方法来处理内容偏移变化(防止跳转)
// In Flow Layout
class FlowLayout: UICollectionViewFlowLayout {
var animating: Bool = false
var layoutType: LayoutType
// ...
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
guard animating else {
// return super
}
// Use our 'graceful' content content offset
// instead of arbitrary "jump"
switch(layoutType){
case .list: return transformCurrentContentOffset(.fromStripToList)
case .strip: return transformCurrentContentOffset(.fromListToStrip)
}
}
// ...
内容偏移量变换的实现如下
/**
Transforms this layouts content offset, to the other layout
as specified in the layout transition parameter.
*/
private func transformCurrentContentOffset(_ transition: LayoutTransition) -> CGPoint{
let stripItemWidth: CGFloat = 100.0
let listItemHeight: CGFloat = 50.0
switch(transition){
case .fromStripToList:
let numberOfItems = collectionView!.contentOffset.x / stripItemWidth // from strip
var newPoint = CGPoint(x: 0, y: numberOfItems * CGFloat(listItemHeight)) // to list
if (newPoint.y + collectionView!.frame.height) >= contentSize.height{
newPoint = CGPoint(x: 0, y: contentSize.height - collectionView!.frame.height)
}
return newPoint
case .fromListToStrip:
let numberOfItems = collectionView!.contentOffset.y / listItemHeight // from list
var newPoint = CGPoint(x: numberOfItems * CGFloat(stripItemWidth), y: 0) // to strip
if (newPoint.x + collectionView!.frame.width) >= contentSize.width{
newPoint = CGPoint(x: contentSize.width - collectionView!.frame.width, y: 0)
}
return newPoint
}
}
我在评论中遗漏了一些小细节,作为对 OP 演示项目的拉取请求,因此任何有兴趣的人都可以研究它。
关键 take-aways 是,
当内容偏移发生任意变化以响应布局变化时使用
targetContentOffset
。注意
layoutAttributesForElements
布局属性查询错误。调试你的直肠!记得在
prepare()
方法中清除缓存的布局属性。
注释
“跳跃”行为甚至在您引入 your gif.
中所见的缩放变换之前就已经很明显了如果回答冗长,我深表歉意。或者,解决方案并不是您想要的。这个问题很有趣,这就是为什么我花了一整天的时间来寻找帮助的方法。
Fork and Pull request.
感谢您的详细调查@Thisura Dodangoda – 它帮助我解决了类似的问题。对于最终来到这里的人,我想添加一个小细节,以防你 运行 进入我做的另一个问题。 UICollectionViewLayout API 有两个非常相似的方法:
func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint
此方法检索停止滚动的点 和
func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint
此方法检索内容偏移以在动画布局更新或更改后使用
我已经为滚动期间的一些自定义行为实现了第一个,并且我试图在该方法中实现@Thisura Dodangoda 发布的解决方案。 但是,它们用于完全不同的目的。您需要使用第二种方法(不带velocity参数)来实现布局变化的方案。