21 October 2013

Making UICollectionView play nice with INotifyCollectionChanged

Posted by Oliver Weichhold in ios and data-binding

Even though Monotouch aka Xamarin IOS provides us developers with a powerful toolset for writing cross-platform applications, things like Data-Binding still have to be implemented by yourself or your favorite Cross-Platform Framework like ReactiveUI or MvvmCross.

I recently began implementing the iOS UI Layer of one of our cross-platform apps. One of the View-Models exposes a collection that needs to be bound to a UICollectionView. The collection can be filtered by user input in realtime and therefore implements INotifyCollectionChanged. This is something that's absolutely trivial when data-binding against WPF, Windows Phone or Windows 8 Controls. But then again, writing some glue layer between INotifyCollectionChanged and UICollectionView shouldn't be that hard right? Wrong ...

The most obvious approach would be a custom UICollectionViewDataSource acting as middle-man between the collection and the UICollectionView.

A naive approach:

public class UICollectionViewDataSourceFlat : UICollectionViewDataSource
{
    public UICollectionViewDataSourceFlat(UICollectionView collectionView, IList items)
    {
        this.items = items;
        this.CollectionView = collectionView;

        var ncc = items as INotifyCollectionChanged;
        if(ncc != null)
            ncc.CollectionChanged += OnCollectionChanged;
    }

    void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        CollectionView.ReloadData();
    }
}

This works, but only if you have low standards. The code won't crash, but performance is horrible and all of your cells will flicker in response to the slightest change of the collection.

Let's see if we can do better:

public class UICollectionViewDataSourceFlat : UICollectionViewDataSource
{
    public UICollectionViewDataSourceFlat(UICollectionView collectionView, IList items)
    {
        this.items = items;
        this.CollectionView = collectionView;

        var ncc = items as INotifyCollectionChanged;
        if(ncc != null)
            ncc.CollectionChanged += OnCollectionChanged;
    }

    void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        switch(e.Action)
        {
            case NotifyCollectionChangedAction.Reset:
                CollectionView.ReloadData();
                break;
            case NotifyCollectionChangedAction.Add:
                CollectionView.InsertItems(IndexPathHelper.FromRange(e.NewStartingIndex, e.NewItems.Count));
                break;
            case NotifyCollectionChangedAction.Remove:
                CollectionView.InsertItems(IndexPathHelper.FromRange(e.OldStartingIndex, e.OldItems.Count));
                break;
            case NotifyCollectionChangedAction.Move:
                for(int i=0;i<e.OldItems.Count;i++)
                    CollectionView.MoveItem(NSIndexPath.FromItemSection(e.OldStartingIndex + i, 0),
                        NSIndexPath.FromItemSection(e.NewStartingIndex + i, 0));
                break;
            case NotifyCollectionChangedAction.Replace:
                CollectionView.ReloadItems(IndexPathHelper.FromRange(e.OldStartingIndex, e.OldItems.Count));
                break;
        }
    }
}

This works quite well. Except when it doesn't. And here's when things got ugly.

UICollectionView (at least the iOS7 version) has an internal artificial limit for the maximum number of animations that can be in-flight at a time. And since we communicate changes to the underlying collection without any kind of batching to the UICollectionView, the animation-limit can be hit at any time! What makes the situation even more serious is the way the iOS Team decided to handle the animation limit: They crash your app with an assertion failure.

And if this wasn't enough, there's another problem introduced by the asynchronous nature of ReloadData. When ReloadData is still in progress and lots of InsertItems calls happen in the same Run-Loop, another assertation failure can be triggered that also causes a crash.

The only way to take care of both problems is to batch collection changes, which introduces problems in its own right. One example is GetItemsCount. Since this override will be called by UICollectionView in response to each and every InsertItems or DeleteItems call, the return value must be in sync with the collection-view's own view of the situation at all times, or - you guessed right - the result will be a crash. Since batching introduces a time based disconnect between what the underlying collection really looks like and what has been communicated to the Collection View so far, we are forced to micro-manage an itemCount field that represents the number of items as known by the Collection-View.

Another caveat is that UICollectionView does not like to see the same NSIndexPath used multiple times in a single batch update.

The final implementation:

public class UICollectionViewDataSourceFlat : UICollectionViewDataSource
  IEnableLogger
{
  public UICollectionViewDataSourceFlat(UICollectionView collectionView, IList items)
  {
    this.items = items;
    itemCount = items.Count;
    this.CollectionView = collectionView;

    var ncc = items as INotifyCollectionChanged;
    if(ncc != null)
    {
      collectionChangedSubscription = Observable.FromEventPattern<NotifyCollectionChangedEventHandler, NotifyCollectionChangedEventArgs>(
        h => ncc.CollectionChanged += h, h => ncc.CollectionChanged -= h)
        .Select(x => x.EventArgs)
        .Buffer(TimeSpan.FromMilliseconds(100))
        .Where(x => x.Count > 0)
        .Select(x => x.ToArray())
        .ObserveOn(RxApp.MainThreadScheduler)
        .Subscribe(OnItemsChanged);
    }
  }

  #region Properties
  private IList items;
  int itemCount;
  #endregion

  #region Overrides of UICollectionViewDataSource

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

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

  #endregion

  void OnItemsChanged(NotifyCollectionChangedEventArgs[] changes)
  {
    this.Log().Info("**** OnItemsChanged set - {0}", changes.Length);

    if(changes.Any(x => x.Action == NotifyCollectionChangedAction.Reset)
       || ContainsIndexDupe(changes))
    {
      // The tricky part with Reset is that if we are issuing a Reload in response
      // additions that might immediately follow the Reload while it is still in 
      // progress might cause a crash. Therefore we avoid issuing a Reload at all!

      var oldItemCount = itemCount;
      itemCount = items.Count;

      // is existing itemcount greater than new one?
      if(oldItemCount > items.Count)
      {
        this.Log().Info("Emulating Reset - Truncate {0}", oldItemCount - items.Count);

        // truncate at the end
        CollectionView.DeleteItems(IndexPathHelper.FromRange(items.Count, oldItemCount - items.Count));

        // reload remaining items
        if(items.Count > 0)
          CollectionView.ReloadItems(IndexPathHelper.FromRange(0, items.Count));
      }

      else if(oldItemCount < items.Count)
      {
        this.Log().Info("Emulating Reset - Extend {0}", items.Count - oldItemCount);

        // extend at the end
        CollectionView.InsertItems(IndexPathHelper.FromRange(oldItemCount, items.Count - oldItemCount));

        // reload remaining items
        CollectionView.ReloadItems(IndexPathHelper.FromRange(0, oldItemCount));
      }

      return;
    }

    var additions = new List<NSIndexPath>();
    var removals = new List<NSIndexPath>();
    var reloads = new List<NSIndexPath>();

    for(int i=0;i<changes.Length;i++)
    {
      var e = changes[i];

      if(e.Action == NotifyCollectionChangedAction.Add)
        additions.AddRange(IndexPathHelper.FromRange(e.NewStartingIndex, e.NewItems.Count));
      else if(e.Action == NotifyCollectionChangedAction.Remove)
        removals.AddRange(IndexPathHelper.FromRange(e.OldStartingIndex, e.OldItems.Count));
      else if(e.Action == NotifyCollectionChangedAction.Replace)
        reloads.AddRange(IndexPathHelper.FromRange(e.OldStartingIndex, e.OldItems.Count));
    }

    if(reloads.Count > 0)
      CollectionView.ReloadItems(reloads.ToArray());

    CollectionView.PerformBatchUpdates(() =>
    {
      itemCount += additions.Count;
      itemCount -= removals.Count;

      if(additions.Count > 0)
        CollectionView.InsertItems(additions.ToArray());

      if(removals.Count > 0)
        CollectionView.DeleteItems(removals.ToArray());
    },
    (completed)=>
    {
      this.Log().Info("*** Completed update. {0} additions, {1} removals, {2} reloads", additions.Count, removals.Count, reloads.Count);
    });
  }

  bool ContainsIndexDupe(NotifyCollectionChangedEventArgs[] changes)
  {
    int prevItem = -1;
    var changedIndexes = changes.SelectMany(x => GetChangedIndexes(x)).ToList();
    changedIndexes.Sort();

    for(int j=0; j < changedIndexes.Count; j++)
    {
      if(prevItem == changedIndexes[j] && j > 0)
        return true;

      prevItem = changedIndexes[j];
    }

    return false;
  }

  IEnumerable<int> GetChangedIndexes(NotifyCollectionChangedEventArgs e)
  {
    switch (e.Action)
    {
      case NotifyCollectionChangedAction.Add:
      case NotifyCollectionChangedAction.Replace:
        return Enumerable.Range(e.NewStartingIndex, e.NewItems.Count);
      case NotifyCollectionChangedAction.Move:
        return new[] { e.OldStartingIndex, e.NewStartingIndex };
      case NotifyCollectionChangedAction.Remove:
        return Enumerable.Range(e.OldStartingIndex, e.OldItems.Count);
      default:
        throw new ArgumentException("Don't know how to deal with " + e.Action);
    }
  }
}

Note: Parts not relevant to the article have been omitted for simplicity