File: Handlers\Items\iOS\ObservableGroupedSource.cs
Web Access
Project: src\src\Controls\src\Core\Controls.Core.csproj (Microsoft.Maui.Controls)
#nullable disable
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using Foundation;
using UIKit;
 
namespace Microsoft.Maui.Controls.Handlers.Items
{
	internal class ObservableGroupedSource : IObservableItemsViewSource
	{
		readonly WeakReference<UICollectionViewController> _collectionViewController;
		UICollectionView _collectionView => _collectionViewController.TryGetTarget(out var controller) ? controller.CollectionView : null;
		readonly IList _groupSource;
		bool _disposed;
		List<ObservableItemsSource> _groups = new List<ObservableItemsSource>();
 
		public ObservableGroupedSource(IEnumerable groupSource, UICollectionViewController collectionViewController)
		{
			_collectionViewController = new(collectionViewController);
			_groupSource = groupSource as IList ?? new ListSource(groupSource);
 
			if (_groupSource is INotifyCollectionChanged incc)
			{
				incc.CollectionChanged += CollectionChanged;
			}
 
			_groupCount = GroupsCount();
			ResetGroupTracking();
		}
 
		public object this[NSIndexPath indexPath]
		{
			get
			{
				return GetGroupItemAt(indexPath.Section, (int)indexPath.Item);
			}
		}
 
		int _groupCount = 0;
 
		public int GroupCount => _groupCount;
 
		public int ItemCount
		{
			get
			{
				var total = 0;
 
				for (int n = 0; n < _groupSource.Count; n++)
				{
					total += GetGroupCount(n);
				}
 
				return total;
			}
		}
 
		public bool ObserveChanges { get; set; } = true;
 
		public NSIndexPath GetIndexForItem(object item)
		{
			for (int i = 0; i < _groupSource.Count; i++)
			{
				var j = IndexInGroup(item, _groupSource[i]);
 
				if (j == -1)
				{
					continue;
				}
 
				return NSIndexPath.Create(i, j);
			}
 
			return NSIndexPath.Create(-1, -1);
		}
 
		public object Group(NSIndexPath indexPath)
		{
			return _groupSource[indexPath.Section];
		}
 
		public IItemsViewSource GroupItemsViewSource(NSIndexPath indexPath)
		{
			return _groups[indexPath.Section];
		}
 
		public int ItemCountInGroup(nint group)
		{
			return GetGroupCount((int)group);
		}
 
		public void Dispose()
		{
			Dispose(true);
		}
 
		protected virtual void Dispose(bool disposing)
		{
			if (_disposed)
			{
				return;
			}
 
			_disposed = true;
 
			if (disposing)
			{
				ClearGroupTracking();
				if (_groupSource is INotifyCollectionChanged incc)
				{
					incc.CollectionChanged -= CollectionChanged;
				}
			}
		}
 
		void ClearGroupTracking()
		{
			for (int n = _groups.Count - 1; n >= 0; n--)
			{
				_groups[n].Dispose();
				_groups.RemoveAt(n);
			}
		}
 
		void ResetGroupTracking()
		{
			if (!_collectionViewController.TryGetTarget(out var controller))
				return;
 
			ClearGroupTracking();
 
			for (int n = 0; n < _groupSource.Count; n++)
			{
				if (_groupSource[n] is INotifyCollectionChanged && _groupSource[n] is IEnumerable list)
				{
					_groups.Add(new ObservableItemsSource(list, controller, n));
				}
			}
		}
 
		void CollectionChanged(object sender, NotifyCollectionChangedEventArgs args)
		{
			if (!ObserveChanges)
			{
				return;
			}
 
			if (!ApplicationModel.MainThread.IsMainThread)
			{
				ApplicationModel.MainThread.BeginInvokeOnMainThread(() => CollectionChanged(args));
			}
			else
			{
				CollectionChanged(args);
			}
		}
 
		void CollectionChanged(NotifyCollectionChangedEventArgs args)
		{
			if (!_collectionViewController.TryGetTarget(out var controller))
				return;
 
			// Force UICollectionView to get the internal accounting straight
			var collectionView = controller.CollectionView;
			if (!collectionView.Hidden)
			{
				var numberOfSections = collectionView.NumberOfSections();
				for (int section = 0; section < numberOfSections; section++)
				{
					collectionView.NumberOfItemsInSection(section);
				}
			}
 
			switch (args.Action)
			{
				case NotifyCollectionChangedAction.Add:
					Add(args);
					break;
				case NotifyCollectionChangedAction.Remove:
					Remove(args);
					break;
				case NotifyCollectionChangedAction.Replace:
					Replace(args);
					break;
				case NotifyCollectionChangedAction.Move:
					Move(args);
					break;
				case NotifyCollectionChangedAction.Reset:
					Reload(true);
					break;
				default:
					throw new ArgumentOutOfRangeException();
			}
		}
 
		void Reload(bool collectionWasReset = false)
		{
			ResetGroupTracking();
 
			_groupCount = GroupsCount();
 
			_collectionView.ReloadData();
			if (collectionWasReset)
			{
				_collectionView.LayoutIfNeeded();
			}
 
			_collectionView.CollectionViewLayout.InvalidateLayout();
		}
 
		NSIndexSet CreateIndexSetFrom(int startIndex, int count)
		{
			return NSIndexSet.FromNSRange(new NSRange(startIndex, count));
		}
 
		bool NotLoadedYet()
		{
			if (!_collectionViewController.TryGetTarget(out var controller))
				return false;
			// If the UICollectionView hasn't actually been loaded, then calling InsertSections or DeleteSections is 
			// going to crash or get in an unusable state; instead, ReloadData should be used
			return !controller.IsViewLoaded || controller.View.Window == null;
		}
 
		void Add(NotifyCollectionChangedEventArgs args)
		{
			if (NotLoadedYet())
			{
				Reload();
				return;
			}
 
			var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : _groupSource.IndexOf(args.NewItems[0]);
			var count = args.NewItems.Count;
			_groupCount += count;
 
			// Adding a group will change the section index for all subsequent groups, so the easiest thing to do
			// is to reset all the group tracking to get it up-to-date
			ResetGroupTracking();
 
			// Queue up the updates to the UICollectionView
			Update(() => _collectionView.InsertSections(CreateIndexSetFrom(startIndex, count)));
		}
 
		void Remove(NotifyCollectionChangedEventArgs args)
		{
			var startIndex = args.OldStartingIndex;
 
			if (startIndex < 0)
			{
				// INCC implementation isn't giving us enough information to know where the removed items were in the
				// collection. So the best we can do is a complete reload
				Reload();
				return;
			}
 
			if (ReloadRequired())
			{
				Reload();
				return;
			}
 
			// Removing a group will change the section index for all subsequent groups, so the easiest thing to do
			// is to reset all the group tracking to get it up-to-date
			ResetGroupTracking();
 
			// Since we have a start index, we can be more clever about removing the item(s) (and get the nifty animations)
			var count = args.OldItems.Count;
			_groupCount -= count;
 
			// Queue up the updates to the UICollectionView
			Update(() => _collectionView.DeleteSections(CreateIndexSetFrom(startIndex, count)));
		}
 
		void Replace(NotifyCollectionChangedEventArgs args)
		{
			var newCount = args.NewItems.Count;
 
			if (newCount == args.OldItems.Count)
			{
				ResetGroupTracking();
 
				var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : _groupSource.IndexOf(args.NewItems[0]);
 
				// We are replacing one set of items with a set of equal size; we can do a simple item range update
				Update(() => _collectionView.ReloadSections(CreateIndexSetFrom(startIndex, newCount)));
				return;
			}
 
			// The original and replacement sets are of unequal size; this means that everything currently in view will 
			// have to be updated. So we just have to use ReloadData and let the UICollectionView update everything
			Reload();
		}
 
		void Move(NotifyCollectionChangedEventArgs args)
		{
			var count = args.NewItems.Count;
 
			ResetGroupTracking();
 
			if (count == 1)
			{
				// For a single item, we can use MoveSection and get the animation
				Update(() => _collectionView.MoveSection(args.OldStartingIndex, args.NewStartingIndex));
				return;
			}
 
			var start = Math.Min(args.OldStartingIndex, args.NewStartingIndex);
			var end = Math.Max(args.OldStartingIndex, args.NewStartingIndex) + count;
 
			Update(() => _collectionView.ReloadSections(CreateIndexSetFrom(start, end)));
		}
 
		int GetGroupCount(int groupIndex)
		{
			if (groupIndex < 0 || groupIndex >= _groupSource.Count)
			{
				return 0;
			}
 
			switch (_groupSource[groupIndex])
			{
				case IList list:
					return list.Count;
				case IEnumerable enumerable:
					var count = 0;
					var enumerator = enumerable.GetEnumerator();
					while (enumerator.MoveNext())
					{
						count += 1;
					}
					return count;
			}
 
			return 0;
		}
 
		object GetGroupItemAt(int groupIndex, int index)
		{
			if (groupIndex < 0 || groupIndex >= _groupSource.Count)
			{
				return -1;
			}
 
			switch (_groupSource[groupIndex])
			{
				case IList list:
					return list[index];
				case IEnumerable enumerable:
					var count = -1;
					var enumerator = enumerable.GetEnumerator();
 
					do
					{
						enumerator.MoveNext();
						count += 1;
					}
					while (count < index);
 
					return enumerator.Current;
			}
 
			return null;
		}
 
		int IndexInGroup(object item, object group)
		{
			switch (group)
			{
				case IList list:
					return list.IndexOf(item);
				case IEnumerable enumerable:
					var enumerator = enumerable.GetEnumerator();
					var index = 0;
					while (enumerator.MoveNext())
					{
						if (Equals(enumerator.Current, item))
						{
							return index;
						}
					}
					return -1;
			}
 
			return -1;
		}
 
		bool ReloadRequired()
		{
			// If the UICollectionView has never been loaded, or doesn't yet have any sections, any insert/delete operations 
			// are gonna crash hard. We'll need to reload the data instead.
 
			return NotLoadedYet()
				|| _collectionView.NumberOfSections() == 0;
		}
 
		void Update(Action update)
		{
			if (_collectionView.Hidden)
			{
				return;
			}
 
			update();
		}
 
		int GroupsCount()
		{
			if (_groupSource is IList list)
				return list.Count;
 
			int count = 0;
			foreach (var item in _groupSource)
				count++;
			return count;
		}
	}
}