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()
}
Related
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 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.
I have few cells on my UICollectionView and on rotations the number of rows and columns changes. I am able to achieve this with:
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize
{
if UIDevice.currentDevice().orientation.isPortrait.boolValue{
return CGSizeMake((collectionView.frame.size.width-2)/2, (collectionView.frame.size.height - 4)/3)
}
else{
return CGSizeMake((collectionView.frame.size.width-4)/3, (collectionView.frame.size.height - 4)/3)
}
}
All is well and fine until device is rotated. For rotation I have reloaded the collection view like this
override func didRotateFromInterfaceOrientation(fromInterfaceOrientation: UIInterfaceOrientation) {
myCollectionView!.reloadData()
}
The end result looks like this.(Sorry for bad gif)
The desired number of cells is achieved. But as you can see from the link, for a brief second there is a gap noticed when rotation occurs. This effect is not desired. Also it is bad to keep reloading the UICollectionView again and again just to fix the layout. I am able to achieve what I want but not in a proper way. Some expert ideas will be appreciated.
Here is a solution assuming you have a UICollectionViewFlowLayout:
First, add this to your ViewController:
NSNotificationCenter.defaultCenter().addObserver(self, selector: #selector(ResultsCollectionViewController.orientationDidChange(_:)), name: UIDeviceOrientationDidChangeNotification, object: nil)
Don't forget to remove self when VC is closed:
deinit {
NSNotificationCenter.defaultCenter().removeObserver(self)
}
Then implement the selector method :
func orientationDidChange(notification: NSNotification) {
collectionView!.collectionViewLayout.invalidateLayout()
}
Basically you just send a notification when the device orientation changes, then the selector invalidates your layout so that it gets refreshed.
EDIT :
Here is a basic UICollectionViewFlowLayout setup method :
func setupCollectionViewLayout(minimumInteritemSpacing minimumInteritemSpacing: CGFloat, minimumLineSpacing: CGFloat) {
let layout = UICollectionViewFlowLayout()
layout.minimumInteritemSpacing = minimumInteritemSpacing
layout.minimumLineSpacing = minimumLineSpacing
collectionView?.collectionViewLayout = layout
}
Then in viewDidLoad you can call :
// Setup a layout
setupCollectionViewLayout(minimumInteritemSpacing: 0, minimumLineSpacing: 0)
If you wish to use a UICollectionViewDelegateFlowLayout, you can use the following template, but keep in mind that you have to adapt it !
// ----------------------------------------------------------------------------------
// MARK: - UICollectionViewDelegateFlowLayout
// ----------------------------------------------------------------------------------
extension ResultsCollectionViewController: UICollectionViewDelegateFlowLayout {
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
switch UIDevice.currentDevice().userInterfaceIdiom {
case .Phone:
return CGSize(width: self.view.frame.width, height: 100.0)
case .Pad:
return CGSize(width: self.view.frame.width/2.0, height: 100.0)
default:
return CGSize(width: self.view.frame.width, height: 100.0)
}
}
}
This question already has answers here:
Keeping the contentOffset in a UICollectionView while rotating Interface Orientation
(24 answers)
Closed 6 years ago.
I have a paginated, horizontally scrolling UICollectionView where each cell takes up the size of the view/device screen, and one cell appears at a time. I'm experiencing an issue where on device rotation, the cells will transition to the correct new size, but the position of the cells will be off.
Before Rotation:
After Rotation:
On rotation, how can I not only resize the collection view cells properly but ensure that the current cell stays visible?
I've boiled this problem down to a very simple example without a custom flow layout.
Setting the size of the cells to the collectionView frame's size (the collection view is the size of the view controller holding it):
func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
return collectionView.frame.size
}
Attempting to handle rotation in viewWillLayoutSubviews:
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
guard let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return }
flowLayout.itemSize = collectionView.frame.size
flowLayout.invalidateLayout()
}
Actually, if I call invalidateLayout() in viewWillLayoutSubviews, setting the itemSize here shouldn't be necessary, as sizeForItemAtIndexPath will be called when the layout is recreated.
I've also tried manually scrolling to the first visible cell during rotation after setting the new itemSize, but that shows no improvement:
if let item = collectionView.indexPathsForVisibleItems().first {
collectionView.scrollToItemAtIndexPath(item, atScrollPosition: .CenteredHorizontally, animated: true)
}
You should handle content offset of your UICollectionView, so in your view controller try to override viewWillTransitionToSize function from UIContentContainer protocol, like this:
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)
let offset = yourCollectionView?.contentOffset;
let width = yourCollectionView?.bounds.size.width;
let index = round(offset!.x / width!);
let newOffset = CGPointMake(index * size.width, offset!.y);
yourCollectionView?.setContentOffset(newOffset, animated: false)
coordinator.animateAlongsideTransition({ (context) in
yourCollectionView?.reloadData()
yourCollectionView?.setContentOffset(newOffset, animated: false)
}, completion: nil)
}
I have a headerView in my CollectionView and have resized the headerView programmatically based on the text size of the Label. On rotating to landscape orientation the reusable header view doesn't resize automatically but on scrolling it resizes itself to give the intended result.
Below is the snippet I have used to resize my header.
override func collectionView(collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, atIndexPath indexPath: NSIndexPath) -> UICollectionReusableView {
switch kind{
case UICollectionElementKindSectionHeader:
let headerView = collectionView.dequeueReusableSupplementaryViewOfKind(kind, withReuseIdentifier: "HeaderView", forIndexPath: indexPath) as! CollectionViewHeader
var headerString = westBeaches[indexPath.section] as NSString
var newSize: CGSize = headerString.sizeWithAttributes([NSFontAttributeName:headerView.headerText.font])
headerView.frame.size.width = newSize.width + 20
headerView.layer.cornerRadius = 15
headerView.headerText.text = westBeaches[indexPath.section]
headerView.center.x = collectionView.center.x
headerView.alpha = 0.7
return headerView
default:
assert(false, "Unexpected element Kind")
}
}
CollectionViewHeader is a custom class inheriting UIcollectionReusableView and contains headerText as UILabel. Is there any way to prevent the reusable view from going back to its original size when the orientation changes?
For me I had a UISearchBar as a header, and had this same problem. That is after rotation the search bar doesn't fill the CollectionView width.
I fixed it by animating the search bar's width alongside the rotation animation.
In UIViewController
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)
// Animate searchBar too
coordinator.animateAlongsideTransition({ [unowned self] _ in
var frame = self.searchController.searchBar.frame
frame.size.width = size.width
self.searchController.searchBar.frame = frame
}, completion: nil)
}
You have to update UICollectionView flow layout for orientations.
override func didRotateFromInterfaceOrientation(fromInterfaceOrientation: UIInterfaceOrientation) {
collectionView.performBatchUpdates({ () -> Void in
}, completion: { (complete) -> Void in
})
}