Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

UICollectionView scrollToItem() scrolling to previous cell not working correctly

I've been breaking my head over this for the past few weeks and I'm out of ideas..

Short background

I built my own UICollectionView framework with a custom UICollectionViewFlowLayout for vertical card paging and creating a card stack effect.

Here's a gif of what it looks like:

framework gif example

Problem

I'm trying to implement a feature that allows the user to scroll to a specific card at a specific index (using the scrollToItem function for example).

Now the problem is that for some reason, when trying to scroll back to the previous card it doesn't work correctly.

Below is a recording of what happens, I've attached a tapGestureRecognizer to it, when I tap the focussed (currently centered) card, it should scroll to that index - 1 (so the previous card).
For some reason it only does this once the frame.origin.y of the bottom card is above the frame.origin.maxY of the focussed (currently centered card).

Problem demo gif

Notice that I'm tapping twice on the card to make it work, because the frame.origin.y value of the bottom card needs to be lower than the frame.origin.maxY of the top card for some reason for it to work.

Basic answers I've already tried so far

collectionView.scrollToItem(at: convertIndexToIndexPath(for: index), at: .top, animated: animated)

and

if let frame = collectionView.layoutAttributesForItem(at: convertIndexToIndexPath(for: index))?.frame {
    collectionView.scrollRectToVisible(frame, animated: true)
}

Some important stuff to know

I've already figured out which line is causing the issue, inside the subclass of UICollectionViewFlowLayout (called VerticalCardSwiperFlowLayout), there is a function called updateCellAttributes, which modifies some properties of the frames to be able to achieve this effect. The issue occurs on the following line:

let finalY = max(cvMinY, cardMinY)

Here's the function in question including a very extended explanation of how it works at the top in code comments:

/**
 Updates the attributes.
 Here manipulate the zIndex of the cards here, calculate the positions and do the animations.
 
 Below we'll briefly explain how the effect of scrolling a card to the background instead of the top is achieved.
 Keep in mind that (x,y) coords in views start from the top left (x: 0,y: 0) and increase as you go down/to the right,
 so as you go down, the y-value increases, and as you go right, the x value increases.
 
 The two most important variables we use to achieve this effect are cvMinY and cardMinY.
 * cvMinY (A): The top position of the collectionView + inset. On the drawings below it's marked as "A".
 This position never changes (the value of the variable does, but the position is always at the top where "A" is marked).
 * cardMinY (B): The top position of each card. On the drawings below it's marked as "B". As the user scrolls a card,
 this position changes with the card position (as it's the top of the card).
 When the card is moving down, this will go up, when the card is moving up, this will go down.
 
 We then take the max(cvMinY, cardMinY) to get the highest value of those two and set that as the origin.y of the card.
 By doing this, we ensure that the origin.y of a card never goes below cvMinY, thus preventing cards from scrolling upwards.
 
 ```
 +---------+   +---------+
 |         |   |         |
 | +-A=B-+ |   |  +-A-+  | ---> The top line here is the previous card
 | |     | |   | +--B--+ |      that's visible when the user starts scrolling.
 | |     | |   | |     | |
 | |     | |   | |     | |  |  As the card moves down,
 | |     | |   | |     | |  v  cardMinY ("B") goes up.
 | +-----+ |   | |     | |
 |         |   | +-----+ |
 | +--B--+ |   | +--B--+ |
 | |     | |   | |     | |
 +-+-----+-+   +-+-----+-+
 ```
 
 - parameter attributes: The attributes we're updating.
 */
fileprivate func updateCellAttributes(_ attributes: UICollectionViewLayoutAttributes) {
    
    guard let collectionView = collectionView else { return }
    
    var cvMinY = collectionView.bounds.minY + collectionView.contentInset.top
    let cardMinY = attributes.frame.minY
    var origin = attributes.frame.origin
    let cardHeight = attributes.frame.height
            
    if cvMinY > cardMinY + cardHeight + minimumLineSpacing + collectionView.contentInset.top {
        cvMinY = 0
    }
    
    let finalY = max(cvMinY, cardMinY)
    
    let deltaY = (finalY - cardMinY) / cardHeight
    transformAttributes(attributes: attributes, deltaY: deltaY)
    
    // Set the attributes frame position to the values we calculated
    origin.x = collectionView.frame.width/2 - attributes.frame.width/2 - collectionView.contentInset.left
    origin.y = finalY
    attributes.frame = CGRect(origin: origin, size: attributes.frame.size)
    attributes.zIndex = attributes.indexPath.row
}

// Creates and applies a CGAffineTransform to the attributes to recreate the effect of the card going to the background.
fileprivate func transformAttributes(attributes: UICollectionViewLayoutAttributes, deltaY: CGFloat) {
    
    let translationScale = CGFloat((attributes.zIndex + 1) * 10)
    
    if let itemTransform = firstItemTransform {
        let scale = 1 - deltaY * itemTransform
        
        var t = CGAffineTransform.identity
        
        t = t.scaledBy(x: scale, y: 1)
        if isPreviousCardVisible {
            t = t.translatedBy(x: 0, y: (deltaY * translationScale))
        }
        attributes.transform = t
    }
}

Here's the function that calls that specific code:

internal override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    
    let items = NSArray(array: super.layoutAttributesForElements(in: rect)!, copyItems: true)
    for object in items {
        if let attributes = object as? UICollectionViewLayoutAttributes {
            self.updateCellAttributes(attributes)
        }
    }
    return items as? [UICollectionViewLayoutAttributes]
}

If you want to have a look for yourself, you can download the full project here: https://github.com/JoniVR/VerticalCardSwiper/archive/master.zip

(if you do checkout through github, make sure to download the development branch)

The specific Github issue with some discussion and possibly extra information can be found here.

Thank you for your time, any help or information with this problem would be deeply appreciated! Also, please let me know if you have any further question or if any crucial information is missing

edit 1: Note: The weird effect only happens when scrolling to currentIndex - 1, currentIndex + 1 doesn't give any issues, which I think somewhat explains why it's happening on let finalY = max(cvMinY, cardMinY), but I haven't figured out a proper solution for it yet.

like image 322
JoniVR Avatar asked Jan 04 '19 20:01

JoniVR


1 Answers

I've managed to get it to work another way, by using setContentOffset and calculating the offset manually.

guard index >= 0 && index < verticalCardSwiperView.numberOfItems(inSection: 0) else { return }

let y = CGFloat(index) * (flowLayout.cellHeight + flowLayout.minimumLineSpacing) - topInset
let point = CGPoint(x: verticalCardSwiperView.contentOffset.x, y: y)
verticalCardSwiperView.setContentOffset(point, animated: animated)

verticalCardSwiperView is basically just a subclass of UICollectionView, I'm doing this because I want to narrow down what a user can access since this is a specific library and allowing full collectionView access would make things confusing.

If you do happen to know what exactly is causing this issue or how I can fix it better, I'd still be happy to find out :)

like image 180
JoniVR Avatar answered Nov 15 '22 20:11

JoniVR