4

Update: Solved! See my answer below for the solution.

My app displays a number of images in a UICollectionView. And I'm currently experiencing a problem with insertItemsAtIndexPaths when new items are getting inserted too fast for the collection view to handle. Below is the exception:

NSInternalInconsistencyException Reason: too many update animations on one view - limit is 31 in flight at a time

Turns out this was caused by my model buffering up to 20 new images and pushing them to the datasource at once but not inside a collection view batch update block. The absence of the batch update is not caused by laziness on my part but because of an abstraction layer between my datasource which is actually a .Net Observable collection (code below).

What I would like to know is how is the developer supposed to prevent hitting the hard coded limit of 31 animations in flight? I mean when it happens, you are toast. So what was Apple thinking?

Note to Monotouch Developers reading the code:

The crash is effectively caused by the UICollectionViewDataSourceFlatReadOnly overwhelming UIDataBoundCollectionView with CollectionChanged events which it proxies to the control on behalf of the underlying observable collection. Which results in the collectionview getting hammered with non-batched InsertItems calls. (yes Paul, its a ReactiveCollection).

UIDataBoundCollectionView

/// <summary>
/// UITableView subclass that supports automatic updating in response 
/// to DataSource changes if the DataSource supports INotifiyCollectionChanged
/// </summary>
[Register("UIDataBoundCollectionView")]
public class UIDataBoundCollectionView : UICollectionView,
  IEnableLogger
{
  public override NSObject WeakDataSource
  {
    get
    {
      return base.WeakDataSource;
    }

    set
    {
      var ncc = base.WeakDataSource as INotifyCollectionChanged;
      if(ncc != null)
      {
        ncc.CollectionChanged -= OnDataSourceCollectionChanged;
      }

      base.WeakDataSource = value;

      ncc = base.WeakDataSource as INotifyCollectionChanged;
      if(ncc != null)
      {
        ncc.CollectionChanged += OnDataSourceCollectionChanged;
      }
    }
  }

  void OnDataSourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
  {
    NSIndexPath[] indexPaths;

    switch(e.Action)
    {
      case NotifyCollectionChangedAction.Add:
        indexPaths = IndexPathHelper.FromRange(e.NewStartingIndex, e.NewItems.Count);
        InsertItems(indexPaths);
        break;

      case NotifyCollectionChangedAction.Remove:
        indexPaths = IndexPathHelper.FromRange(e.OldStartingIndex, e.OldItems.Count);
        DeleteItems(indexPaths);
        break;

      case NotifyCollectionChangedAction.Replace:
      case NotifyCollectionChangedAction.Move:
        PerformBatchUpdates(() =>
        {
          for(int i=0; i<e.OldItems.Count; i++)
            MoveItem(NSIndexPath.FromItemSection(e.OldStartingIndex + i, 0), NSIndexPath.FromItemSection(e.NewStartingIndex + i, 0));
        }, null);
        break;

      case NotifyCollectionChangedAction.Reset:
        ReloadData();
        break;
    }
  }
}

UICollectionViewDataSourceFlatReadOnly

/// <summary>
/// Binds a table to an flat (non-grouped) items collection 
/// Supports dynamically changing collections through INotifyCollectionChanged 
/// </summary>
public class UICollectionViewDataSourceFlatReadOnly : UICollectionViewDataSource,
  ICollectionViewDataSource,
  INotifyCollectionChanged
{
  /// <summary>
  /// Initializes a new instance of the <see cref="UICollectionViewDataSourceFlat"/> class.
  /// </summary>
  /// <param name="table">The table.</param>
  /// <param name="items">The items.</param>
  /// <param name="cellProvider">The cell provider</param>
  public UICollectionViewDataSourceFlatReadOnly(IReadOnlyList<object> items, ICollectionViewCellProvider cellProvider)
  {
    this.items = items;
    this.cellProvider = cellProvider;

    // wire up proxying collection changes if supported by source
    var ncc = items as INotifyCollectionChanged;
    if(ncc != null)
    {
      // wire event handler
      ncc.CollectionChanged += OnItemsChanged;
    }
  }

  #region Properties
  private IReadOnlyList<object> items;
  private readonly ICollectionViewCellProvider cellProvider;
  #endregion

  #region Overrides of UICollectionViewDataSource

  public override int NumberOfSections(UICollectionView collectionView)
  {
    return 1;
  }

  public override int GetItemsCount(UICollectionView collectionView, int section)
  {
    return items.Count;
  }

  /// <summary>
  /// Gets the cell.
  /// </summary>
  /// <param name="tableView">The table view.</param>
  /// <param name="indexPath">The index path.</param>
  /// <returns></returns>
  public override UICollectionViewCell GetCell(UICollectionView collectionView, NSIndexPath indexPath)
  {
    // reuse or create new cell
    var cell = (UICollectionViewCell) collectionView.DequeueReusableCell(cellProvider.Identifier, indexPath);

    // get the associated collection item
    var item = GetItemAt(indexPath);

    // update the cell
    if(item != null)
      cellProvider.UpdateCell(cell, item, collectionView.GetIndexPathsForSelectedItems().Contains(indexPath));

    // done
    return cell;
  }

  #endregion

  #region Implementation of ICollectionViewDataSource

  /// <summary>
  /// Gets the item at.
  /// </summary>
  /// <param name="indexPath">The index path.</param>
  /// <returns></returns>
  public object GetItemAt(NSIndexPath indexPath)
  {
    return items[indexPath.Item];
  }

  public int ItemCount
  {
    get
    {
      return items.Count;
    }
  }

  #endregion

  #region INotifyCollectionChanged implementation

  // UIDataBoundCollectionView will subscribe to this event
  public event NotifyCollectionChangedEventHandler CollectionChanged;

  #endregion

  void OnItemsChanged(object sender, NotifyCollectionChangedEventArgs e)
  {
    if(CollectionChanged != null)
      CollectionChanged(sender, e);
  }
}
Oliver Weichhold
  • 10,259
  • 5
  • 45
  • 87
  • Just fixed a similar issue with a collection view, which started occurring on iOS 7. It had to do with downloading multiple images at once and setting them to collection cells. I fixed it with the help of Tasks and async/await. I am starting up to a specific number of tasks that download the images and process them as they finish, through Task.WhenAny, starting the next batch of tasks when all of the previous ones are done. Of course, my data source implementation is a lot simpler. – Dimitris Tavlikos Sep 27 '13 at 11:41

2 Answers2

3

Cool! The latest version of RxUI has a similar class for UITableView, ReactiveTableViewSource. I also had some tricky issues with NSInternalInconsistencyException:

  1. If any of your updates are a Reset, you need to forget about doing everything else
  2. If the app has added and removed the same item in the same run, you need to detect that and debounce it (i.e. don't even tell UIKit about it). This gets even trickier when you realize that Add / Remove can change a range of indices, not just a single index.
Ana Betts
  • 73,868
  • 16
  • 141
  • 209
  • I was planning to contribute this stuff to RXUI as soon as I feel its stable enough. Never in my life something brought me so close to insanity as dealing with UICollectionView internal-consistency madness ;( Sooo many edge-cases. – Oliver Weichhold Sep 28 '13 at 18:27
  • @OliverWeichhold http://churchm.ag/wp-content/uploads/2012/02/I-know-that-feel-bro-tights-620x620.jpg – Ana Betts Sep 28 '13 at 23:05
2

Update: Now almost a year later after I wrote this answer, I would strongly recommend using the ReactiveUI CollectionView/TableView binding functionality mentioned by Paul Betts. Which is in a much more mature state now.


The solution turned out to be a bit harder than expected. Thanks to RX, throttling the rate of per single item inserts or deletes was easy to solve in UICollectionViewDataSourceFlatReadOnly . The next step involved batching those changes together inside UIDataBoundCollectionView. PerformBatchUpdate didn't help here but issuing a single InsertItems call with all the inserted IndexPaths did solve the problem.

Due to the way UICollectionView validates its internal consistency (ie. it calls GetItemsCount after each and every InsertItem or DeleteItems etc), I had to hand over ItemCount management to UIDataBoundCollectionView (that one was hard to swallow but there was no choice).

Performance is stellar by the way.

Here's the updated source for anyone interested:

ICollectionViewDataSource

public interface ICollectionViewDataSource
{
  /// <summary>
  /// Gets the bound item at the specified index
  /// </summary>
  /// <param name="indexPath">The index path.</param>
  /// <returns></returns>
  object GetItemAt(NSIndexPath indexPath);

  /// <summary>
  /// Gets the actual item count.
  /// </summary>
  /// <value>The item count.</value>
  int ActualItemCount { get; }

  /// <summary>
  /// Gets or sets the item count reported to UIKit
  /// </summary>
  /// <value>The item count.</value>
  int ItemCount { get; set; }

  /// <summary>
  /// Observable providing change monitoring
  /// </summary>
  /// <value>The collection changed observable.</value>
  IObservable<NotifyCollectionChangedEventArgs[]> CollectionChangedObservable { get; }
}

UIDataBoundCollectionView

[Register("UIDataBoundCollectionView")]
public class UIDataBoundCollectionView : UICollectionView,
  IEnableLogger
{
  public UIDataBoundCollectionView (NSObjectFlag t) : base(t)
  {
  }

  public UIDataBoundCollectionView (IntPtr handle) : base(handle)
  {
  }

  public UIDataBoundCollectionView (RectangleF frame, UICollectionViewLayout layout) : base(frame, layout)
  {
  }

  public UIDataBoundCollectionView (NSCoder coder) : base(coder)
  {
  }

  protected override void Dispose(bool disposing)
  {
    base.Dispose(disposing);

    if(collectionChangedSubscription != null)
    {
      collectionChangedSubscription.Dispose();
      collectionChangedSubscription = null;
    }
  }

  IDisposable collectionChangedSubscription;

  public override NSObject WeakDataSource
  {
    get
    {
      return base.WeakDataSource;
    }

    set
    {
      if(collectionChangedSubscription != null)
      {
        collectionChangedSubscription.Dispose();
        collectionChangedSubscription = null;
      }

      base.WeakDataSource = value;

      collectionChangedSubscription = ICVS.CollectionChangedObservable
        .Subscribe(OnDataSourceCollectionChanged);
    }
  }

  ICollectionViewDataSource ICVS
  {
    get { return (ICollectionViewDataSource) WeakDataSource; }
  }

  void OnDataSourceCollectionChanged(NotifyCollectionChangedEventArgs[] changes)
  {
    List<NSIndexPath> indexPaths = new List<NSIndexPath>();
    int index = 0;

    for(;index<changes.Length;index++)
    {
      var e = changes[index];

      switch(e.Action)
      {
        case NotifyCollectionChangedAction.Add:
          indexPaths.AddRange(IndexPathHelper.FromRange(e.NewStartingIndex, e.NewItems.Count));
          ICVS.ItemCount++;

          // attempt to batch subsequent changes of the same type
          if(index < changes.Length - 1)
          {
            for(int i=index + 1; i<changes.Length; i++)
            {
              if(changes[i].Action == e.Action)
              {
                indexPaths.AddRange(IndexPathHelper.FromRange(changes[i].NewStartingIndex, changes[i].NewItems.Count));
                index++;
                ICVS.ItemCount++;
              }
            }
          }

          InsertItems(indexPaths.ToArray());
          indexPaths.Clear();
          break;

        case NotifyCollectionChangedAction.Remove:
          indexPaths.AddRange(IndexPathHelper.FromRange(e.OldStartingIndex, e.OldItems.Count));
          ICVS.ItemCount--;

          // attempt to batch subsequent changes of the same type
          if(index < changes.Length - 1)
          {
            for(int i=index + 1; i<changes.Length; i++)
            {
              if(changes[i].Action == e.Action)
              {
                indexPaths.AddRange(IndexPathHelper.FromRange(changes[i].OldStartingIndex, changes[i].OldItems.Count));
                index++;
                ICVS.ItemCount--;
              }
            }
          }

          DeleteItems(indexPaths.ToArray());
          indexPaths.Clear();
          break;

        case NotifyCollectionChangedAction.Replace:
        case NotifyCollectionChangedAction.Move:
          PerformBatchUpdates(() =>
          {
            for(int i=0; i<e.OldItems.Count; i++)
              MoveItem(NSIndexPath.FromItemSection(e.OldStartingIndex + i, 0), NSIndexPath.FromItemSection(e.NewStartingIndex + i, 0));
          }, null);
          break;

        case NotifyCollectionChangedAction.Reset:
          ICVS.ItemCount = ICVS.ActualItemCount;
          ReloadData();
          break;
      }
    }
  }
}

UICollectionViewDataSourceFlatReadOnly

public class UICollectionViewDataSourceFlatReadOnly : UICollectionViewDataSource,
  ICollectionViewDataSource
{
  /// <summary>
  /// Initializes a new instance of the <see cref="UICollectionViewDataSourceFlat"/> class.
  /// </summary>
  /// <param name="table">The table.</param>
  /// <param name="items">The items.</param>
  /// <param name="cellProvider">The cell provider</param>
  public UICollectionViewDataSourceFlatReadOnly(IReadOnlyList<object> items, ICollectionViewCellProvider cellProvider)
  {
    this.items = items;
    this.cellProvider = cellProvider;

    // wire up proxying collection changes if supported by source
    var ncc = items as INotifyCollectionChanged;
    if(ncc != null)
    {
      collectionChangedObservable = Observable.FromEventPattern<NotifyCollectionChangedEventHandler, NotifyCollectionChangedEventArgs>(
        h => ncc.CollectionChanged += h, h => ncc.CollectionChanged -= h)
        .SubscribeOn(TaskPoolScheduler.Default)
        .Select(x => x.EventArgs)
        .Buffer(TimeSpan.FromMilliseconds(100), 20)
        .Where(x => x.Count > 0)
        .Select(x => x.ToArray())
        .ObserveOn(RxApp.MainThreadScheduler)
        .StartWith(new[] { reset});   // ensure initial update
    }

    else
      collectionChangedObservable = Observable.Return(reset);
  }

  #region Properties
  private IReadOnlyList<object> items;
  private readonly ICollectionViewCellProvider cellProvider;
  IObservable<NotifyCollectionChangedEventArgs[]> collectionChangedObservable;
  static readonly NotifyCollectionChangedEventArgs[] reset = new[] { new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset) };
  #endregion

  #region Overrides of UICollectionViewDataSource

  public override int NumberOfSections(UICollectionView collectionView)
  {
    return 1;
  }

  public override int GetItemsCount(UICollectionView collectionView, int section)
  {
    return ItemCount;
  }

  /// <summary>
  /// Gets the cell.
  /// </summary>
  /// <param name="tableView">The table view.</param>
  /// <param name="indexPath">The index path.</param>
  /// <returns></returns>
  public override UICollectionViewCell GetCell(UICollectionView collectionView, NSIndexPath indexPath)
  {
    // reuse or create new cell
    var cell = (UICollectionViewCell) collectionView.DequeueReusableCell(cellProvider.Identifier, indexPath);

    // get the associated collection item
    var item = GetItemAt(indexPath);

    // update the cell
    if(item != null)
      cellProvider.UpdateCell(cell, item, collectionView.GetIndexPathsForSelectedItems().Contains(indexPath));

    // done
    return cell;
  }

  #endregion

  #region Implementation of ICollectionViewDataSource

  /// <summary>
  /// Gets the item at.
  /// </summary>
  /// <param name="indexPath">The index path.</param>
  /// <returns></returns>
  public object GetItemAt(NSIndexPath indexPath)
  {
    return items[indexPath.Item];
  }

  public int ActualItemCount
  {
    get
    {
      return items.Count;
    }
  }

  public int ItemCount { get; set; }

  public IObservable<NotifyCollectionChangedEventArgs[]> CollectionChangedObservable
  {
    get
    {
      return collectionChangedObservable;
    }
  }

  #endregion
}
Community
  • 1
  • 1
Oliver Weichhold
  • 10,259
  • 5
  • 45
  • 87
  • I have a doubt in your GetCell method in UICollectionViewDataSourceFlatReadOnly, how do you bind the images to their respective cell? I'm only seeing there getting an reference to the cell, then you get the item in the items List, then if the item exists then you update it, two issues arise from this: the current Xamarin documentation doesn't mention a ICollectionViewCellProvider class, is this a custom class of yours? If so, can I replace the call to UpdateCell method with something of my own like cell.ImageView.Image = item (suppose item is a UIImage)? – Uriel Arvizu Sep 29 '14 at 17:06
  • Another doubt I have, is it necessary to implement a read-only List to hold the items for the CollectionView? – Uriel Arvizu Sep 29 '14 at 17:07
  • also it seems the Observable class isn't available in Xamarin Studio for MacOSX, I checked on the References and I couldn't find it. – Uriel Arvizu Sep 29 '14 at 17:40
  • I found the Observable class in the Rx component found in Xamarin's Component Store, but the RxApp class is found in ReactiveUI which is not found in the Component Store, is there a guide on how to add this component without the Component Store? – Uriel Arvizu Sep 29 '14 at 18:11
  • @UrielArvizu I would strongly recommend using the ReactiveUI collection classes which are in a much more mature state now almost a year later. I've also updated my answer reflecting this. – Oliver Weichhold Sep 30 '14 at 09:02
  • I have a Starter account so I can't make use of components from the store to keep my binaries under 64 KB, from what I read Rx and ReactiveUI are a must for CollectionViews, so if CollectionView is a no, do I have to implement my own grid-like view? – Uriel Arvizu Sep 30 '14 at 18:56
  • You dont want to go that Route. Trust me. – Oliver Weichhold Sep 30 '14 at 19:29