How to gracefully recover from an assertion failure in UICollectionView performBatchUpdates: - ios

I have a UICollectionView that gets updated like so:
[self.collectionView performBatchUpdates:^{
[self.collectionView deleteItemsAtIndexPaths:pathsRemoved];
[self.collectionView insertItemsAtIndexPaths:pathsAdded];
}
Usually, this works just fine, but under certain circumstances the data source count doesn't match the original number of items - count of pathsRemoved + count of pathsAdded.
Therefore, I wrapped this code inside a #try/#catch block that then calls [self.collectionView reloadData].
What ends up happening, however, is that after reloading the data, the layout becomes messed up; cells that were previously there (above on-screen cells, but off screen) no longer show. The collection view's content size is correct, and I can scroll up an down, but only see the cells that were on screen at time of reload.
Interestingly, Reveal.app shows that the other cells do in fact exist, but are hidden; I have a hunch that this is a red herring, and that there is some view caching going on here, but the cells are nonetheless listed in Reveal.
I have tried invalidating the layout, calling layoutIfNeeded, as well as the various subclass-of-flow-layout tricks I've found, and nothing has fixed it.
I've resorted to mitigating the issue by doing my own benign assertion, and doing a check to make sure the counts add up. If they do, performBatchUpdates. Else, don't bother, and just reload. This works, since we're bypassing performBatchUpdates: altogether. But I have to ask:
Is there a better way to gracefully recover from an assertion exception thrown from within performBatchUpdates:? Or is it that UICollectionViews are just buggy (still, on iOS 7).

I just find a way to handle this !
I tried like you to wrap the performBatchUpdates: code inside a #try/#catch block that calls [collectionView reloadData].
Then I realized that the problems disappeared when my ViewController was deallocated, the collectionView also.
So I found the ugly solution (but working) : kill your old collectionView and create a new one in the catch block.
#try {
[collectionView performBatchUpdates:^ {
// Do your stuff
}];
}
#catch (NSException *exception) {
UIView *oldSuperview = self.collectionView.superview;
[self.collectionView removeFromSuperview];
self.collectionView = [[UICollectionView alloc] initWithFrame:self.collectionView.frame collectionViewLayout:self.collectionView.collectionViewLayout];
self.collectionView.dataSource = self;
self.collectionView.delegate = self;
// set other collectionView's properties here
[oldSuperview addSubview:self.collectionView];
}
I know this is bad, but it saved me !

Related

Update UITableView height when tweet loaded

I have a UITableViewCell which contains a TWTRTweetView with auto layout. I am loading a tweet like this:
- (void)loadTweetWithId:(NSString *)tweetId {
if (mTweetId == nil || ![mTweetId isEqualToString:tweetId]) {
mTweetId = tweetId;
[[[TWTRAPIClient alloc] init] loadTweetWithID:tweetId completion:^(TWTRTweet *tweet, NSError *error) {
if (tweet) {
NSLog(#"Tweet loaded!");
[mTweetView configureWithTweet:tweet];
[mTweetView setShowActionButtons:YES];
//[mTweetView setDelegate:self];
[mTweetView setPresenterViewController:self.viewController];
[mTweetView setNeedsLayout];
[mTweetView layoutIfNeeded];
[mTweetView layoutSubviews];
hc.constant = mTweetView.frame.size.height;
[self updateConstraints];
[self layoutIfNeeded];
[self layoutSubviews];
[self.tableView setNeedsLayout];
[self.tableView layoutIfNeeded];
[self.tableView layoutSubviews];
} else {
NSLog(#"Tweet load error: %#", [error localizedDescription]);
}
}];
}
}
When tweet loaded cell doesn't resize unless I scroll it out and scroll it to back. I have tried several approaches as you can see in code snippet. But non of these works. My table view uses full auto layout approach which doesn't implement cell height for row function. How can i fix this?
UPDATE:
Using:
[self.tableView beginUpdates];
[self.tableView endUpdates];
is not possible because when I do that all cells being redrawn and very big jumping happens and that is not acceptable. Also I have confirmed that tweet completion block runs in main thread.
UPDATE 2:
I have also tried to cache tweet view with tweet id and reload cell for related index path and give the same tweet view for tweet id. The cell height is corrected but it doesn't become visible until scroll out/in.
UPDATE 3:
I give constraints to tweet view in xib of the cell and height constraint is connected. So this is not a main thread issue. I have also mentioned that reloading particular cell at index doesn't work.
While working an other solution I have seen some sample TwitterKit codes that uses TWTRTweetTableViewCell but was preloading tweets to configure the cells. So I have done the same. This is a workaround of course.
Updated Answer:
You're doing a couple of things wrong that are likely to cause (or at least contribute to) the jumping:
Never call layoutSubviews yourself.
It's a method called by the system to resolve your constraints. It's automatically triggered when calling setNeedsLayout and layoutIfNeeded in a row.
The same applies to updateConstraints. It is called by the system during a layout pass. You can manually trigger it by subsequently calling setNeedsUpdateContraints and updateConstraintsIfNeeded. Furthermore, it only has an effect if you actually implemented (overrode) that method in your custom view (or cell).
When you call layoutIfNeeded on a view it layouts its subviews. Thus, when you change the constant of a constraint that constrains your mTweetView, it probably won't have any effect (unless the view hierarchy is invalidated during the triggered layout pass). You need to call layoutIfNeeded on mTweetView's superview which is the cell's content view (judging from the screenshot you added to your post):
[contentView layoutIfNeeded];
Furthermore, there is one more thing you need to be aware of that can cause flickering as well:
Cells in a table view are being recycled. Each time a cell is reused you load a new tweet. I guess it's from an asynchronous network request? If so, there is the possibility that the completion block from the first tweet you load for that cell instance returns after the completion block from the second tweet you load for that (recycled) cell when you scroll really fast or you internet connection is really slow. Make sure you cancel the request or invalidate it somehow when your cell is reused (prepareForReuse method).
Please make sure you've fixed all these issues and see if animation now works as expected. (My original answer below remains valid.)
Original Answer:
I'm pretty sure that
[self.tableView beginUpdates];
[self.tableView endUpdates];
is the only way to have a cell auto-resize itself while being displayed.
Reason:
For historic and performance reasons a UITableView always works with fixed-height cells (internally). Even when using self-sizing cells by setting an estimatedRowHeight etc. the table view will compute the height of a cell when it's dequeued, i.e. before it appears on screen. It will then add some internal constraints to the cell to give it a fixed width and a fixed height that just match the size computed by Auto Layout.
These internal constraints are only updated when needed, i.e. when a row is reloaded. Now when you add any constraints inside you cell you will "fight" against these internal constraints which have a required priority (aka 1000). In other words: There's no way to win!
The only way to update these internal (fixed) cell constraints is to tell the table view that it should. And as far as I know the only public (documented) API for that is
- (void)beginUpdates;
- (void)endUpdates;
So the only question that remains is:
Why is this approach not an option for you?
I think it's legitimate to redraw a cell after it's been resized. When you expand the cell to show a longer tweet than before the cell needs to be redrawn anyway!
You probably won't (and shouldn't) resize all visible cells all the time. (That would be quite confusing for the user...)
Try reloading that particular cell, after you loaded the tweet using,
- (void)reloadRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths
withRowAnimation:(UITableViewRowAnimation)animation;
I had the similar issue and i got that fixed by adding all my code in dispatch_async to make sure its running on main thread.
dispatch_async(dispatch_get_main_queue(), ^{
/*CODE HERE*/
});
So your code should be like this:
- (void)loadTweetWithId:(NSString *)tweetId {
if (mTweetId == nil || ![mTweetId isEqualToString:tweetId]) {
mTweetId = tweetId;
[[[TWTRAPIClient alloc] init] loadTweetWithID:tweetId completion:^(TWTRTweet *tweet, NSError *error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (tweet) {
NSLog(#"Tweet loaded!");
[mTweetView configureWithTweet:tweet];
[mTweetView setShowActionButtons:YES];
//[mTweetView setDelegate:self];
[mTweetView setPresenterViewController:self.viewController];
[mTweetView setNeedsLayout];
[mTweetView layoutIfNeeded];
[mTweetView layoutSubviews];
hc.constant = mTweetView.frame.size.height;
[self updateConstraints];
[self layoutIfNeeded];
[self layoutSubviews];
[self.tableView setNeedsLayout];
[self.tableView layoutIfNeeded];
[self.tableView layoutSubviews];
} else {
NSLog(#"Tweet load error: %#", [error localizedDescription]);
}
});
}];
}
}

UITableViewCell showing UIImageView in some cells and different cells when scrolling - in use with CoreData and NSFetchedResultsController

I have a simple application whereby a UITableView is populated by a user tapping on an item in the UINavigationBar and then getting taken to another view to fill in UITextFields and UITextView. When the user clicks save, the information is saved to Core Data and then represented in this UITableView with the use of NSFetchedResultsController.
One of the attributes is a "Notes" attribute on a "Transaction" Entity. Filling in the Notes in the UITextView is completely optional, but if the user adds in a note, I want to show a custom image that I've created on the cell for the entry that has the note.
When the app is run in this version alone (so deleted and installed with this developer release), it works very well and the cells show the notes only for the cells that have the notes. However, when updating from a previous version of the app, this is where the problem occurs.
The previous version of the app didn't have a Notes attribute and so I used CoreData lightweight migration to set up a new model with a Notes attribute. That part works.
The Problem
Because of the reusing of cells, I'm noticing that when I've updated from an old version to this new version, none of the cells have the custom image and that's good because the notes doesn't exist. However, if I go in and add a new entry with a note and then scroll through my UITableView, I notice the cells start showing the custom image randomly, based on scrolling. So it disappears from one cell and shows up on another. This is a big mis-representation for my users and I'm not quite sure what to do to fix this.
In my cellForRow I have the following code:
self.pin = [[UIImageView alloc] initWithFrame:CGRectMake(13, 30, 24, 25)];
self.pin.image = [UIImage imageNamed:#"pin"];
self.pin.tag = 777;
if (!transaction.notes) {
dispatch_async (dispatch_get_main_queue (), ^{
[[customCell viewWithTag:777] removeFromSuperview];
});
}
if ([transaction.notes isEqualToString:#""] || ([transaction.notes isEqualToString:#" "] || ([transaction.notes isEqualToString:#" "] || ([transaction.notes isEqualToString:#" "]))))
{
[[customCell viewWithTag:777] removeFromSuperview];
}
else
{
[customCell addSubview:self.pin];
}
So the first if statement is to check whether the notes exist and that returns true when updating from an old version of an app to this version. The second if statement just checks if the value of the notes is equal to a few spaces and if so, then to remove the note.
I just can't figure out what's going on here.
Possible Solution
In the same UITableView cell, I also have a green/red dot UIImageView which is displayed depending on whether the user selected a Given or Received Status when adding a new entry. With this in mind, one image is ALWAYS displayed, whether it's the green or red dot. So what I'm thinking about here is creating a transparent square and just changing the if statement to say "If note, show pin image and if not, show transparent image".
That feels a bit like a hack though and I'd prefer a proper way to fix this.
Any thoughts on this would really be appreciated.
First of all, bad practice to allocate views in cellForRow. If you really need to allocate views in cellForRow do it just when it's needed, in your case in the else statement.
Second, do not use dispatch_async to dispatch on main thread if you are already on main thread (cellForRow it's on main thread).
The above points are just some suggestions for performance improvement.
As a solution of your problem, I would create a custom UITableViewCell and in it's method prepareForReuse I would remove the imageView.
EDIT
YourCustomCell.m
- (void)prepareForReuse {
[super prepareForReuse];
[[self viewWithTag:YOUR_TAG] removeFromSuperview];
}
This is a straightforward implementation, but you have to take in consideration that is more expensive to alloc/dealloc the UIImageView than keep a reference to the image and hide it when you don't need it. Something like:
YourCustomCell.h
#interface YourCustomCell : UITableViewCell {
IBOutlet UIImageView *theImageView; // make sure you link the outlet properly ;)
}
YourCustomCell.m
- (void)prepareForReuse {
[super prepareForReuse];
theImageView.hidden = YES;
}
And in cellForRow you just have to check if you have notes and make the imageView visible (probably you will make theImageView a property)
Because table view cells are reused you must have a default value for your image. So for example set your image to transparent by default and change it under some condition. This will stop your image being shown in reused cells.
Why do you have a dispatch_async here?
if (!transaction.notes) {
dispatch_async (dispatch_get_main_queue (), ^{
[[customCell viewWithTag:777] removeFromSuperview];
});
}
Because you cannot be sure when the function inside it will execute. Suppose that transaction.notes is nil. All the isEqualToString functions will return false and the else condition of addSubView will be called. But sometime after this function is exited the code inside dispatch_async will be run and remove the pin view. I'm not whether this is the intended behavior.

setNeedsLayout relayouts the cell only after is has been displayed

I have a table view with cells, which sometimes have an optional UI element, and sometimes it has to be removed.
Depending on the element, label is resized.
When cell is initialised, it is narrower than it will be later on. When I set data into the label, this code is called from cellForRowAtIndexPath:
if (someFlag) {
// This causes layout to be invalidated
[flagIcon removeFromSuperview];
[cell setNeedsLayout];
}
After that, cell is returned to the table view, and it is displayed. However, the text label at that point has adjusted its width, but not height. Height gets adjusted after a second or so, and the jerk is clearly visible when all cells are already displayed.
Important note, this is only during initial creation of the first few cells. Once they are reused, all is fine, as optional view is removed and label is already sized correctly form previous usages.
Why isn't cell re-layouted fully after setNeedsLayout but before it has been displayed? Shouldn't UIKit check invalid layouts before display?
If I do
if (someFlag) {
[flagIcon removeFromSuperview];
[cell layoutIfNeeded];
}
all gets adjusted at once, but it seems like an incorrect way to write code, I feel I am missing something else.
Some more code on how cell is created:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
ProfileCell *cell = [tableView dequeueReusableCellWithIdentifier:kCellIdentifier];
[cell setData:model.items[indexPath.row] forMyself:YES];
return cell;
}
// And in ProfileCell:
- (void)setData:(Entity *)data forMyself:(BOOL)forMe
{
self.entity = data;
[self.problematicLabel setText:data.attributedBody];
// Set data in other subviews as well
if (forMe) {
// This causes layouts to be invalidated, and problematicLabel should resize
[self.reportButton removeFromSuperview];
[self layoutIfNeeded];
}
}
Also, if it matters, in storyboard cell looks like this, with optional constraint taking over once flag icon is removed:
I agree that calling layoutIfNeeded seems wrong, even though it works in your case. But I doubt that you're missing something. Although I haven't done any research on the manner, in my experience using Auto Layout in table cells that undergo a dynamic layout is a bit buggy. That is, I see herky jerky layouts when removing or adding subviews to cells at runtime.
If you're looking for an alternative strategy (using Auto Layout), you could subclass UITableViewCell and override layoutSubviews. The custom table cell could expose a flag in its public API that could be set in the implementation of tableView:cellForRowAtIndexPath:. The cell's layoutSubviews method would use the flag to determine whether or not it should include the optional UI element. I make no guarantees that this will eliminate the problem however.
A second strategy is to design two separate cell types and swap between the two in tableView:cellForRowAtIndexPath: as necessary.
You've added additional code to the question, so I have another suggestion. In the cell's setData:forMyself: method, try calling setNeedsUpdateConstraints instead of layoutIfNeeded.

contentSize is not updated after reloadData is called on UICollectionView

Does anyone know why contentSize is not updated immediately after reloadData is called on UICollectionView?
If you need to know the contentSize the best work around I've found is the following:
[_collectionView reloadData];
double delayInSeconds = 0.0001;
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
dispatch_after(DISPATCH_TIME_NOW, dispatch_get_main_queue(), ^(void)
{
// TODO: Whatever it is you want to do now that you know the contentSize.
});
Obviously this is a fairly brittle hack that makes assumptions on Apple's implementation but so far it has proven to work quite reliably.
Does anyone have any other workarounds or knowledge on why this happens? I'm debating submitting a radar because this I can't understand why they cannot calculate the contentSize in the same run loop. That is how UITableView has worked for its entire implementation.
EDIT: This question use to reference the setContentOffset method inside the block because I want to scroll the collection view in my app. I've removed the method call because peoples' answers focused on why wasn't I using scrollToItemAtIndexPath inside of why contentSize is not being updated.
To get the content size after reload, try to call collectionViewContentSize of the layout object. It works for me.
This worked for me:
[self.collectionView.collectionViewLayout invalidateLayout];
[self.collectionView.collectionViewLayout prepareLayout];
Edit: I've just tested this and indeed, when the data changes, my original solution will crash with the following:
"Invalid update: invalid number of items in section 0. The number of items contained in an existing section after the update (7) must be equal to the number of items contained in that section before the update (100), plus or minus the number of items inserted or deleted from that section (0 inserted, 0 deleted) and plus or minus the number of items moved into or out of that section (0 moved in, 0 moved out)."
The right way to handle this is to calculate the insertions, deletions, and moves whenever the data source changes and to use performBatchUpdates around them when it does. For example, if two items are added to the end of an array which is the data source, this would be the code:
NSArray *indexPaths = #[indexPath1, indexPath2];
[self.collectionView performBatchUpdates:^()
{
[self.collectionView insertItemsAtIndexPaths:indexPaths];
} completion:^(BOOL finished) {
// TODO: Whatever it is you want to do now that you know the contentSize.
}];
Below is the edited solution of my original answer provided by Kernix which I don't think is guaranteed to work.
Try performBatchUpdates:completion: on UICollectionView. You should have access to the updated properties in the completion block. It would look like this:
[self.collectionView reloadData];
[self.collectionView performBatchUpdates:^()
{
} completion:^(BOOL finished) {
// TODO: Whatever it is you want to do now that you know the contentSize.
}];
After calling
[self.collectionView reloadData];
use this:
[self.collectionView setNeedsLayout];
[self.collectionView layoutIfNeeded];
then you can get the real contentSize
Call prepareLayout first, and then you will get correct contentSize:
[self.collectionView.collectionViewLayout prepareLayout];
In my case, I had a custom layout class that inherits UICollectionViewFlowLayout and set it only in the Interface Builder. I accidentally removed the class from the project but Xcode didn't give me any error, and the contentSize of the collection view returned always zero.
I put back the class and it started working again.

memory retention/leak on iOS

I have a UItableViewCell defined from a XIB file. This cell contains a UIScrollView, which is filled on the fly with UIView Objects - viewXY -. I use arc, everywhere in the code. The UI looks like:
cell 0: [ view00 view01 view02 ...]
cell 1: [ view10 view11 view12 ...]
etc..., where [ ] is a cell content, which is horizontally scrollable.
Problem: I profiled the app, and as I scroll down vertically, the memory footprint grows rapidly, and as far as I could see, never goes back to normal, nor reaches a plateau. More importantly, if I scroll down a few cells and then back up, memory increases too....
I checked for leaks with the instrument tool, nothing.
I narrowed down the problem around the part of the code that creates the viewXY . If I use:
myDisplay* viewXY;//myDisplay is a UIView subclass
-->viewXY = [[NSBundle mainBundle] loadNibNamed:AW_UI_VE owner:self options:nil][0];
[scrollView addSubview:viewXY];
Memory grows out of control as I flip through the cells. The NIB is a simple UIView+UIImageView+2 Labels.... If I replace say
UIImageView *viewXY = [UIImageView alloc] initWithImage:[blablabla]]
[scrollView addSubview:viewXY];
Everything is fine. The Live Bytes in instruments reaches rapidly a plateau.
As part of the tests, I load the NIB file without exercising any of the methods defined in its custom class, so it comes out with the IBOutlets as defined in the IB (default label text, image etc...).
This stuff is driving me crazy.
Suggestions to understand what I'm doing wrong are most welcome.
In your ViewController's viewDidLoad you should have something like this:
[yourTable registerNib:[UINib nibWithNibName:#"yourUITableViewCellNibName" bundle:nil]
forCellReuseIdentifier:#"reuseIdentifier"];
and in your table datasource method(cellForRowAtIndexPath) you should have the following:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
MyCustomCell* cell = [tableView dequeueReusableCellWithIdentifier:#"reuseIdentifier"];
//Set your cell's values.
return cell;
}
I’m not sure if this is related to the memory usage, but the docs recommend UINib for this use case:
Your application should use UINib objects whenever it needs to repeatedly instantiate the same nib data.
Another possibility is that there is a retain cycle in myDisplay. Does it have any strong properties that should be weak?
[scrollView addSubview:viewXY];
There is your problem. You're adding something to the scroll view, and never remove it.
From the sound of things, you aren't using the UITableView properly. You should have a view controller that impliments methods from UITableViewDelegate and UITableViewDataSource protocols, and use those to fill the table with data. You never add anything to the table directly, you only provide it with cells when it requests them.

Resources