Home > Mobile >  Invisible Cell CollectionView Animation
Invisible Cell CollectionView Animation

Time:03-14

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.

enter image description here

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.

  • Related