I got confused by managing animation invisible cell in collectionView.
So I have stacking card collection view, when User pinch out the cell of collection will change the x coordinate. After changing the coordinate some of cell is outside screen.. When I want to get the cell by
collectionView?.cellForItem(at: indexPath)
returned nil.
See the green cell.
func animateExpand(cells: [UICollectionViewCell], coordinateY: [CGFloat]) {
UIView.animate(
withDuration: 0.5,
delay: 0,
options: .curveEaseOut,
animations: {
for (index, cell) in cells.enumerated() {
cell.frame.origin.y = coordinateY[index]
}
},
completion: { [weak self] (_: Bool) in
self?.invalidateLayout()
self?.isMoving = false
}
)
}
cells only return 4 because 1 cell is nil. How can I achive this animation? Thanks
CodePudding user response:
Couple issues you're hitting here.
First, you're using a custom collection view layout to position the cells, but then you're also explicitly setting the cell y-origins.
Second, a collection view will only render the visible cells, so when you try to "un-expand" the layout, the bottom cell(s) don't exist.
I'm going to suggest a slightly different approach.
Use a protocol/delegate pattern so your custom UICollectionViewLayout
can tell the controller to expand / collapse the layout when the pinch gesture occurs. The controller will then create a NEW instance of the custom layout and call .setCollectionViewLayout(...)
- wrapped in an animation block - to either expand or collapse.
In addition, the controller will temporarily extend the height of the collection view so the "off-screen" cells will be rendered.
Here's some example code - I really made very few changes to your existing custom layout. The comments I included should be enough to make things clear.
Note, though, that this is Example Code Only -- it has not been thoroughly test and is intended to be a starting point:
protocol PinchProtocol: AnyObject {
func toggleExpanded(_ expand: Bool)
}
class MyWalletVC: UIViewController, PinchProtocol {
var data: [UIColor] = [
.red, .green, .blue, .cyan, .magenta,
//.yellow, .orange, .systemYellow,
]
var collectionView: UICollectionView!
var cvBottom: NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
let lay = WalletStackLayout()
lay.isExpanded = false
lay.pinchDelegate = self
collectionView = UICollectionView(frame: .zero, collectionViewLayout: lay)
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
let g = view.safeAreaLayoutGuide
cvBottom = collectionView.bottomAnchor.constraint(equalTo: g.bottomAnchor)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
collectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
collectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
cvBottom,
])
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "c")
collectionView.dataSource = self
collectionView.delegate = self
}
func toggleExpanded(_ expand: Bool) {
// increase collection view height
// so "off-screen" cells will be rendered
// I just picked a value of 800 to make sure it's enough
self.cvBottom.constant = 800
UIView.animate(
withDuration: 0.5,
delay: 0,
options: .curveEaseOut,
animations: { [weak self] in
guard let self = self else { return }
// create a NEW layout object
let lay = WalletStackLayout()
// set its isExpanded property
lay.isExpanded = expand
// set self as its pinchDelegate
lay.pinchDelegate = self
// set the new layout
// use "animated: false" because we're animating it with UIView.animate
self.collectionView.setCollectionViewLayout(lay, animated: false)
},
completion: { [weak self] (_: Bool) in
guard let self = self else { return }
// reset collection view height
self.cvBottom.constant = 0
}
)
}
}
extension MyWalletVC: UICollectionViewDataSource, UICollectionViewDelegate {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return data.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let c = collectionView.dequeueReusableCell(withReuseIdentifier: "c", for: indexPath)
c.contentView.backgroundColor = data[indexPath.item]
c.contentView.layer.cornerRadius = 16
return c
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
print("Selected item from Tap gesture:", indexPath)
}
}
typealias CellAndLayoutAttributes = (cell: UICollectionViewCell, layoutAttributes: UICollectionViewLayoutAttributes)
class WalletStackLayout: UICollectionViewLayout {
// so we can tell the controller we got pinched
weak var pinchDelegate: PinchProtocol?
// expanded / collapsed layout
var isExpanded: Bool = false
private let heightRatio: CGFloat = 196/343
private let sidePadding: CGFloat = 16.0
private let peekStack: CGFloat = 40
private var cellWidth: CGFloat {
return UIScreen.main.bounds.width - sidePadding * 2.0
//return Device.screenWidth - sidePadding*2
}
private var cellHeight: CGFloat {
return heightRatio * cellWidth
}
private var isMoving: Bool = false
private var collectionLayoutAttributes: [UICollectionViewLayoutAttributes] = []
private var tapGestureRecognizer: UITapGestureRecognizer?
private var pinchGestureRecognizer: UIPinchGestureRecognizer?
// this is needed to keep the Top cell at the Top of the collection view
// when changing the layout
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
return .zero
}
override var collectionViewContentSize: CGSize {
guard let collectionView = collectionView, collectionView.numberOfSections > 0 else {
return CGSize(width: 0, height: 0)
}
var contentHeight: CGFloat = 0
for index in 0..<collectionView.numberOfSections {
contentHeight = calculateSectionCardHeight(section: index)
}
return CGSize(
width: collectionView.bounds.width,
height: contentHeight
)
}
override func prepare() {
super.prepare()
collectionLayoutAttributes.removeAll()
guard let collectionView = collectionView, collectionView.numberOfSections > 0 else {
return
}
initializeCardCollectionViewLayout()
collectionLayoutAttributes = makeCardsLayoutAttributes()
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let collectionView = collectionView, collectionView.numberOfSections > 0 else {
return nil
}
var visibleLayoutAttributes: [UICollectionViewLayoutAttributes] = []
var r = rect
r.size.height = 500
for attributes in collectionLayoutAttributes where attributes.frame.intersects(r) {
visibleLayoutAttributes.append(attributes)
}
return visibleLayoutAttributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return collectionLayoutAttributes[indexPath.row]
}
private func getCell(at indexPath: IndexPath) -> UICollectionViewCell? {
return collectionView?.cellForItem(at: indexPath)
}
private func getLayoutAttributes(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return collectionView?.layoutAttributesForItem(at: indexPath)
}
private func getCellAndLayoutAttributes(at indexPath: IndexPath) -> CellAndLayoutAttributes? {
guard let cell = getCell(at: indexPath),
let layoutAttributes = getLayoutAttributes(at: indexPath) else {
return nil
}
return (cell: cell, layoutAttributes: layoutAttributes)
}
// MARK: - BEGIN SET CARDS -
private func makeCardsLayoutAttributes() -> [UICollectionViewLayoutAttributes] {
guard let collectionView = collectionView, collectionView.numberOfSections > 0 else {
return []
}
var collectionViewLayout: [UICollectionViewLayoutAttributes] = []
for section in 0..<collectionView.numberOfSections {
for row in 0..<collectionView.numberOfItems(inSection: section) {
let indexPath = IndexPath(row: row, section: section)
collectionViewLayout.append(makeCardLayoutAttributes(forCellWith: indexPath))
}
}
return collectionViewLayout
}
private func makeInitialLayoutAttributes(forCellWith indexPath: IndexPath, height: CGFloat) -> UICollectionViewLayoutAttributes {
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
let preferredSize = CGSize(width: cellWidth, height: height)
attributes.size = preferredSize
return attributes
}
private func makeCardLayoutAttributes(forCellWith indexPath: IndexPath) -> UICollectionViewLayoutAttributes {
let attributes = makeInitialLayoutAttributes(forCellWith: indexPath, height: cellHeight)
let coordinateY = calculateSectionYCoordinate(indexPath: indexPath)
attributes.frame.origin.y = coordinateY
attributes.frame.origin.x = sidePadding
attributes.zIndex = indexPath.item
return attributes
}
private func calculateSectionYCoordinate(indexPath: IndexPath) -> CGFloat {
var sectionYCoordinate: CGFloat = 0
for section in 0..<indexPath.section {
sectionYCoordinate = calculateSectionCardHeight(section: section)
}
if isExpanded {
return (cellHeight sidePadding) * CGFloat(indexPath.row) sectionYCoordinate
} else {
return peekStack * CGFloat(indexPath.row) sectionYCoordinate
}
}
private func calculateSectionCardHeight(section: Int) -> CGFloat {
guard let numberOfItems = collectionView?.numberOfItems(inSection: section) else {
return 0
}
if isExpanded {
let totalExpandedCards: Int = numberOfItems
return (cellHeight sidePadding) * CGFloat(totalExpandedCards)
} else {
let visibleCardCount: Int = 1
let totalStackedCards: Int = numberOfItems > 1 ? numberOfItems - visibleCardCount : 0
return peekStack * CGFloat(totalStackedCards) cellHeight sidePadding
}
}
// MARK: - TAP GESTURE -
private func initializeCardCollectionViewLayout() {
if tapGestureRecognizer == nil {
tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGestureHandler))
if let tapGesture = tapGestureRecognizer {
collectionView?.addGestureRecognizer(tapGesture)
}
}
if pinchGestureRecognizer == nil {
pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(self.handlePinchGesture(_:)))
pinchGestureRecognizer?.delegate = self
if let pinchGesture = pinchGestureRecognizer {
collectionView?.addGestureRecognizer(pinchGesture)
}
}
}
}
extension WalletStackLayout: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
@objc private func handlePinchGesture(_ pinchGesture: UIPinchGestureRecognizer) {
if pinchGesture.state == .began || pinchGesture.state == .changed {
guard let collectionView = collectionView,
let tapLocation = pinchGestureRecognizer?.location(in: collectionView),
let indexPath = collectionView.indexPathForItem(at: tapLocation),
!isMoving else {
return
}
if pinchGesture.scale > 1 {
// tell the controller to switch to Expanded layout
pinchDelegate?.toggleExpanded(true)
} else if pinchGesture.scale < 1 {
// tell the controller to switch to Collapsed layout
pinchDelegate?.toggleExpanded(false)
}
}
}
@objc
private func tapGestureHandler() {
guard let collectionView = collectionView,
let tapLocation = tapGestureRecognizer?.location(in: collectionView),
let indexPath = collectionView.indexPathForItem(at: tapLocation) else {
return
}
print("TapGestureHandler Section: \(indexPath.section) Row: \(indexPath.row)")
collectionView.delegate?.collectionView?(collectionView, didSelectItemAt: indexPath)
}
}
No @IBOutlet
or @IBAction
connections needed. Just assign the custom class of a standard view controller to MyWalletVC
and it should run without problem.