File: Handlers\Items\iOS\ItemsViewLayout.cs
Web Access
Project: src\src\Controls\src\Core\Controls.Core.csproj (Microsoft.Maui.Controls)
#nullable disable
using System;
using System.Collections.Generic;
using System.ComponentModel;
using CoreGraphics;
using Foundation;
using Microsoft.Extensions.Logging;
using UIKit;
 
namespace Microsoft.Maui.Controls.Handlers.Items
{
	public abstract class ItemsViewLayout : UICollectionViewFlowLayout
	{
		readonly ItemsLayout _itemsLayout;
		bool _disposed;
		bool _adjustContentOffset;
		CGSize _adjustmentSize0;
		CGSize _adjustmentSize1;
		CGSize _currentSize;
		WeakReference<Func<UICollectionViewCell>> _getPrototype;
 
		WeakReference<Func<NSIndexPath, UICollectionViewCell>> _getPrototypeForIndexPath;
 
		readonly Dictionary<object, CGSize> _cellSizeCache = new();
 
		public ItemsUpdatingScrollMode ItemsUpdatingScrollMode { get; set; }
 
		public nfloat ConstrainedDimension { get; set; }
 
		public Func<UICollectionViewCell> GetPrototype
		{
			get => _getPrototype is not null && _getPrototype.TryGetTarget(out var func) ? func : null;
			set => _getPrototype = new(value);
		}
 
		internal Func<NSIndexPath, UICollectionViewCell> GetPrototypeForIndexPath
		{
			get => _getPrototypeForIndexPath is not null && _getPrototypeForIndexPath.TryGetTarget(out var func) ? func : null;
			set => _getPrototypeForIndexPath = new(value);
		}
 
		internal ItemSizingStrategy ItemSizingStrategy { get; private set; }
 
		protected ItemsViewLayout(ItemsLayout itemsLayout, ItemSizingStrategy itemSizingStrategy = ItemSizingStrategy.MeasureFirstItem)
		{
			ItemSizingStrategy = itemSizingStrategy;
 
			_itemsLayout = itemsLayout;
			_itemsLayout.PropertyChanged += LayoutOnPropertyChanged;
 
			var scrollDirection = itemsLayout.Orientation == ItemsLayoutOrientation.Horizontal
				? UICollectionViewScrollDirection.Horizontal
				: UICollectionViewScrollDirection.Vertical;
 
			Initialize(scrollDirection);
 
			if (OperatingSystem.IsIOSVersionAtLeast(11) || OperatingSystem.IsMacCatalystVersionAtLeast(11)
#if TVOS
				|| OperatingSystem.IsTvOSVersionAtLeast(11)
#endif
			)
			{
				// `ContentInset` is actually the default value, but I'm leaving this here as a note to
				// future maintainers; it's likely that someone will want a Platform Specific to change this behavior
				// (Setting it to `SafeArea` lets you do the thing where the header/footer of your UICollectionView
				// fills the screen width in landscape while your items are automatically shifted to avoid the notch)
				SectionInsetReference = UICollectionViewFlowLayoutSectionInsetReference.ContentInset;
			}
		}
 
		public override bool FlipsHorizontallyInOppositeLayoutDirection => true;
 
		protected override void Dispose(bool disposing)
		{
			if (_disposed)
			{
				return;
			}
 
			_disposed = true;
 
			if (disposing)
			{
				if (_itemsLayout != null)
				{
					_itemsLayout.PropertyChanged -= LayoutOnPropertyChanged;
				}
			}
 
			base.Dispose(disposing);
		}
 
		void LayoutOnPropertyChanged(object sender, PropertyChangedEventArgs propertyChanged)
		{
			HandlePropertyChanged(propertyChanged);
		}
 
		protected virtual void HandlePropertyChanged(PropertyChangedEventArgs propertyChanged)
		{
			if (propertyChanged.IsOneOf(LinearItemsLayout.ItemSpacingProperty,
				GridItemsLayout.HorizontalItemSpacingProperty, GridItemsLayout.VerticalItemSpacingProperty))
			{
				UpdateItemSpacing();
			}
		}
 
		internal virtual bool UpdateConstraints(CGSize size)
		{
			if (size.IsCloseTo(_currentSize))
			{
				return false;
			}
 
			ClearCellSizeCache();
 
			EstimatedItemSize = CGSize.Empty;
			
			_currentSize = size;
 
			var newSize = new CGSize(Math.Floor(size.Width), Math.Floor(size.Height));
			ConstrainTo(newSize);
 
			UpdateCellConstraints();
			return true;
		}
 
		internal void SetInitialConstraints(CGSize size)
		{
			_currentSize = size;
			ConstrainTo(size);
		}
 
		public abstract void ConstrainTo(CGSize size);
 
		public virtual UIEdgeInsets GetInsetForSection(UICollectionView collectionView, UICollectionViewLayout layout,
			nint section)
		{
			// If we're at the last section, we don't need to add the right inset
			if (section >= (collectionView.NumberOfSections() - 1))
			{
				return UIEdgeInsets.Zero;
			}
 
			if (_itemsLayout is GridItemsLayout gridItemsLayout)
			{
				if (ScrollDirection == UICollectionViewScrollDirection.Horizontal)
				{
					return new UIEdgeInsets(0, 0, 0, new nfloat(gridItemsLayout.HorizontalItemSpacing));
				}
 
				return new UIEdgeInsets(0, 0, new nfloat(gridItemsLayout.VerticalItemSpacing), 0);
			}
			else if (_itemsLayout is LinearItemsLayout listViewLayout)
			{
				if (ScrollDirection == UICollectionViewScrollDirection.Horizontal)
				{
					return new UIEdgeInsets(0, 0, 0, new nfloat(listViewLayout.ItemSpacing));
				}
 
				return new UIEdgeInsets(0, 0, new nfloat(listViewLayout.ItemSpacing), 0);
			}
			else
			{
				return UIEdgeInsets.Zero;
			}
		}
 
		public virtual nfloat GetMinimumInteritemSpacingForSection(UICollectionView collectionView,
			UICollectionViewLayout layout, nint section)
		{
			return (nfloat)0.0;
		}
 
		public virtual nfloat GetMinimumLineSpacingForSection(UICollectionView collectionView,
			UICollectionViewLayout layout, nint section)
		{
			if (_itemsLayout is LinearItemsLayout listViewLayout)
			{
				return (nfloat)listViewLayout.ItemSpacing;
			}
 
			if (_itemsLayout is GridItemsLayout gridItemsLayout)
			{
				if (ScrollDirection == UICollectionViewScrollDirection.Horizontal)
				{
					return (nfloat)gridItemsLayout.HorizontalItemSpacing;
				}
 
				return (nfloat)gridItemsLayout.VerticalItemSpacing;
			}
 
			return (nfloat)0.0;
		}
 
		public void PrepareCellForLayout(ItemsViewCell cell)
		{
			if (EstimatedItemSize == CGSize.Empty)
			{
				cell.ConstrainTo(ItemSize);
			}
			else
			{
				cell.ConstrainTo(ConstrainedDimension);
			}
		}
 
		public override bool ShouldInvalidateLayout(UICollectionViewLayoutAttributes preferredAttributes, UICollectionViewLayoutAttributes originalAttributes)
		{
			// This is currently causing an infinite layout loop on iOS 15 https://github.com/dotnet/maui/issues/6566
			if (preferredAttributes.RepresentedElementKind == "UICollectionElementKindSectionHeader" && OperatingSystem.IsIOSVersionAtLeast(15))
				return base.ShouldInvalidateLayout(preferredAttributes, originalAttributes);
 
			if (ItemSizingStrategy == ItemSizingStrategy.MeasureAllItems)
			{
				if (preferredAttributes.Bounds != originalAttributes.Bounds)
				{
					return true;
				}
			}
 
			return base.ShouldInvalidateLayout(preferredAttributes, originalAttributes);
		}
 
		protected void DetermineCellSize()
		{
			if (GetPrototype == null)
			{
				return;
			}
 
			// We set the EstimatedItemSize here for two reasons:
			// 1. If we don't set it, iOS versions below 10 will crash
			// 2. If GetPrototype() cannot return a cell because the items source is empty, we need to have
			//		an estimate set so that when a cell _does_ become available (i.e., when the items source
			//		has at least one item), Autolayout will kick in for the first cell and size it correctly
			// If GetPrototype() _can_ return a cell, this estimate will be updated once that cell is measured
			if (EstimatedItemSize == CGSize.Empty)
			{
				EstimatedItemSize = new CGSize(1, 1);
			}
 
			ItemsViewCell prototype = null;
 
			if (CollectionView?.VisibleCells.Length > 0)
			{
				prototype = CollectionView.VisibleCells[0] as ItemsViewCell;
			}
 
			if (prototype == null)
			{
				prototype = GetPrototype() as ItemsViewCell;
			}
 
			if (prototype == null)
			{
				return;
			}
 
			// Constrain and measure the prototype cell
			prototype.ConstrainTo(ConstrainedDimension);
			var measure = prototype.Measure();
 
			if (ItemSizingStrategy == ItemSizingStrategy.MeasureFirstItem)
			{
				// This is the size we'll give all of our cells from here on out
				ItemSize = measure;
 
				// Make sure autolayout is disabled 
				EstimatedItemSize = CGSize.Empty;
			}
			else
			{
				// Autolayout is now enabled, and this is the size used to guess scrollbar size and progress
				measure = TryFindEstimatedSize(measure);
				EstimatedItemSize = measure;
			}
		}
 
		void Initialize(UICollectionViewScrollDirection scrollDirection)
		{
			ScrollDirection = scrollDirection;
		}
 
		protected void UpdateCellConstraints()
		{
			PrepareCellsForLayout(CollectionView.VisibleCells);
			PrepareCellsForLayout(CollectionView.GetVisibleSupplementaryViews(UICollectionElementKindSectionKey.Header));
			PrepareCellsForLayout(CollectionView.GetVisibleSupplementaryViews(UICollectionElementKindSectionKey.Footer));
		}
 
		void PrepareCellsForLayout(UICollectionReusableView[] cells)
		{
			for (int n = 0; n < cells.Length; n++)
			{
				if (cells[n] is ItemsViewCell constrainedCell)
				{
					PrepareCellForLayout(constrainedCell);
				}
			}
		}
 
		public override CGPoint TargetContentOffset(CGPoint proposedContentOffset, CGPoint scrollingVelocity)
		{
			var snapPointsType = _itemsLayout.SnapPointsType;
 
			if (snapPointsType == SnapPointsType.None)
			{
				// Nothing to do here; fall back to the default
				return base.TargetContentOffset(proposedContentOffset, scrollingVelocity);
			}
 
			var alignment = _itemsLayout.SnapPointsAlignment;
 
			if (snapPointsType == SnapPointsType.MandatorySingle)
			{
				// Mandatory snapping, single element
				return ScrollSingle(alignment, proposedContentOffset, scrollingVelocity);
			}
 
			// Get the viewport of the UICollectionView at the proposed content offset
			var viewport = new CGRect(proposedContentOffset, CollectionView.Bounds.Size);
 
			// And find all the elements currently visible in the viewport
			var visibleElements = LayoutAttributesForElementsInRect(viewport);
 
			if (visibleElements.Length == 0)
			{
				// Nothing to see here; fall back to the default
				return base.TargetContentOffset(proposedContentOffset, scrollingVelocity);
			}
 
			if (visibleElements.Length == 1)
			{
				// If there is only one item in the viewport,  then we need to align the viewport with it
				return SnapHelpers.AdjustContentOffset(proposedContentOffset, visibleElements[0].Frame, viewport,
					alignment, ScrollDirection);
			}
 
			// If there are multiple items in the viewport, we need to choose the one which is 
			// closest to the relevant part of the viewport while being sufficiently visible
 
			// Find the spot in the viewport we're trying to align with
			var alignmentTarget = SnapHelpers.FindAlignmentTarget(alignment, proposedContentOffset,
				CollectionView, ScrollDirection);
 
			// Find the closest sufficiently visible candidate
			var bestCandidate = SnapHelpers.FindBestSnapCandidate(visibleElements, viewport, alignmentTarget);
 
			if (bestCandidate != null)
			{
				return SnapHelpers.AdjustContentOffset(proposedContentOffset, bestCandidate.Frame, viewport, alignment,
					ScrollDirection);
			}
 
			// If we got this far an nothing matched, it means that we have multiple items but somehow
			// none of them fit at least half in the viewport. So just fall back to the first item
			return SnapHelpers.AdjustContentOffset(proposedContentOffset, visibleElements[0].Frame, viewport, alignment,
					ScrollDirection);
		}
 
		CGPoint ScrollSingle(SnapPointsAlignment alignment, CGPoint proposedContentOffset, CGPoint scrollingVelocity)
		{
			// Get the viewport of the UICollectionView at the current content offset
			var contentOffset = CollectionView.ContentOffset;
			var viewport = new CGRect(contentOffset, CollectionView.Bounds.Size);
 
			// Find the spot in the viewport we're trying to align with
			var alignmentTarget = SnapHelpers.FindAlignmentTarget(alignment, contentOffset, CollectionView, ScrollDirection);
 
			var visibleElements = LayoutAttributesForElementsInRect(viewport);
 
			// Find the current aligned item
			var currentItem = SnapHelpers.FindBestSnapCandidate(visibleElements, viewport, alignmentTarget);
 
			if (currentItem == null)
			{
				// Somehow we don't currently have an item in the viewport near the target; fall back to the
				// default behavior
				return base.TargetContentOffset(proposedContentOffset, scrollingVelocity);
			}
 
			// Determine the index of the current item
			var currentIndex = visibleElements.IndexOf(currentItem);
 
			// Figure out the step size when jumping to the "next" element 
			var span = 1;
			if (_itemsLayout is GridItemsLayout gridItemsLayout)
			{
				span = gridItemsLayout.Span;
			}
 
			// Find the next item in the
			currentItem = SnapHelpers.FindNextItem(visibleElements, ScrollDirection, span, scrollingVelocity, currentIndex);
 
			return SnapHelpers.AdjustContentOffset(CollectionView.ContentOffset, currentItem.Frame, viewport, alignment,
				ScrollDirection);
		}
 
		protected virtual void UpdateItemSpacing()
		{
			if (_itemsLayout == null)
			{
				return;
			}
 
			InvalidateLayout();
		}
 
		public override UICollectionViewLayoutInvalidationContext GetInvalidationContext(UICollectionViewLayoutAttributes preferredAttributes, UICollectionViewLayoutAttributes originalAttributes)
		{
			if (preferredAttributes.RepresentedElementKind != UICollectionElementKindSectionKey.Header
				&& preferredAttributes.RepresentedElementKind != UICollectionElementKindSectionKey.Footer)
			{
				if (OperatingSystem.IsIOSVersionAtLeast(12) || OperatingSystem.IsTvOSVersionAtLeast(12))
				{
					return base.GetInvalidationContext(preferredAttributes, originalAttributes);
				}
 
				try
				{
					// We only have to do this on older iOS versions; sometimes when removing a cell that's right at the edge
					// of the viewport we'll run into a race condition where the invalidation context will have the removed
					// indexpath. And then things crash. So 
 
					var defaultContext = base.GetInvalidationContext(preferredAttributes, originalAttributes);
					return defaultContext;
				}
				catch (ObjCRuntime.ObjCException ex) when (ex.Name == "NSRangeException")
				{
					Application.Current?.FindMauiContext()?.CreateLogger<ItemsViewLayout>()?.LogWarning(ex, "NSRangeException");
				}
 
				UICollectionViewFlowLayoutInvalidationContext context = new UICollectionViewFlowLayoutInvalidationContext();
				return context;
			}
 
			// Ensure that if this invalidation was triggered by header/footer changes, the header/footer are being invalidated
 
			UICollectionViewFlowLayoutInvalidationContext invalidationContext = new UICollectionViewFlowLayoutInvalidationContext();
			var indexPath = preferredAttributes.IndexPath;
 
			if (preferredAttributes.RepresentedElementKind == UICollectionElementKindSectionKey.Header)
			{
				invalidationContext.InvalidateSupplementaryElements(UICollectionElementKindSectionKey.Header, new[] { indexPath });
			}
			else if (preferredAttributes.RepresentedElementKind == UICollectionElementKindSectionKey.Footer)
			{
				invalidationContext.InvalidateSupplementaryElements(UICollectionElementKindSectionKey.Footer, new[] { indexPath });
			}
 
			return invalidationContext;
		}
 
		public override void PrepareLayout()
		{
			base.PrepareLayout();
 
			// PrepareLayout is the only good place to consistently track the content size changes
			TrackOffsetAdjustment();
		}
 
		public override void PrepareForCollectionViewUpdates(UICollectionViewUpdateItem[] updateItems)
		{
			base.PrepareForCollectionViewUpdates(updateItems);
 
			if (ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepScrollOffset)
			{
				// This is the default behavior for iOS, no need to do anything
				return;
			}
 
			if (ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepItemsInView
			   || ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepLastItemInView)
			{
				// If this update will shift the visible items,  we'll have to adjust for 
				// that later in TargetContentOffsetForProposedContentOffset
				_adjustContentOffset = UpdateWillShiftVisibleItems(CollectionView, updateItems);
			}
		}
 
		public override CGPoint TargetContentOffsetForProposedContentOffset(CGPoint proposedContentOffset)
		{
			if (_adjustContentOffset)
			{
				_adjustContentOffset = false;
 
				// PrepareForCollectionViewUpdates detected that an item update was going to shift the viewport
				// and we want to make sure it stays in place
				return proposedContentOffset + ComputeOffsetAdjustment();
			}
 
			return base.TargetContentOffsetForProposedContentOffset(proposedContentOffset);
		}
 
		public override void FinalizeCollectionViewUpdates()
		{
			base.FinalizeCollectionViewUpdates();
 
			if (ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepLastItemInView)
			{
				ForceScrollToLastItem(CollectionView, _itemsLayout);
			}
		}
 
		void TrackOffsetAdjustment()
		{
			// Keep track of the previous sizes of the CollectionView content so we can adjust the viewport
			// offsets if we're in ItemsUpdatingScrollMode.KeepItemsInView
 
			// We keep track of the last two adjustments because the only place we can consistently track this
			// is PrepareLayout, and by the time PrepareLayout has been called, the CollectionViewContentSize
			// has already been updated
 
			if (_adjustmentSize0.IsEmpty)
			{
				_adjustmentSize0 = CollectionViewContentSize;
			}
			else if (_adjustmentSize1.IsEmpty)
			{
				_adjustmentSize1 = CollectionViewContentSize;
			}
			else
			{
				_adjustmentSize0 = _adjustmentSize1;
				_adjustmentSize1 = CollectionViewContentSize;
			}
		}
 
		CGSize ComputeOffsetAdjustment()
		{
			return CollectionViewContentSize - _adjustmentSize0;
		}
 
		static bool UpdateWillShiftVisibleItems(UICollectionView collectionView, UICollectionViewUpdateItem[] updateItems)
		{
			// Find the first visible item
			var firstPath = collectionView.IndexPathsForVisibleItems.FindFirst();
 
			if (firstPath == null)
			{
				// No visible items to shift
				return false;
			}
 
			// Determine whether any of the new items will be "before" the first visible item
			foreach (var item in updateItems)
			{
				if (item.UpdateAction == UICollectionUpdateAction.Delete
					|| item.UpdateAction == UICollectionUpdateAction.Insert
					|| item.UpdateAction == UICollectionUpdateAction.Move)
				{
					if (item.IndexPathAfterUpdate == null)
					{
						continue;
					}
 
					if (item.IndexPathAfterUpdate.IsLessThanOrEqualToPath(firstPath))
					{
						// If any of these items will end up "before" the first visible item, then the items will shift
						return true;
					}
				}
			}
 
			return false;
		}
 
		static void ForceScrollToLastItem(UICollectionView collectionView, ItemsLayout itemsLayout)
		{
			var sections = (int)collectionView.NumberOfSections();
 
			if (sections == 0)
			{
				return;
			}
 
			for (int section = sections - 1; section >= 0; section--)
			{
				var itemCount = collectionView.NumberOfItemsInSection(section);
				if (itemCount > 0)
				{
					var lastIndexPath = NSIndexPath.FromItemSection(itemCount - 1, section);
 
					if (itemsLayout.Orientation == ItemsLayoutOrientation.Vertical)
						collectionView.ScrollToItem(lastIndexPath, UICollectionViewScrollPosition.Bottom, true);
					else
						collectionView.ScrollToItem(lastIndexPath, UICollectionViewScrollPosition.Right, true);
 
					return;
				}
			}
		}
 
		public override bool ShouldInvalidateLayoutForBoundsChange(CGRect newBounds)
		{
			if (newBounds.Size.IsCloseTo(_currentSize))
			{
				return base.ShouldInvalidateLayoutForBoundsChange(newBounds);
			}
 
			return UpdateConstraints(CollectionView.AdjustedContentInset.InsetRect(newBounds).Size);
		}
 
		internal bool TryGetCachedCellSize(object item, out CGSize size)
		{
			if (_cellSizeCache.TryGetValue(item, out CGSize internalSize))
			{
				size = internalSize;
				return true;
			}
 
			size = CGSize.Empty;
			return false;
		}
 
		internal void CacheCellSize(object item, CGSize size)
		{
			_cellSizeCache[item] = size;
		}
 
		internal void ClearCellSizeCache()
		{
			_cellSizeCache.Clear();
		}
 
		CGSize TryFindEstimatedSize(CGSize existingMeasurement)
		{
			if (CollectionView == null || GetPrototypeForIndexPath == null)
				return existingMeasurement;
 
			//Since this issue only seems to be reproducible on Horizontal scrolling, we only check for that
			if (ScrollDirection == UICollectionViewScrollDirection.Horizontal)
			{
				return FindEstimatedSizeUsingWidth(existingMeasurement);
			}
 
			return existingMeasurement;
		}
 
		CGSize FindEstimatedSizeUsingWidth(CGSize existingMeasurement)
		{
			// TODO: Handle grouping
			var group = 0;
			var collectionViewWidth = CollectionView.Bounds.Width;
			var numberOfItemsInGroup = CollectionView.NumberOfItemsInSection(group);
 
			// Calculate the number of cells that can fit in the viewport
			var numberOfCellsToCheck = Math.Min((int)(collectionViewWidth / existingMeasurement.Width) + 1, numberOfItemsInGroup);
 
			// Iterate through the cells and find the one with a wider width
			for (int i = 1; i < numberOfCellsToCheck; i++)
			{
				var indexPath = NSIndexPath.Create(group, i);
				if (GetPrototypeForIndexPath(indexPath) is ItemsViewCell cellAtIndex)
				{
					cellAtIndex.ConstrainTo(ConstrainedDimension);
					var measureCellAtIndex = cellAtIndex.Measure();
 
					// Check if the cell has a wider width
					if (measureCellAtIndex.Width > existingMeasurement.Width)
					{
						existingMeasurement = measureCellAtIndex;
					}
 
					// TODO: Cache this cell size
				}
			}
 
			return existingMeasurement;
		}
	}
}