5

Problem

I'd like to make a UICollectionView do an animated scroll to a specific item.

This works most of the time, but occasionally the item that I'm trying to scroll to doesn't end up being shown.

Code

- (void)onClick {
  // (Possibly recompute the _items array.)
  NSInteger target_idx = // (...some valid index of _items)
  NSIndexPath *item_idx = [NSIndexPath indexPathForItem:target_idx inSection:0];
  [self scrollToItem:item_idx];
}

- (void)scrollToItem:(NSIndexPath*)item_idx {
  // Make sure our view is up-to-date with the data we want to show.
  NSInteger num_items = [self.collection_view numberOfItemsInSection:0];
  if (num_items != _items.count) {
    [self.collection_view reloadData];
  }

  [self.collection_view 
    scrollToItemAtIndexPath:item_idx
           atScrollPosition:UICollectionViewScrollPositionCenteredHorizontally
                   animated:YES];
}

Details

  • self.collection_view is a UICollectionView that consists of a single row of items, with a standard flow layout and horizontal scrolling enabled.
  • I need to call reloadData before scrolling, because it's possible that _items has changed since the UICollectionView last displayed it.
  • The problem only happens with animated scrolling; if I pass animated:NO then everything works as expected.
  • When the problem happens, a post-scroll call to indexPathsForVisibleItems reveals that the UICollectionView doesn't think that the target item is visible.

Any ideas why scrolling to an item silently fails sometimes?


Update: the problem seems to come from reloading and scrolling in quick succession; if there is no reload, scrolling behaves as expected. Are there any idioms for scrolling to an item after loading new data?

Nate Kohl
  • 35,264
  • 10
  • 43
  • 55
  • My guess is that reloadData call is messing things up for you. What happens if you comment it out? – Nicholas Hart Jul 19 '13 at 18:00
  • @NicholasHart: that's a good idea, but the problem is that frequently my data changes (which means I need to `reloadData`) and as a result I want to scroll to a new item (because I have new data). If I don't reload, I might scroll to an invalid index, possibly causing a crash. Scrolling after reloading seems like a pattern that should be fairly common, but perhaps not? – Nate Kohl Jul 19 '13 at 18:38
  • I'm not suggesting you not reload as a course of action, I'm just asking if temporarily removing it causes the undesirable behavior to stop. If so you know which direction to take for finding a solution (eg: figure out a way to wait until the table is done reloading before scrolling.) – Nicholas Hart Jul 19 '13 at 18:44
  • Scrolling seems to work as expected if I comment out the `reloadData`. So the question becomes: how can we combine reloading with scrolling? I tried reloading, asynchronously waiting, then scrolling, but the problem persists. Maybe there is some `invalidate` call that I'm missing? – Nate Kohl Jul 19 '13 at 19:05
  • Well, I don't think UICollectionViewDelegate or UICollectionViewDataSource have any methods that definitively tell you "reloading is done", so this might be a tricky problem to solve. I sorta wonder though about reloading while handling a "click." How is your data updating? Maybe you should reload the view when the data is updated and not do a reload when handling the user input. – Nicholas Hart Jul 19 '13 at 19:56
  • Hm...the flow I'd like to have is: 1. user selects a new datasource from a separate list, 2. UICollectionView updates to show new datasource, and 3. UICollectionView scrolls to show a specific item from new datasource. – Nate Kohl Jul 19 '13 at 20:02
  • This is a tricky problem since there's no sure-fire way to know when the collection view is done reloading. I wonder though, what if you called scrollToItemAtIndexPath first, then reloaded the data? – Nicholas Hart Jul 19 '13 at 20:28

3 Answers3

12

I've just run in to a similar / the same issue after adding an item to a UICollectionView.

What's happening

The issue seems to be that immediately following [collectionView reloadData] or [collectionView insertItemsAtIndexPaths: @[newItemIndexPath]], the collection view's content size is not yet updated.

If you then try to scroll the added item visible, it will fail because the content rect doesn't yet include space for the new item.

A fix

There is a simple and fairly robust work around, which is to post the scroll event on to the next iteration of the run loop like this:

const NSUInteger newIndex = 
  [self collectionView: self.collectionView numberOfItemsInSection: 0] - 1;

NSIndexPath *const newPath = 
  [NSIndexPath indexPathForItem: newIndex
                      inSection: 0];

[self.collectionView insertItemsAtIndexPaths: @[newPath]];

UICollectionViewLayoutAttributes *const layoutAttributes =
  [self.collectionView layoutAttributesForItemAtIndexPath: newPath];

dispatch_async(dispatch_get_main_queue(), ^{
    [self.collectionView scrollRectToVisible: layoutAttributes.frame 
                                    animated: YES];
});

Isn't there a nicer fix?

While it works, this "post the scroll call on the next run loop tick" shenanigans feels hacky.

It would be preferable if UICollectionView could invoke a callback when it finished updating the content rect. UIKit has callbacks of this style for other methods that perform asynchronous updates to its model. For example, a completion block for UIViewController transitions and UIView animations.

UICollectionView does not provide this callback. As far as I know, there is no other simple clean way to find when it completes its update. In the absence of this, the next run loop tick is a viable horse proxy for the callback unicorn we would prefer to use.

Anything else to know?

It's probably useful to know that UITableView also has this issue. A similar workaround should work there too.

Benjohn
  • 13,228
  • 9
  • 65
  • 127
3

With some help from @NicholasHart, I think I understand the problem.

Trying to reloadData and then performing an animated scroll to a new position makes sense (and seems to work) as long as the reload makes the collection bigger.

When reloading shrinks the collection, however, the starting point for an animated scroll might not exist anymore. This makes animation troublesome.

For example, if you start with the view scrolled all the way to the right (so that your rightmost item is visible) and then reload and lose half of your items, it's not clear what the starting point for the animation should be. Trying to do an animated scroll in this situation results in either a no-op or a jump to a very strange position.

One solution that looks reasonably good is to only animate if the collection is getting bigger:

- (void)scrollToItem:(NSIndexPath*)item_idx {
  // Make sure our view is up-to-date with the data we want to show.
  NSInteger old_num_items = [self.collection_view numberOfItemsInSection:0];
  NSInteger new_num_items = _items.count;
  if (old_num_items != new_num_items) {
    [self.collection_view reloadData];
  }

  // Animating if we're getting smaller doesn't really make sense, and doesn't 
  // seem to be supported.
  BOOL is_expanding = new_num_items >= old_num_items;
  [self.collection_view 
    scrollToItemAtIndexPath:item_idx
           atScrollPosition:UICollectionViewScrollPositionCenteredHorizontally
                   animated:is_expanding];
}
Nate Kohl
  • 35,264
  • 10
  • 43
  • 55
0

This works for me:

[UIView performWithoutAnimation:^{
    [self.collectionView performBatchUpdates:^{
        [self.collectionView reloadData];
    } completion:^(BOOL finished) {
        if (finished) {
            [self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:messages.count - 1 inSection:0]
                                        atScrollPosition:UICollectionViewScrollPositionBottom
                                                animated:NO];
        }
    }];
}];
Lizhen Hu
  • 814
  • 10
  • 16