I set up a UICollectionView that has a following settings:
collectionView fits screen bounds
only vertical scroll is applied
most of cells fit to content's width
some of cells can change their heights on user interaction dynamically (animated)
It's pretty much like a UITableView, which works fine in most cases, except one specific situation when the animation doesn't apply.
Among stacked cells in collectionView, say one of the upper cells expands its height. Then the lower cell must be moving downwards to keep the distance. If this moving cell's target frame is out of collectionView's bounds, then no animation applies and the cell disappears.
Opposite case works the same way; if the lower cell's source frame is out of screen bounds (currently outside of the bounds) and the upper cell should shrink, no animation applies and it just appear on target frame.
This seems appropriate in memory management logic controlled by UICollectionView, but at the same time nothing natural to show users that some of contents just appear or disappear out of blue. I had tested this with UITableView and the same thing happens.
Is there a workaround for this issue?
You should add some code or at least a gif of your UI problem.
I tried to replicate your problem using a basic UICollectionViewLayout subclass :
protocol CollectionViewLayoutDelegate: AnyObject {
func heightForItem(at indexPath: IndexPath) -> CGFloat
}
class CollectionViewLayout: UICollectionViewLayout {
weak var delegate: CollectionViewLayoutDelegate?
private var itemAttributes: [UICollectionViewLayoutAttributes] = []
override func prepare() {
super.prepare()
itemAttributes = generateItemAttributes()
}
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
return collectionView?.contentOffset ?? .zero
return super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return itemAttributes.first { $0.indexPath == indexPath }
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return itemAttributes.filter { $0.frame.intersects(rect) }
}
private func generateItemAttributes() -> [UICollectionViewLayoutAttributes] {
var offset: CGFloat = 0
return (0..<numberOfItems()).map { index in
let indexPath = IndexPath(item: index, section: 0)
let frame = CGRect(
x: 0,
y: offset,
width: collectionView?.bounds.width ?? 0,
height: delegate?.heightForItem(at: indexPath) ?? 0
)
offset = frame.maxY
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = frame
return attributes
}
}
}
In a simple UIViewController, I reloaded the first cell each time a cell is selected:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
updatedIndexPath = IndexPath(item: 0, section: 0)
collectionView.reloadItems(at: [updatedIndexPath])
}
In that case, I faced an animation like this:
How to fix it ?
I think you could try to tweak the attributes returned by super.finalLayoutAttributesForDisappearingItem(at: itemIndexPath) computing its correct frame and play with the z-index.
But you could also simply try to invalidate all the layout like so:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
layout = CollectionViewLayout()
layout.delegate = self
collectionView.setCollectionViewLayout(layout, animated: true)
}
and override:
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
return collectionView?.contentOffset ?? .zero
}
to avoid a wrong target content offset computation when the layout is invalidated.
Related
I have a UICollectionView with horizontal scrolling and paging. The cells should be the screen size always, so when the orientation of the device changes there is the call collectionView.collectionViewLayout.invalidateLayout() and the item size and collection view offset get recalculated and it works well.
The issue occurs when going to another view controller, then rotating and going back to the view controller with the collection view. Then it looks like the collection view doesn't take into account the orientation change or something like this and there could be two cells visible, which should not happen. Also there is some weird animation when going back to the view controller with the collection view. How should these be fixed?
Here is some code:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
collectionView.collectionViewLayout.invalidateLayout()
coordinator.animate(alongsideTransition: { (context) in
}) { (context) in
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return collectionView.frame.size
}
func collectionView(_ collectionView: UICollectionView, targetContentOffsetForProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
let width = collectionView.frame.size.width
let visibleCells = collectionView.visibleCells
if visibleCells.count == 0 {
return CGPoint.zero
}
let cell = visibleCells[0]
let indexPath = collectionView.indexPath(for: cell)
let index = indexPath?.item
let offsetX = CGFloat(index!) * width
let offsetY: CGFloat = 0
let offset = CGPoint(x: offsetX, y: offsetY)
return offset
}
Trying to fix the issue I make the following calls in viewDidAppear but no luck:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
collectionView.collectionViewLayout.invalidateLayout()
collectionView.layoutSubviews()
}
I'm using a UICollectionView with a Flow Layout. I've set the header, which is a UICollectionReusableView to behave like so;
layout?.sectionHeadersPinToVisibleBounds = true
...
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionElementKindSectionHeader, withReuseIdentifier: "header", for: indexPath as IndexPath)
header.layer.zPosition = -1
return header
}
This gives the desired effect that when scrolling the cells up, the header stays pinned but goes behind the regular cells.
However, if I try to click a UICollectionViewCell that is scrolled toward the top, i.e. so it's technically covering the UICollectionReusableView, the UICollectionViewCell's didSelectItemAt tap event no longer fires until I scroll it back down away from where the header is. In other words, the UICollectionReusableView is blocking tap gestures, even though it's zPosition is set to -1 and isn't visible.
Has anyone ever had this issue and how did you fix it?
Adding this to your Section Header view class:
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return false
}
will pass all touches through to the next receiver below - in this case, your collection view cell. If you need an interactive element (such as a button) in the Section Header, you can do:
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let ptInSub = theButton.convert(point, from: theButton.superview)
if theButton.bounds.contains(ptInSub) {
return true
}
return false
}
This could give you what you want, although... if the Cell View is covering the Button on the Section Header, and you tap the cell where the button is, the button will take the tap. Should be able to get around that with another contains(point) or two...
I ended up adding a Parallax effect to my UICollectionReusableView header. There are a few Parallax libraries out there, but it's actually really simple - I achieved it using the following code;
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
if header != nil {
let scrollDiff = scrollView.contentOffset.y - self.previousScrollOffset
let absoluteTop: CGFloat = 0;
let absoluteBottom: CGFloat = scrollView.contentSize.height - scrollView.frame.size.height;
let isScrollingDown = scrollDiff > 0 && scrollView.contentOffset.y > absoluteTop
let isScrollingUp = scrollDiff < 0 && scrollView.contentOffset.y < absoluteBottom
var newHeight = self.headerHeight
if isScrollingDown {
newHeight = max(self.minHeaderHeight, self.headerHeight - abs(scrollDiff))
} else if isScrollingUp {
newHeight = min(self.maxHeaderHeight, self.headerHeight + abs(scrollDiff))
}
if newHeight != self.headerHeight {
self.headerHeight = newHeight
self.collectionView?.contentOffset = CGPoint(x: (self.collectionView?.contentOffset.x)!, y: self.previousScrollOffset)
self.collectionView?.collectionViewLayout.invalidateLayout()
}
self.previousScrollOffset = scrollView.contentOffset.y
}
}
And then to alter the height of the header (which is called when you invalidateLayout());
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
return CGSize(width: collectionView.bounds.width, height:self.headerHeight)
}
The result is that no overlapping ever occurs, but it still gives the desired effect that I was aiming to achieve in the first place.
Problem looks like this: http://i.imgur.com/5iaAiGQ.mp4
(red is a color of cell.contentView)
Here is the code: https://github.com/nezhyborets/UICollectionViewContentsAnimationProblem
Current status:
The content of UICollectionViewCell's contentView does not animate alongside contentView frame change. It gets the size immediately without animation.
Other issues faced when doing the task:
The contentView was not animating alongside cell's frame change either, until i did this in UICollectionViewCell subclass:
override func awakeFromNib() {
super.awakeFromNib()
//Because contentView won't animate along with cell
contentView.frame = bounds
contentView.autoresizingMask = [.flexibleHeight, .flexibleWidth]
}
Other notes:
Here is the code involved in cell size animation
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
self.selectedIndex = indexPath.row
collectionView.performBatchUpdates({
collectionView.reloadData()
}, completion: nil)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let isSelected = self.selectedIndex == indexPath.row
let someSize : CGFloat = 90 //doesn't matter
let sizeK : CGFloat = isSelected ? 0.9 : 0.65
let size = CGSize(width: someSize * sizeK, height: someSize * sizeK)
return size
}
I get the same results when using collectionView.setCollectionViewLayout(newLayout, animated: true), and there is no animation at all when using collectionView.collectionViewLayout.invalidateLayout() instead of reloadData() inside batchUpdates.
UPDATE
When I print imageView.constraints inside UICollectionView's willDisplayCell method, it prints empty array.
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
for view in cell.contentView.subviews {
print(view.constraints)
}
//Outputs
//View: <UIImageView: 0x7fe26460e810; frame = (0 0; 50 50); autoresize = RM+BM; userInteractionEnabled = NO; layer = <CALayer: 0x608000037280>>
//View constraints: []
}
This is a finicky problem, and you're very close to the solution. The issue is that the approach to animating layout changes varies depending on whether you're using auto layout or resizing masks or another approach, and you're currently using a mix in your ProblematicCollectionViewCell class. (The other available approaches would be better addressed in answer to a separate question, but note that Apple generally seems to avoid using auto layout for cells in their own apps.)
Here's what you need to do to animate your particular cells:
When cells are selected or deselected, tell the collection view layout object that cell sizes have changed, and to animate those changes to the extent it can do so. The simplest way to do that is using performBatchUpdates, which will cause new sizes to be fetched from sizeForItemAt, and will then apply the new layout attributes to the relevant cells within its own animation block:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
self.selectedIndex = indexPath.row
collectionView.performBatchUpdates(nil)
}
Tell your cells to layout their subviews every time the collection view layout object changes their layout attributes (which will occur within the performBatchUpdates animation block):
// ProblematicCollectionViewCell.swift
override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
super.apply(layoutAttributes)
layoutIfNeeded()
}
If you want greater control over your animations, you can nest the call to performBatchUpdates inside a call to one of the UIView.animate block-based animation methods. The default animation duration for collection view cells in iOS 10 is 0.25.
The solution is very easy. First, in ViewController.collectionView(_,didSelectItemAt:), write only this:
collectionView.performBatchUpdates({
self.selectedIndex = indexPath.row
}, completion: nil)
And then, in the class ProblematicCollectionViewCell add this func:
override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
super.apply(layoutAttributes)
self.layoutIfNeeded()
}
Enjoy!
You can apply a transform to a cell, although it has some drawbacks, such as handling orientation changes.
For extra impact, I have added a color change and a spring effect in the mix, neither of which could be achieved using the reloading route:
func collectionView(_ collectionView: UICollectionView,
didSelectItemAt indexPath: IndexPath) {
UIView.animate(
withDuration: 0.4,
delay: 0,
usingSpringWithDamping: 0.4,
initialSpringVelocity: 0,
options: UIViewAnimationOptions.beginFromCurrentState,
animations: {
if( self.selectedIndexPath.row != NSNotFound) {
if let c0 =
collectionView.cellForItem(at: self.selectedIndexPath)
{
c0.contentView.layer.transform = CATransform3DIdentity
c0.contentView.backgroundColor = UIColor.lightGray
}
}
self.selectedIndexPath = indexPath
if let c1 = collectionView.cellForItem(at: indexPath)
{
c1.contentView.layer.transform =
CATransform3DMakeScale(1.25, 1.25, 1)
c1.contentView.backgroundColor = UIColor.red
}
},
completion: nil)
}
I am trying to make a transition animation like the demonstration in the link here. So when I clicked the cell, it expands and covers the whole screen.
Here are my codes(I have to admit that I am not familiar with CollectionView)`
import UIKit
class ViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate {
#IBOutlet weak var mainDesLabel: UILabel!
#IBOutlet weak var collectionView: UICollectionView!
#IBOutlet weak var secDesLabel: UILabel!
let searchBar = UISearchBar()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
self.collectionView.delegate = self
self.collectionView.dataSource = self
self.collectionView.backgroundColor = UIColor.clearColor()
////////////////////////////////////////////////////////////////////////
self.searchBar.frame = CGRect(x: 175, y: 0, width: 200, height: 50)
self.searchBar.searchBarStyle = UISearchBarStyle.Minimal
self.searchBar.backgroundColor = UIColor.whiteColor()
self.view.addSubview(searchBar)
////////////////////////////////////////////////////////////////////////
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 10
}
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("cell", forIndexPath: indexPath) as UICollectionViewCell
cell.layer.cornerRadius = 5
return cell
}
func numberOfSectionsInCollectionView(collectionView: UICollectionView) -> Int {
return 1
}
//Use for size
func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
self.collectionView.frame = self.view.bounds
let cell = collectionView.cellForItemAtIndexPath(indexPath)
cell!.frame = CGRectMake(0, 0, self.view.bounds.width, self.view.bounds.height)
}
}
So I thought use 'didSelectItemAtIndexPath' would help, however it turns out like this
thoughts? Any help would be highly appreciated!
Or what you can do is expand the item and change its frame with UIAnimation.
And when he cell is tapped, you get the views inside the cell to be expanded also using auto layout and I'm hinting towards (clips to bounds).
something like this:
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
let item = collectionView.cellForItemAtIndexPath(indexPath) as! cellCollectionViewExpress // or whatever you collection view cell class name is.
UIView.animateWithDuration(1.0, animations: {
self.view.bringSubviewToFront(collectionView)
collectionView.bringSubviewToFront(item)
item.frame.origin = self.view.frame.origin /// this view origin will be at the top of the scroll content, you'll have to figure this out
item.frame.size.width = self.view.frame.width
item.frame.size.height = self.view.frame.height
})
}
I would suggest you that you use UICollectionView Controller, so things are at ease in general with using that.
You cannot just use didSelectItemAtIndexPath or any similar methods to update the size of a UICollectionViewCell once the UICollectionView is done performing the view layout.
To update cell height,
You can first capture which cell had been tapped in didSelectItemAtIndexPath.
Then, you can either reload the entire collection view with the new cell frame being passed in the sizeForItemAtIndexpath.
Or, you can just reload the specific cell with reloadItemsAtIndexPaths, but you still need to pass the updated size of the cell via sizeForItemAtIndexpath.
UPDATE
I now see the question details have been updated by an animation which you desire to have.
I had performed a similar animation by:-
Capturing the cell which had been tapped in didSelectItemAtIndexPath.
Adding a replica view to the UIViewContrller, but with its frame totally coinciding with the cell which had been tapped.
Then, animating this view which had been added. Thus giving an impression that the cell was animated.
Any additional functionality which has to be given can also be written in this view. Thus the code of the cell and the animated view is separated too.
When a cell tapped or a button or any tappable thing got tapped inside the cell, then you get the call from
didSelectItemAtIndexPath or through delegate, then to give the cell the required size, you have to invalidate the layout of the current collectionview. After this, size for item will get called and give the new size for the,
This will update the size of the collectioncell without reloading it.
You can give animation also.
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
if let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
flowLayout.invalidateLayout()
}
}
extension UICollectionView {
func transactionAnimation(with duration: Float, animateChanges: #escaping () -> Void) {
UIView.animate(withDuration: TimeInterval(duration)) {
CATransaction.begin()
CATransaction.setAnimationDuration(CFTimeInterval(duration))
CATransaction.setAnimationTimingFunction(.init(name: .default))
animateChanges()
CATransaction.commit()
}
}
}
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
collectionView.transactionAnimation(with: 0.3) {
self.carInfoCollectionView?.performBatchUpdates({}, completion: { _ in })
}
}
my View hierarchy is something like:
TableView-->tableViewCell-->CollectionView-->CollectionViewCell-->imageView
and in my tableViewCell I have some other items (textView, labels and a UIPageControl) so now am trying to change the currentPage of PageControl according to the item of CollectionViewCell ( same as carousel ) but I don't know why UIPageControl is not changing its position this is what I tried:
override func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
// here scrollView is my collectionView
if scrollView != tableView {
pagerControll.currentPage = Int(scrollView.contentOffset.x / self.view.frame.size.width)
}
}
I also tried this (for remembering the current position of Item in a cell after scrolling through the tableView)
override func tableView(tableView: UITableView,
willDisplayCell cell: UITableViewCell,
forRowAtIndexPath indexPath: NSIndexPath) {
guard let cell = cell as? NotesTableViewCell else { return }
cell.setCollectionViewDataSourceDelegate(self, forRow: indexPath.row)
cell.collectionViewOffset = storedOffsets[indexPath.row] ?? 0
cell.pageControll.currentPage = calculateCurrentPage(storedOffsets[indexPath.row] ?? 0)
}
above am fetching the contentOffset of each Row from an array of (contentoffset) so that I can show the previous positions Of CollectionViewCell's items, when tableView reuses the cell its working fine for the items of my collectionView cell but not for my UIPageControl
func calculateCurrentPage(offSet : CGFloat) -> Int{
if offSet >= self.view.frame.size.width && offSet < (self.view.frame.size.width*2) {
return 1
}else if offSet >= (self.view.frame.size.width*2) {
return 2
}else {
return 0
}
}
What's wrong here? or how to do it?
UPDate Extra Codes:
//inside my tableViewCell
func setCollectionViewDataSourceDelegate
>
(dataSourceDelegate: D, forRow row: Int) {
collectionView.delegate = dataSourceDelegate
collectionView.dataSource = dataSourceDelegate
collectionView.tag = row
collectionView.reloadData()
}
//in TableView
extension NotesTableViewController: UICollectionViewDelegate, UICollectionViewDataSource , UICollectionViewDelegateFlowLayout {
func collectionView(collectionView: UICollectionView,
numberOfItemsInSection section: Int) -> Int {
pagerControll.numberOfPages = attachedImgUrlDict[collectionView.tag]!.count
return attachedImgUrlDict[collectionView.tag]!.count
}
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
let newSize = CGSizeMake(collectionView.frame.size.width , collectionView.frame.size.height)
return newSize
}
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAtIndex section: Int) -> UIEdgeInsets {
return UIEdgeInsetsMake(0, 0, 0, 0)
//t,l,b,r
}
override func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
if scrollView != tableView {
if scrollView.contentOffset.x >= 320 && scrollView.contentOffset.x < 640{
pagerControll.currentPage = 1
}else if scrollView.contentOffset.x >= 600 {
pagerControll.currentPage = 2
}
//pagerControll.currentPage = Int(scrollView.contentOffset.x / self.view.frame.size.width)
print(scrollView.contentOffset)
}
}
}
Check #1
You'll receive callbacks for scroll events for UICollectionView as soon as you set a UICollectionViewDelegate on the collectionView instance.
Seems like you might be missing setting up UICollectionViewDelegate in following call
cell.setCollectionViewDataSourceDelegate(self, forRow: indexPath.row)
Can you verify you're receiving callbacks for UICollectionView scroll events?
Check #2
Say you are receiving callbacks properly now, Can you check your logic works properly with page index? A good thing would be to add a print statement that would show you the page index that you are calculating.
let pageIndex = Int(scrollView.contentOffset.x / self.view.frame.size.width)
print("pageIndex == \(pageIndex)")
Check #3
Say you are calculating it right, Can you check if cell.pageControl is populated with properly with UIPageControl instance you need to update.
Maybe you need to check your IBOutlet connections?
Check #4
Inside prepareForReuse() callback, you need to make sure that pageControl is set to some initial value like 0.
Adding a small delay while updating the pageIndex could work if you see inconsistencies like it is updating sometimes and sometimes it's not.
Hope it helps.