I am trying to apply a kind of reverse-parallax effect on the first cell in my collectionview by overriding the layoutAttributesForElements(in rect:) method on UICollectionViewCompositionalLayout, calling the super implementation to obtain the default values then manipulating the frame of only the item at indexPath (0,0).
class ScrollingCardCollectionViewLayout: UICollectionViewCompositionalLayout {
override func shouldInvalidateLayout(forBoundsChange _: CGRect) -> Bool {
return true
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attrs = super.layoutAttributesForElements(in: rect)
guard let attrs = attrs, let collectionView = collectionView else { return attrs }
if let attr = attrs.first(where: { $0.representedElementCategory == .cell && $0.indexPath.section == 0 && $0.indexPath.item == 0 }) {
let offset = collectionView.contentOffset
let inset = collectionView.contentInset
let adjustedOffset = offset.y + inset.top
attr.frame.origin.y = adjustedOffset / 3 * 2
}
}
}
This works perfectly when scrolling slowly, however if you scroll quickly it the scrollview seems to lose track of its contentOffset and jerks back 3-5 times. This occurs both when both panning to scroll or 'flicking' to allow it to animate / decelerate. It also starts working perfectly once you have scrolled to the bottom of the content at least once (i.e. once the layout has established the heights of all the index paths).
I am using a vertical UICollectionViewCompositionalLayout with fixed full-screen width and dynamically sized (height) cells.
Full sample project available here
I've also tried setting an affine transform translation on the layout attributes which yielded the same results. Is this an iOS bug or am I doing something I shouldn't be?
Related
I'm facing a weird issue that I can't seem to figure out or find anything about online.
So I'm trying to replicate the Shazam discover UI with a UICollectionView and a custom UICollectionViewFlowlayout.
So far everything is working pretty well, but when adding the "card stack" effect I (or rather the person who was implementing it) noticed there seems to be a weird issue where on some occasions (or rather, when specific indexes are visible, in the example it's row 5, 9) there will be 4 visible cells instead of 3. My guess would be that this has something to do with cell reuse, but I'm not sure why it's doing this. I looked into the individual cell dimensions and they all seem to be the same so it's not that cells are just sized differently.
Does anyone have an idea as to why this could be happening? Any help or suggestions are really appreciated.
I'll add a code snippet of the custom flowlayout and screenshots below.
You can download the full project here, or alternatively, check out the PR on Github.
Here's a visual comparison:
Source code of the custom flowlayout:
import UIKit
/// Custom `UICollectionViewFlowLayout` that provides the flowlayout information like paging and `CardCell` movements.
internal class VerticalCardSwiperFlowLayout: UICollectionViewFlowLayout {
/// This property sets the amount of scaling for the first item.
internal var firstItemTransform: CGFloat?
/// This property enables paging per card. The default value is true.
internal var isPagingEnabled: Bool = true
/// Stores the height of a CardCell.
internal var cellHeight: CGFloat!
internal override func prepare() {
super.prepare()
assert(collectionView!.numberOfSections == 1, "Number of sections should always be 1.")
assert(collectionView!.isPagingEnabled == false, "Paging on the collectionview itself should never be enabled. To enable cell paging, use the isPagingEnabled property of the VerticalCardSwiperFlowLayout instead.")
}
internal override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let items = NSMutableArray (array: super.layoutAttributesForElements(in: rect)!, copyItems: true)
items.enumerateObjects(using: { (object, index, stop) -> Void in
let attributes = object as! UICollectionViewLayoutAttributes
self.updateCellAttributes(attributes)
})
return items as? [UICollectionViewLayoutAttributes]
}
// We invalidate the layout when a "bounds change" happens, for example when we scale the top cell. This forces a layout update on the flowlayout.
internal override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
// Cell paging
internal override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
// If the property `isPagingEnabled` is set to false, we don't enable paging and thus return the current contentoffset.
guard isPagingEnabled else {
let latestOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
return latestOffset
}
// Page height used for estimating and calculating paging.
let pageHeight = cellHeight + self.minimumLineSpacing
// Make an estimation of the current page position.
let approximatePage = self.collectionView!.contentOffset.y/pageHeight
// Determine the current page based on velocity.
let currentPage = (velocity.y < 0.0) ? floor(approximatePage) : ceil(approximatePage)
// Create custom flickVelocity.
let flickVelocity = velocity.y * 0.4
// Check how many pages the user flicked, if <= 1 then flickedPages should return 0.
let flickedPages = (abs(round(flickVelocity)) <= 1) ? 0 : round(flickVelocity)
// Calculate newVerticalOffset.
let newVerticalOffset = ((currentPage + flickedPages) * pageHeight) - self.collectionView!.contentInset.top
return CGPoint(x: proposedContentOffset.x, y: newVerticalOffset)
}
internal override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
// make sure the zIndex of the next card is higher than the one we're swiping away.
let nextIndexPath = IndexPath(row: itemIndexPath.row + 1, section: itemIndexPath.section)
let nextAttr = self.layoutAttributesForItem(at: nextIndexPath)
nextAttr?.zIndex = nextIndexPath.row
// attributes for swiping card away
let attr = self.layoutAttributesForItem(at: itemIndexPath)
return attr
}
/**
Updates the attributes.
Here manipulate the zIndex of the cards here, calculate the positions and do the animations.
- parameter attributes: The attributes we're updating.
*/
fileprivate func updateCellAttributes(_ attributes: UICollectionViewLayoutAttributes) {
let minY = collectionView!.bounds.minY + collectionView!.contentInset.top
let maxY = attributes.frame.origin.y
let finalY = max(minY, maxY)
var origin = attributes.frame.origin
let deltaY = (finalY - origin.y) / attributes.frame.height
let translationScale = CGFloat((attributes.zIndex + 1) * 10)
// create stacked effect (cards visible at bottom
if let itemTransform = firstItemTransform {
let scale = 1 - deltaY * itemTransform
var t = CGAffineTransform.identity
t = t.scaledBy(x: scale, y: 1)
t = t.translatedBy(x: 0, y: (translationScale + deltaY * translationScale))
attributes.transform = t
}
origin.x = (self.collectionView?.frame.width)! / 2 - attributes.frame.width / 2 - (self.collectionView?.contentInset.left)!
origin.y = finalY
attributes.frame = CGRect(origin: origin, size: attributes.frame.size)
attributes.zIndex = attributes.indexPath.row
}
}
edit 1: Just as an extra clarification, the final end result should make it look something like this:
edit 2:
Seems to be happening every 4-5 cards you scroll from my testing.
You have a layout that inherits from flow layout. You overrode layoutAttributesForElements(in rect:) where you take all the elements from super.layoutAttributesForElements and then for each one modify the properties in the method updateCellAttributes.
This is generally a good way to make a subclass of a flow layout. The UICollectionViewFlowLayout is doing most of the hard work - figuring out where each element should be, which elements are in the rect, what their basically attributes are, how they should be padded, etc, and you can just modify a few properties after the "hard" work is done. This works fine when you are adding a rotation or opacity or some other feature that does not change the location of the item.
You get into trouble when you change the items frame with updateCellAttributes. Then you can have a situation when you have a cell that would not have appeared in the frame at all for the regular flow layout, but now SHOULD appear because of your modification. So the attribute is not being returned AT ALL by super.layoutAttributesForElements(in rect: CGRect) so they don't show up at all. You can also have the opposite problem that cells that should not be in the frame at all are in the view but transformed in a way that cannot be seen by the user.
You haven't explained enough of what effect you are trying to do and why you think inheriting from UIFlowLayout is correct for me to be able to specifically help you. But I hope that I have given you enough information that you can find the problem on your own.
The bug is on how you defined frame.origin.y for each attribute. More specifically the value you hold in minY and determines how many of the cells you're keeping on screen. (I will edit this answer and explain more later but for now, try replacing the following code)
var minY = collectionView!.bounds.minY + collectionView!.contentInset.top
let maxY = attributes.frame.origin.y
if minY > attributes.frame.origin.y + attributes.bounds.height + minimumLineSpacing + collectionView!.contentInset.top {
minY = 0
}
I have a custom UICollectionView layout that resizes when the user scrolls. As the header shrinks at one point it begins to flicker.
I'm guessing the issue is that when the header shrinks the collection view thinks it's out of frame and perhaps dequeues it but then it calculates that it is in frame and re-queues it which might be what's causing the flicker.
class CustomLayout: UICollectionViewFlowLayout, UICollectionViewDelegateFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttributes = super.layoutAttributesForElements(in: rect) as! [UICollectionViewLayoutAttributes]
let offset = collectionView!.contentOffset ?? CGPoint.zero
let minY = -sectionInset.top
if (offset.y >= minY) {
let setOffset = fabs(170 - minY)
let extraOffset = fabs(offset.y - minY)
if offset.y <= 170 {
for attributes in layoutAttributes {
if let elementKind = attributes.representedElementKind {
if elementKind == UICollectionElementKindSectionHeader {
var frame = attributes.frame
frame.size.height = max(minY, headerReferenceSize.height - (extraOffset * 1.25))
frame.origin.y = frame.origin.y + (extraOffset * 1.25)
attributes.frame = frame
}
}
}
} else {
for attributes in layoutAttributes {
if let elementKind = attributes.representedElementKind {
if elementKind == UICollectionElementKindSectionHeader {
var frame = attributes.frame
frame.size.height = max(minY, headerReferenceSize.height - (setOffset * 1.25))
frame.origin.y = frame.origin.y + (setOffset * 1.25)
attributes.frame = frame
}
}
}
}
}
return layoutAttributes
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
}
Here is a gif showing the behavior. Notice how it starts out fine and begins to flicker. Also fast scrolling has an undesired effect.
Any suggestions?
I don't think your issue is related to the layout code. I copied and tried using your CustomLayout in a simple sample app with no obvious flickering issues.
Other things to try:
Make sure your collectionView(viewForSupplementaryElementOfKind:at:) function properly reuses the header view, using collectionView.dequeueReusableSupplementaryView(ofKind:withReuseIdentifier:for:)). Creating a new header view each time could cause substantial delays.
Do you have any complex drawing code in the cell itself?
Worst case, you could try profiling the app using Instruments Time Profiler to see what operations are taking up the most CPU cycles (assuming dropped frames are your issue).
Looks like that your collection view moves the header to the back.
Try insert this in code where you're changing frame of the header:
collectionView.bringSubview(toFront: elementKind)
I have a collectionView and the direction is set to Horizontal and every cell just contain a UIlabel and it's using autolayout to determine the width of the cell.
for example, there are three cells and it's default behaviour.
|[x] [xxx] [xxxxxxxxx] |
but the requirement is to center align these three cells if the total width of all cells doesn't exceed the width of the screen.
the left inset for the first cell should be
leftInset = (screen width - (first cell width + second cell width + third cell width + total spaces between the three cells) /) 2
the result would be
| [x] [xxx] [xxxxxxxxx] |
I have found a great answer for the cells with fixed width from How to center horizontally UICollectionView Cells?, but doesn't work on self-size one.
would be much appreciated if you would help.
Really annoying, that Apple doesn't provide ready-to-use layouts for such things...
In the end I came up with following flow layout:
class HorizontallyCenteredCollectionViewFlowLayout: UICollectionViewFlowLayout {
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.scrollDirection = .horizontal
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let attributes = super.layoutAttributesForElements(in: rect) else { return nil }
guard let collectionView = self.collectionView,
let rightmostEdge = attributes.map({ $0.frame.maxX }).max() else { return attributes }
let contentWidth = rightmostEdge + self.sectionInset.right
let margin = (collectionView.bounds.width - contentWidth) / 2
if margin > 0 {
let newAttributes: [UICollectionViewLayoutAttributes]? = attributes
.compactMap {
let newAttribute = $0.copy() as? UICollectionViewLayoutAttributes
newAttribute?.frame.origin.x += margin
return newAttribute
}
return newAttributes
}
return attributes
}
}
This basically consists of the following steps:
calculate the contentWidth
calculate the (left+right) margin between the contentWidth and the collectionViews width
apply the margin if necessary*, which leads to a entering of the content
* Negative margin means the content is bigger than the collectionView and the attributes should therefore stay unchanged => scrollable and left aligned as one would expect.
I want the items of one section in a UICollectionView to remain stationary while the rest of the items inside the UICollectionView are being scrolled.
I tried to achieve this by setting Autolayout constraint that pin the items to the superview of the UICollectionView. However, this does not seem to work because the constraints complain about UICollectionViewCell and the UICollectionView's superview not having a common ancestor.
Is there any other way to achieve it?
Thanks to Ad-J's comment I was able to implement the solution.
I needed to override UICollectionViewFlowLayout and implement the following methods:
override func prepareLayout() {
super.prepareLayout()
//fill layoutInfo of type [NSIndexPath:UICollectionViewLayoutAttributes]
//with layoutAttributes you need in your layout
if let cv = self.collectionView {
for (indexPath, tileToFloat) in layoutInfo {
if indexPath.section == 0 {
var origin = tileToFloat.frame.origin
origin.y += cv.contentOffset.y + cv.contentInset.top
tileToFloat.frame = CGRect(origin: origin, size: tileToFloat.size)
}
tileToFloat.zIndex = 1
}
}
}
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
return true
}
This will make all items in the first section stationary.
Context: I am using an UICollectionView for a photoview. Every picture is a cell with one UIImage. Images can have different sizes and I want them to fill the whole screen.
So I wrote a class who determines the frame of every single UICollectionCell and let a subclass of UICollectionViewFlowLayout ask that class for the right frame for every item.
My implementation of the UICollectionViewFlowLayoutClass:
override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
let attributesToReturn = super.layoutAttributesForElementsInRect(rect) as? [UICollectionViewLayoutAttributes]
for attributes in attributesToReturn ?? [] {
if attributes.representedElementCategory == .Cell {
let frame = self.layoutAttributesForItemAtIndexPath(attributes.indexPath).frame
attributes.size = frame.size
attributes.frame = frame
}
}
return attributesToReturn
}
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes! {
let curAttributes = super.layoutAttributesForItemAtIndexPath(indexPath)
let frame = mazeManager.sizeForItemAtIndex(indexPath, collectionView: collectionView!)
curAttributes.size = frame.size
curAttributes.frame = frame
return curAttributes
}
So the frame asks my MazeManager to give back a frame. The returned frames seem to be correct and they all fit in the UICollectionView.
When I open my app everything looks fine, even when I scroll. But when I scroll to a specific position (this position feels random because it depends on the images I test with, but with the same set of images the positions are the same) cells disappear from my view. When I scroll back they return.
I've checked if the cells where not hidden, but they never are.
On some other threads with similar issues the answer is to implement the collectionViewContentSize so I did:
override func collectionViewContentSize() -> CGSize {
let size = mazeManager.collectionViewContentSize
return size.height < collectionView!.frame.size.height ? collectionView!.frame.size : size
}
The number of items is not static and it grows while reaching the end of the view. So what happens here is:
The manager determines the Origin.y + the Size.Height of the last item (+ 10 points to be sure), the width is the width of the UICollectionView.
Still all the frames of the cells are within the sizes returned by this method. But still some cell disappear or never appear at all.
When I scroll further through the UICollectionView I see other cells which are positioned on the right place. So there are gaps in my view, but the flow continues. (When a gap appears there are no calls for the items at the missing idexpaths in the collectionViewDelegate. For example the CollectionView asks for items: 1,2,3,4,5,6, 12,13,14).
The console prints nothing about wrong positioning, I've checked and everything is within the ContentSize. So I'm almost out of options. Can anybody explain what's happening in my case?
Thank you.
Edit:
While I was looking for a solution, I already found the mentioned post (UICollectionView's cell disappearing) and I already did 3 of the 4 steps.
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
return true
}
And I just added the scroll direction in the initializer:
override init(){
super.init()
scrollDirection = .Vertical
}
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.scrollDirection = .Vertical
}
Unfortunately this doesn't fix my issue, so it doesn't seem a duplicate to me.
I don't know why, but it works when I add the following line in my init methods:
self.itemSize = CGSize(width: 165,height: 165)
165 is the average height for my cells (I need to make this less static). The size I specified here seems to be ignored, because the sizes I see on my screen are all calculated in the layoutAttributesForItemAtIndexPath.
So without this property set the view behaves strange, I still don't know the reason, but I'm glad it works. (If anyone knows the reason, I would like to hear it)