UICollectionView not removing old cells after scroll - ios

I have a UICollectionView with a grid of images. When you tap on one, it opens up the grid and shows a subview with some details. Like this:
I open up the grid in my UICollectionViewLayout by adjusting the UICollectionViewLayoutAttributes and setting a translation on the transform3D property for all cells below the current row of the selected item. This works really nicely, and is a much better animation and a simpler approach than my first attempt at inserting another cell into the grid which is a different size to the others.
Anyway... it works most of the time, but then after continued use I see old images on the collection view. They are like ghost cells. I can't click them, it's like they haven't been removed from the collection view properly, and they sit on top of the cells preventing taps and just being a nuisance. Like this:
Any ideas why these cells are doing this?
EDIT:
I'd like to add, I think it only happens when i scroll the collection view really fast. I've written my own UICollectionViewFlowLayout replacement to test if it still happens. It does.
EDIT 2:
The 3d transforms or layout have nothing to do with this. It must be a bug in UICollectionView. I can exploit by just scrolling really fast, letting come to a standstill and then querying the views that are on screen. There are often double the number of cells, but they are hidden as they are stacked on top of each other. My implementation above reveals them because of the translation i do.
This can really hurt performance too.
See my answer for a solution.

My second edit of my question details why this is happenening, and here is my workaround. It's not bullet proof, but it works in my case, and if you experience somethign similar you could tweak my solution:
- (void) removeNaughtyLingeringCells {
// 1. Find the visible cells
NSArray *visibleCells = self.collectionView.visibleCells;
//NSLog(#"We have %i visible cells", visibleCells.count);
// 2. Find the visible rect of the collection view on screen now
CGRect visibleRect;
visibleRect.origin = self.collectionView.contentOffset;
visibleRect.size = self.collectionView.bounds.size;
//NSLog(#"Rect %#", NSStringFromCGRect(visibleRect));
// 3. Find the subviews that shouldn't be there and remove them
//NSLog(#"We have %i subviews", self.collectionView.subviews.count);
for (UIView *aView in [self.collectionView subviews]) {
if ([aView isKindOfClass:UICollectionViewCell.class]) {
CGPoint origin = aView.frame.origin;
if(CGRectContainsPoint(visibleRect, origin)) {
if (![visibleCells containsObject:aView]) {
[aView removeFromSuperview];
}
}
}
}
//NSLog(#"%i views shouldn't be there", viewsShouldntBeThere.count);
// 4. Refresh the collection view display
[self.collectionView setNeedsDisplay];
}
and
- (void) scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
if (!decelerate) {
[self removeNaughtyLingeringCells];
}
}
- (void) scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
[self removeNaughtyLingeringCells];
}

A quick further comment to bandejapaisa's: under iOS 6 only, I found that UICollectionView also had a habit of bungling animated transitions. The original cells would remain where they were, copies would be made and then the copies would be animated. Usually on top of the originals but not always. So a simple bounds test wasn't sufficient.
I therefore wrote a custom subclass of UICollectionView that does the following:
- (void)didAddSubview:(UIView *)subview
{
[super didAddSubview:subview];
//
// iOS 6 contains a bug whereby it fails to remove subviews, ever as far as I can make out.
// This is a workaround for that. So, if this is iOS 6...
//
if(![UIViewController instancesRespondToSelector:#selector(automaticallyAdjustsScrollViewInsets)])
{
// ... then we'll want to wait until visibleCells has definitely been updated ...
dispatch_async(dispatch_get_main_queue(),
^{
// ... then we'll manually remove anything that's a sub of UICollectionViewCell
// and isn't currently listed as a visible cell
NSArray *visibleCells = self.visibleCells;
for(UIView *view in self.subviews)
{
if([view isKindOfClass:[UICollectionViewCell class]] && ![visibleCells containsObject:view])
[view removeFromSuperview];
}
});
}
}
Obviously it's a shame that 'is this iOS 6' test can't be a little more direct but it's hidden off in a category in my actual code.

A Swift UICollectionView extension version of bandejapaisa's answer:
extension UICollectionView {
func removeNaughtyLingeringCells() {
// 1. Find the visible cells
let visibleCells = self.visibleCells()
//NSLog("We have %i visible cells", visibleCells.count)
// 2. Find the visible rect of the collection view on screen now
let visibleRect = CGRectOffset(bounds, contentOffset.x, contentOffset.y)
//NSLog("Rect %#", NSStringFromCGRect(visibleRect))
// 3. Find the subviews that shouldn't be there and remove them
//NSLog("We have %i subviews", subviews.count)
for aView in subviews {
if let aCollectionViewCell = aView as? UICollectionViewCell {
let origin = aView.frame.origin
if (CGRectContainsPoint(visibleRect, origin)) {
if (!visibleCells.contains(aCollectionViewCell)) {
aView.removeFromSuperview()
}
}
}
}
// 4. Refresh the collection view display
setNeedsDisplay()
}
}

Related

UITableViewCell setFrame "bugged" when updating table

I have a custom cell which should be spaced from the edges of the display. For that I use this:
- (void)setFrame:(CGRect)frame {
frame.origin.x += kCellSidesInset;
frame.size.width -= 2 * kCellSidesInset;
[super setFrame:frame];
}
I do have a button that hides/shows the bottom view of a stacked view inside the cell. For which I use this code:
- (IBAction)showCardDetails:(id)sender {
UITableView *cellTableView = (UITableView*)[[[[sender superview] superview] superview] superview ];
[cellTableView beginUpdates];
self.details.hidden = !self.details.hidden;
[cellTableView endUpdates];
// [cellTableView reloadSections:[NSIndexSet indexSetWithIndex:0] withRowAnimation:UITableViewRowAnimationBottom];
// [cellTableView reloadData];
}
However when the table is updated to reflect the change the right padding becomes allot bigger. Then when I scroll a bit it gets fixed.
As much as I could visually judge it is like 3 times. Maybe it adds two more kCellSidesInset on the right but I doubt it.
Why is this happening and how can it be fixed? Maybe it can be avoid by instead of giving inset to the cell giving it to the UITableView (I have some trouble figuring how to do this).
PS. All the code is inside the CustomCell.m. I am open for a suggestion to a better way of getting the UITableView inside the action. Should I use selector in the CustomTableViewController.m to implement the action there when the cell is added?
EDIT: From what I can see the re rendering of the cells goes trough three phases.
Phase one, a couple of these:
Phase two, it updates the view cells:
And here everything looks good for now. The view that I want to hide/show is hidden/shown and all is good but then some sort of cleanup breaks the layout:
I solved the problem by refactoring the setFrame method to use the superview's frame of the cell as a reference point for the cell frame
- (void)setFrame:(CGRect)frame {
frame.origin.x = self.superview.frame.origin.x + kCellSidesInset;
frame.size.width = self.superview.frame.size.width - 2 * kCellSidesInset;
[super setFrame:frame];
}

Scrolling to bottom of tableView programatically when uibutton is tapped

When my View (containing a Table View) loads, I want it to scroll to the bottom of the table view. This works great. However, when one of my buttons (sendReply) is tapped, I also want the tableView to scroll to the bottom. For some reason, scrolling to the bottom of the tableView works when the View is initially loaded, however [self bottomScroll:self] doesn't seem to fire when I place it inside of my sendReply action?
.m
-(void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
[self bottomScroll:self];
}
- (void)bottomScroll:(id)sender {
if (self.messages.count > 0)
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:self.messages.count-1 inSection:0] atScrollPosition:UITableViewScrollPositionBottom animated:YES];
}
- (IBAction)sendReply:(id)sender {
[self bottomScroll:self];
}
This should work. Are you sure messages.count is greater than 0 when you tap the button? You can set a breakpoint at that line and see if this code is executed.
Also you can try -scrollRectToVisible as an alternative.
as phi stated scrollRectToVisible would be the way I would go. Call your tableView's scroll view passing in the rect of your button you want to show. It's been awhile since I've used obj-c but:
-(IBAction)sendReply: (id)sender {
[scrollView scrollRectToVisible: sendReply.frame, animated: YES]
}
? The syntax may be wrong there but it should be close enough to get you where you are going.
In swift:
#IBAction func sendReply(sender: UIButton/AnyObject) -> Void {
let scrollView = tableView.scrollView
scrollView.scrollrectToVisible(_ rect: sender.frame, animated: true)
}
It seems strange based on your description. Need more code to find whats going on. What can be inferred is you want to scroll the last cell to the bottom. So make sure the contentsize of tableview is larger than its frame vertically.
Try this one
if (self.tableView.contentSize.height > self.tableView.frame.size.height){
CGPoint point = CGPointMake(0, self.tableView.contentSize.height - self.tableView.frame.size.height);
[self.tableView setContentOffset:point];
}

UITableView with pull to refresh and locked cells like UltraVisual

I'm trying to make something similar to what UltraVisual for iOS already does. I'd like to make my pull-to-refresh be in a cell in-between other cells.
I think the following GIF animation explains it better:
It looks like the first cell fades out when pulling up, while when you pull down and you're at the top of the table, it adds a new cell right below the first one and use it as the pull-to-refresh.
Has anyone done anything similar?
Wrote this one for UV. Its actually way simpler than you're describing. Also, for what its worth, this view was written as a UICollectionView, but the logic still applies to UITableView.
There is only one header cell. Durring the 'refresh' animation, I simply set the content inset of the UICollectionView to hold it open. Then when I've finished with the reload, I animate the content inset back to the default value.
As for the springy fixed header, there's a couple of ways you can handle it. Quick and dirty is to use a UICollectionViewFlowLayout, and modify the attributes in - (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect
Here's some pseudo code assuming your first cell is the sticky header:
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
NSArray *layoutAttributes = [super layoutAttributesForElementsInRect:rect];
if ([self contentOffsetIsBelowZero]) {
for (UICollectionViewLayoutAttributes *attributes in layoutAttributes) {
if (attributes.indexPath.item == 0) {
CGPoint bottomOrigin = CGPointMake(0, CGRectGetMaxY(attributes.frame));
CGPoint converted = [self.collectionView convertPoint:bottomOrigin toView:self.collectionView.superview];
height = MAX(height, CGRectGetHeight(attributes.frame));
CGFloat offset = CGRectGetHeight(attributes.frame) - height;
attributes.frame = CGRectOffset(CGRectSetHeight(attributes.frame, height), 0, offset);
break;
}
}
}
Another approach would be to write a custom UICollectionViewLayout and calculate the CGRect's manually.
And finally, the 'fade out' is really nothing more than setting the opacity of the objects inside the first cell as it moves off screen. You can calculate the position of the cell on screen during - (void)applyLayoutAttributes… and set the opacity based on that.
Finally, something to note: In order to do any 'scroll based' updates with UICollectionView, you'll need to make sure - (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds returns YES. You can do a simple optimisation check like:
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
BOOL shouldInvalidate = [super shouldInvalidateLayoutForBoundsChange:newBounds];
if ([self contentOffsetIsBelowZero]) {
shouldInvalidate = YES;
}
return shouldInvalidate;
}
Again this is mostly pseudo code, so re-write based on your own implementation. Hope this helps!

UICollectionView not re-displaying supplementary views scrolled off screen when keyboard is visible

I have a UICollectionViewLayout subclass, which specifies supplementary views around cells and at the start and end of a section. For the purpose of this question, I've created a sample project which has a distilled version of my layout subclass. I've also created a video which demonstrates visually the problem I'm about to describe.
The sample project lays out things as such ([0, 1] = section 0 item 1):
[0, 0] A "section"-level supplementary view of type HEYCollectionViewElementKindHeaderA
[0, 0] An "item"-level supplementary view of type HEYCollectionViewElementKindCellA
[0, 0] A cell for this index path.
[0, 1] Another CellA variety
[0, 1] A cell for this index path.
etc.
In the sample project, there are a few additional headers that I've commented out in the HEYCollectionViewLayout class. If you uncomment them, you can see even more views that aren't reused correctly.
All of the header views have a UITextField inside, though I am able to reproduce this issue with both a UITextView and also my own UIView which has an inputView set and becomes first responder. When the keyboard is active on the text field in the CellA [0, 0] element and you scroll the supplementary views above it off-screen and then back on, the views are not re-added to the collection view.
In my investigations, I've found that the UICollectionReusableView subclasses are placed into the reuse queue and show up as hidden=YES in the view hierarchy, as expected when awaiting reuse. When the keyboard is not active, and I scroll the elements back on, the same instance of the class that was shown before will be reused and displayed onto the screen.
I have tried having the UICollectionViewLayout ask for a relayout on each bounds change (by returning YES from -shouldInvalidateLayoutForBoundsChange:); this has no effect and the elements still end up hidden.
I've been unable to find any other reports of this issue on the internet, but I've reproduced it on the iOS simulator 7.1 in Xcode 5.1.1, and on an iPod Touch running iOS 7.1.2.
I've updated the repo above with a possible workaround:
- (BOOL)canBecomeFirstResponder {
return YES;
}
- (BOOL)resignFirstResponder {
BOOL didResign = [super resignFirstResponder];
if (didResign) {
// This works around http://stackoverflow.com/q/25189751/551420
dispatch_async(dispatch_get_main_queue(), ^{
[self.collectionView performBatchUpdates:^{
} completion:^(BOOL finished) {
}];
});
}
return didResign;
}
I believe the issue is some kind of broken internal state in UICollectionView which is fixed by forcing an update whenever the UICollectionViewController resigns first responder (because a child becomes first responder).
This doesn't seem like the right solution, so I'm not marking it as an answer, but in case somebody else encounters the problem, this is how I'm currently trying to work around it.
This definitely seems to me to be some kind of internal state dealing with the first responder that's getting broken.
One workaround I've found is to make each supplementary view's index path unique by adding an extra index:
- (void)prepareLayout {
[super prepareLayout];
[self.supplementaryViewAttributes removeAllObjects];
[self.cellAttributes removeAllObjects];
CGFloat minimumY = 0;
for (NSInteger sectionIdx = 0; sectionIdx < self.collectionView.numberOfSections; sectionIdx++) {
NSIndexPath *sectionIndexPath = [NSIndexPath indexPathForItem:0 inSection:sectionIdx];
NSInteger suppViewIdx = 0;
NSIndexPath *suppIndexPath = [sectionIndexPath indexPathByAddingIndex:suppViewIdx];
suppViewIdx++;
[self newAttributesForSupplementaryViewAtIndexPath:suppIndexPath
kind:HEYCollectionViewElementKindHeaderA
height:HEYCollectionViewHeaderHeight
minimumY:&minimumY];
suppIndexPath = [sectionIndexPath indexPathByAddingIndex:suppViewIdx];
suppViewIdx++;
[self newAttributesForSupplementaryViewAtIndexPath:suppIndexPath
kind:HEYCollectionViewElementKindHeaderB
height:HEYCollectionViewHeaderHeight
minimumY:&minimumY];
for (NSInteger itemIdx = 0; itemIdx < [self.collectionView numberOfItemsInSection:sectionIdx]; itemIdx++) {
NSIndexPath *itemIndexPath = [NSIndexPath indexPathForItem:itemIdx inSection:sectionIdx];
suppIndexPath = [itemIndexPath indexPathByAddingIndex:suppViewIdx];
suppViewIdx++;
[self newAttributesForSupplementaryViewAtIndexPath:suppIndexPath
kind:HEYCollectionViewElementKindCellA
height:HEYCollectionViewCellHeight
minimumY:&minimumY];
suppIndexPath = [itemIndexPath indexPathByAddingIndex:suppViewIdx];
suppViewIdx++;
[self newAttributesForSupplementaryViewAtIndexPath:suppIndexPath
kind:HEYCollectionViewElementKindCellB
height:HEYCollectionViewCellHeight
minimumY:&minimumY];
[self newAttributesForCellAtIndexPath:itemIndexPath minimumY:&minimumY];
}
}
}
If this is an internal bug, then this seems like a decent workaround to me since it does not alter your index paths' section or item values, and it also means you don't have to subclass UICollectionView to force it to update when it resigns first responder.
However, if you are currently compare:-ing or testing for equality on index paths (using isEqual: for example), then this workaround could possibly break that. You'd have to instead make sure to only compare the section and item to keep the same functionality.

Redraw subviews after UIScrollView zoom

I've got a UIScrollView with a child UIView (CATiledLayer) - then I have a bunch more child views of that view (some of which are UITextViews)
After zooming everything is all fuzzy.
I have read various articles on the subject and they all seem to indicate that I must handle scrollViewDidEndZooming then 'do some transform stuff, mess with the frame and tweak the content offset'. Please can someone put me out of my misery and explain how this is meant to work.
Thanks in advance...
I had a similar problem where I needed zooming with text. Mine wasn't using the CATiledLayer, so this may or may not work for you. I also wasn't using ARC, so if you are, you'll have to adjust that as well.
The solution I came up with was to set the UIScrollViewDelegate methods as follows this:
// Return the view that you want to zoom. My UIView is named contentView.
-(UIView*) viewForZoomingInScrollView:(UIScrollView*)scrollView {
return self.contentView;
}
// Recursively find all views that need scaled to prevent blurry text
-(NSArray*)findAllViewsToScale:(UIView*)parentView {
NSMutableArray* views = [[[NSMutableArray alloc] init] autorelease];
for(id view in parentView.subviews) {
// You will want to check for UITextView here. I only needed labels.
if([view isKindOfClass:[UILabel class]]) {
[views addObject:view];
} else if ([view respondsToSelector:#selector(subviews)]) {
[views addObjectsFromArray:[self findAllViewsToScale:view]];
}
}
return views;
}
// Scale views when finished zooming
- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(float)scale {
CGFloat contentScale = scale * [UIScreen mainScreen].scale; // Handle retina
NSArray* labels = [self findAllViewsToScale:self.contentView];
for(UIView* view in labels) {
view.contentScaleFactor = contentScale;
}
}
I hit the same problem and the code above didn't work for my case. Then I followed the documentation:
If you intend to support zoom in your scroll view, the most common technique is to use a single subview that encompasses the entire contentSize of the scroll view and then add additional subviews to that view. This allows you to specify the single ‘collection’ content view as the view to zoom, and all its subviews will zoom according to its state.
"Adding Subviews" section of https://developer.apple.com/library/ios/#documentation/WindowsViews/Conceptual/UIScrollView_pg/CreatingBasicScrollViews/CreatingBasicScrollViews.html
I simply created an empty view and added my CATiledLayer and all other subviews to that empty view. Then added that empty view as the only child view of the scroll view. And, it worked like a charm. Something along these lines:
- (void)viewDidLoad
{
_containerView = [[UIView alloc] init];
[_containerView addSubView:_yourTiledView];
for (UIView* subview in _yourSubviews) {
[_containerView addSubView:subview];
}
[_scrollView addSubView:_containerView];
}
-(UIView*) viewForZoomingInScrollView:(UIScrollView*)scrollView {
return _containerView;
}

Resources