How to change UICollectionViewCell initial layout position? - ios

Background
So, I'm working on a custom framework, and I've implemented a custom UICollectionViewFlowLayout for my UICollectionView.
The implementation allows you to scroll through the card stack while also swiping cards (cells) to the left/right (Tinder + Shazam Discover combo).
I'm modifying the UICollectionViewLayoutAttributes to create a scrolling card stack effect.
The Problem
At the end of the stack, when I swipe away a card (cell), the new cards don't appear from behind the stack, but from the top instead. This only happens at the end of the stack, and I have no idea why.
What I think - What I've tried
My guess is that I need to modify some things in the initialLayoutAttributesForAppearingItem, and I've tried that, but it doesn't seem to do anything.
I'm currently calling the updateCellAttributes function inside it to update the attributes, but I've also tried manually modifying the attributes inside of it. I'm really not seeing the issue here, unless there's a different way to modify how a card is positioned for this case.
Could it perhaps be that because the cells are technically not in the "rect" yet (see layoutAttributesForElements(in rect: CGRect)), they are not updated?
Is there anything I'm missing?
Is anyone more familiar with how I can modify the flowlayout to achieve my desired behaviour?
Examples and code
Here's a gif of it in action:
Here's a gif of the bug I'm trying to solve:
As you can see, when swiping away the last card, the new card appears from the top, while it should appear from behind the previous card instead.
Below you can find the custom UICollectionViewFlowLayout code.
The most important function is the updateCellAttributes one,
which is well documented with inline comments (see code below).
This function is called from:
initialLayoutAttributesForAppearingItem
finalLayoutAttributesForDisappearingItem
layoutAttributesForItem
layoutAttributesForElements
To modify the layout info and create the stack effect.
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. Default is true.
internal var isPagingEnabled: Bool = true
/// Stores the height of a CardCell.
internal var cellHeight: CGFloat!
/// Allows you to make the previous card visible or not visible (stack effect). Default is `true`.
internal var isPreviousCardVisible: Bool = true
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 = 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]
}
internal override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
if self.collectionView?.numberOfItems(inSection: 0) == 0 { return nil }
if let attr = super.layoutAttributesForItem(at: indexPath)?.copy() as? UICollectionViewLayoutAttributes {
self.updateCellAttributes(attr)
return attr
}
return nil
}
internal override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
// attributes for swiping card away
return self.layoutAttributesForItem(at: itemIndexPath)
}
internal override func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
// attributes for adding card
return self.layoutAttributesForItem(at: itemIndexPath)
}
// 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 let collectionView = self.collectionView, 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 = 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) - collectionView.contentInset.top
return CGPoint(x: proposedContentOffset.x, y: newVerticalOffset)
}
/**
Updates the attributes.
Here manipulate the zIndex of the cells 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.
*/
private 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.
private func transformAttributes(attributes: UICollectionViewLayoutAttributes, deltaY: CGFloat) {
if let itemTransform = firstItemTransform {
let scale = 1 - deltaY * itemTransform
let translationScale = CGFloat((attributes.zIndex + 1) * 10)
var t = CGAffineTransform.identity
t = t.scaledBy(x: scale, y: 1)
if isPreviousCardVisible {
t = t.translatedBy(x: 0, y: (deltaY * translationScale))
}
attributes.transform = t
}
}
}
Full project zip (instant download)
Github repo
Github issue
If you have any further questions I'd gladly answer them.
Thank you for your time and effort, your help would be deeply appreciated!

It seems that after removing last cell we got two animations happens simultaneously. Content offet (because of content size change) changes with animation and new last cell goes to its new position. But new visible cell already at its position. Its a pity but I dont see the quick way to fix this.

First of all you should understand that super.layoutAttributesForElements(in: rect) will return only cells which are visible in standard FlowLayout. Thats why you can see how card under top card disappears when you bounce UICollectionView on bottom. Thats why you should manage attributes on your own. I mean copy all attributes in prepare() or even create them. Another problem was described by #team-orange. He is correct UIKit's animation classes handle this as simple animation and in your logic you calculate cell's positions based on current contentOffset which is already changed in animation block. I'm not sure what you can really do here, maybe you can implement it on your side by settings updated attributes for all cells, but even with isHidden = true it will decrease performance.
<VerticalCardSwiper.VerticalCardSwiperView: 0x7f9a63810600; baseClass = UICollectionView; contentOffset: {-20, 13636}; contentSize: {374, 14320}; adjustedContentInset: {40, 20, 124, 20}>
<VerticalCardSwiper.VerticalCardSwiperView: 0x7f9a63810600; baseClass = UICollectionView; contentOffset: {-20, 12918}; contentSize: {374, 14320}; adjustedContentInset: {40, 20, 124, 20}>

Related

Manipulating frame in layoutAttributesForElements(in rect:) causes contentOffset to jerk

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?

Flexible size UICollectionview Footer not working properly

I have been working on a UI screen where I need to fill up screen with fetched data from API and show view at very bottom of the screen.
My screen contains data in scrollable fashion so I have to use UICollectionView to manage this scrollable content properly.
i.e. If more content then footer will be shown at end of UICollectionView after whole list scroll is over, if less content then footer will be show at very bottom of the screen leaving blank space between footer and content.
Following is my code where I have dynamic flow layout which changes based on data I get.
API Function:
private func fetchEventDetails(_ eventId: Int) {
{
API Call - Success
}
self.contentItem = response
} onFailure: { error in
{
API Call - Failure
}
}
ContentItem is a variable stores API data and loads them into UICollectionView. So, whenever contentItem is set, new flowLayout is generated and Collection view is being re-rendered.
var contentItem: LSTEventDetails? {
didSet {
updateCollectionViewLayout()
}
}
private func updateCollectionViewLayout() {
CollectionView.collectionViewLayout = <generateNewFlowLayout()>
CollectionView.reloadData()
CollectionView.collectionViewLayout.invalidateLayout()
CollectionView.layoutIfNeeded()
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
}
Above function assigns new Flow layout to collection view and we have written resize footer view related function in viewDidLayoutSubviews, so, layout methods are called here. I don't know best place where to call this resizeFooter function which can be executed once data is loaded.
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if CollectionView.numberOfSections > 0 {
//DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.CollectionView.resizeStickyFooter(for: IndexPath(item: 0, section: 0), andSectionLayout: self.sectionLayouts, andView: self.view)
//}
}
}
In the above function, if I add asyncAfter I get some jerky animation, like all data is loaded and then after sometime, the footer jumps to correct position which seems a bit weird.
sectionLayouts holds reference to all NSCollectionLayoutSection sections which I create while generating flow layout.
The main resizeFooter function is added as extension to UICollectionView:
extension UICollectionView {
func resizeStickyFooter(for indexPath: IndexPath = IndexPath(item: 0, section: 0), andSectionLayout sectionLayouts: [NSCollectionLayoutSection]?, andView view: UIView) {
let size = self.bounds.size.height - self.contentInset.bottom//- (sectionLayouts?.reduce(0, { $0 + $1.contentInsets.top + $1.contentInsets.bottom }) ?? 0) // - LSTTheme.UI.TabBarSize - no need as tab is hidden
let layoutViewSize = self.collectionViewLayout.collectionViewContentSize
if layoutViewSize.height < size {
if let footerView = self.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath) {
let footerHeight = size - layoutViewSize.height
let footerSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(max(0, footerHeight) + footerView.bounds.height)
)
let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: footerSize,
elementKind: UICollectionView.elementKindSectionFooter,
alignment: .bottom
)
if (indexPath.section < (sectionLayouts?.count ?? 0)),
let layouts = sectionLayouts {
let section = layouts[indexPath.section]
section.boundarySupplementaryItems.removeAll(where: {$0.elementKind == UICollectionView.elementKindSectionFooter} )
section.boundarySupplementaryItems.append(sectionFooter)
let layout = UICollectionViewCompositionalLayout { sectionIndex, env in
return layouts[sectionIndex]
}
self.collectionViewLayout = layout
self.collectionViewLayout.invalidateLayout()
self.layoutIfNeeded()
}
}
}
}
}
Here, when this function gets executed at first time, I am not getting proper footer size and collectionViewContentSize.
Like, for the first time when this screen is loaded, I get following data listed on left-side while when I do some action on that particular screen by tapping button and which in turn calls updateCollectionViewLayout() again to create new flow layout and execute resize footer from viewDidLayoutSubviews(), I get following data listed on right-side.
size = 656 VS size = 656
layoutViewSize = (375.0, 414.0) | layoutViewSize = (375.0, 576.33)
- width : 375.0 | - width : 375.0
- height : 414.0 | - height : 576.33
|
footerView = (0 394; 375 20); | footerView = (0 499.333; 375 77);
- height : 20 | - height : 77
|
So, footerSize = {1, 262} | So, footerSize = {1, 156.66}
- height : 262 | - height : 156.66
Here, you will be able to see difference in layoutViewSize and footerView both which in turn affects footerSize.
Would anyone suggest me better option where to call resizeFooter method, so, I get proper contentViewSize once data is loaded? But I don't want that kind of jumpy solution where footer jumps to its original size post loading of all data.
ViewDidLoad() method just has call to fetchData API and nothing else, so, anything in viewDidLoad does not affect the collectionview.
I want it to be seamless so UX can not be affected.
Please provide me any suggestion.

iOS - Custom view: Intrinsic content size ignored after updated in layoutSubviews()

I have a tableview cell containing a custom view among other views and autolayout is used.
The purpose of my custom view is to layout its subviews in rows and breaks into a new row if the current subview does not fit in the current line. It kind of works like a multiline label but with views. I achieved this through exact positioning instead of autolayout.
Since I only know the width of my view in layoutSubviews(), I need to calculate the exact positions and number of lines there. This worked out well, but the frame(zero) of my view didn't match because of missing intrinsicContentSize.
So I added a check to the end of my calculation if my height changed since the last layout pass. If it did I update the height property which is used in my intrinsicContentSize property and call invalidateIntrinsicContentSize().
I observed that initially layoutSubviews() is called twice. The first pass works well and the intrinsicContentSize is taken into account even though the width of the cell is smaller than it should be. The second pass uses the actual width and also updates the intrinsicContentSize. However the parent(contentView in tableview cell) ignores this new intrinsicContentSize.
So basically the result is that the subviews are layout and drawn correctly but the frame of the custom view is not updated/used in parent.
The question:
Is there a way to notify the parent about the change of the intrinsic size or a designated place to update the size calculated in layoutSubviews() so the new size is used in the parent?
Edit:
Here is the code in my custom view.
FYI: 8 is just the vertical and horizontal space between two subviews
class WrapView : UIView {
var height = CGFloat.zero
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: height)
}
override func layoutSubviews() {
super.layoutSubviews()
guard frame.size.width != .zero else { return }
// Make subviews calc their size
subviews.forEach { $0.sizeToFit() }
// Check if there is enough space in a row to fit at least one view
guard subviews.map({ $0.frame.size.width }).max() ?? .zero <= frame.size.width else { return }
let width = frame.size.width
var row = [UIView]()
// rem is the remaining space in the current row
var rem = width
var y: CGFloat = .zero
var i = 0
while i < subviews.count {
let view = subviews[i]
let sizeNeeded = view.frame.size.width + (row.isEmpty ? 0 : 8)
let last = i == subviews.count - 1
let fit = rem >= sizeNeeded
if fit {
row.append(view)
rem -= sizeNeeded
i += 1
guard last else { continue }
}
let rowWidth = row.map { $0.frame.size.width + 8 }.reduce(-8, +)
var x = (width - rowWidth) * 0.5
for vw in row {
vw.frame.origin = CGPoint(x: x, y: y)
x += vw.frame.width + 8
}
y += row.map { $0.frame.size.height }.max()! + 8
rem = width
row = []
}
if height != y - 8 {
height = y - 8
invalidateIntrinsicContentSize()
}
}
}
After a lot of trying and research I finally solved the bug.
As #DonMag mentioned in the comments the new size of the cell wasn't recognized until a new layout pass. This could be verified by scrolling the cell off-screen and back in which showed the correct layout. Unfortunately it is harder than expected to trigger new pass as .beginUpdates() + .endUpdates()didn't
do the job.
Anyway I didn't find a way to trigger it but I followed the instructions described in this answer. Especially the part with the prototype cell for the height calculation provided a value which can be returned in tableview(heightForRowAt:).
Swift 5:
This is the code used for calculation:
let fitSize = CGSize(width: view.frame.size.width, height: .zero)
/* At this point populate the cell with the exact same data as the actual cell in the tableview */
cell.setNeedsUpdateConstraints()
cell.updateConstraintsIfNeeded()
cell.bounds = CGRect(x: .zero, y: .zero, width: view.frame.size.width, height: cell.bounds.height)
cell.setNeedsLayout()
cell.layoutIfNeeded()
height = headerCell.contentView.systemLayoutSizeFitting(fitSize).height + 1
The value is only calculated once and the cached as the size doesn't change anymore in my case.
Then the value can be returned in the delegate:
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
indexPath.row == 0 ? height : UITableView.automaticDimension
}
I only used for the first cell as it is my header cell and there is only one section.

UICollectionview custom layout: some indexes have more visible cells than others?

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 want to make effect similar to resizing top view on contacts app?

Native contacts app has interesting effect - when user tries to scroll, the scroll view pushes the top view with avatar and only when top view is in "small mode" it is scrolled up.
I was able to resize view on scrolling, via didScroll method. But problem is, content offset is also changing while i push the top vied. In native contacts, content offset changes only when top view is in "small mode"
Any suggestions, how did they made this?
Here's a sample project of how you could do it:
https://github.com/lukaswuerzburger/resizeable-tableview-header
You simply have a view controller with a table view and a custom view (resizable header) in it. On scrollViewDidScroll you change the height constraint.
PS: The transitions between the master and detail view controller are not prefect, but maybe someone can help me with that. The change of navigationBar's backgroundImage and shadowImage doesn't really work animated when popping back to the master view controller.
The bounty answer is good, but it still gives somewhat choppy solution to this problem. If you want exacly like native this is your solution. I have been playing a lot lately with collection views, and have gained much more experience. One of the things that I found is that this problem can easily be solved with custom layout:
class CustomCollectionViewFlowLayout: UICollectionViewFlowLayout {
let minHeightForHeader: CGFloat = 50
let offsetFromTop: CGFloat = 20
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
// get current attributes
let attributes = super.layoutAttributesForSupplementaryView(ofKind: elementKind, at: indexPath)
// get first section
if let attributes = attributes, elementKind == UICollectionElementKindSectionHeader && indexPath.section == 0 {
if let collectionView = collectionView {
// now check for content offset
var frame = attributes.frame
let yOffset = collectionView.contentOffset.y
if yOffset >= -offsetFromTop {
let calculatedHeight = frame.size.height - (yOffset + offsetFromTop)
let maxValue = minHeightForHeader > calculatedHeight ? minHeightForHeader : calculatedHeight
frame.size.height = maxValue
attributes.frame = frame
}
else {
frame.origin.y = offsetFromTop + yOffset
attributes.frame = frame
}
}
return attributes
}
// default return
return attributes
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
}
Just replace your default layout with this custom class and add this line somewhere in your setup method
flowLayout.sectionHeadersPinToVisibleBounds = true
Voila!
EDIT: Just one more optional method for clipping part, when you scroll to certain part, scroll might continue or return:
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
if let collectionView = collectionView {
let yOffset = collectionView.contentOffset.y
if yOffset + offsetFromTop >= maxHeightForHeader / 2 && yOffset + offsetFromTop < maxHeightForHeader && !(velocity.y < 0) || velocity.y > 0{
return CGPoint.init(x: 0, y: maxHeightForHeader - minHeightForHeader - offsetFromTop)
}
if yOffset + offsetFromTop < maxHeightForHeader / 2 && yOffset + offsetFromTop > 0 || velocity.y < 0 {
return CGPoint.init(x: 0, y: -offsetFromTop)
}
}
return proposedContentOffset
}

Resources