iosobjective-cscrolluicollectionviewanimated

Animated scroll-to-item in UICollectionView doesn't always work


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

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?


Solution

  • 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.