File: iOS\CollectionView\GridViewLayout.cs
Web Access
Project: src\src\Compatibility\Core\src\Compatibility.csproj (Microsoft.Maui.Controls.Compatibility)
using System;
using System.ComponentModel;
using CoreGraphics;
using Foundation;
using ObjCRuntime;
using UIKit;
 
namespace Microsoft.Maui.Controls.Compatibility.Platform.iOS
{
	public class GridViewLayout : ItemsViewLayout
	{
		readonly GridItemsLayout _itemsLayout;
 
		public GridViewLayout(GridItemsLayout itemsLayout, ItemSizingStrategy itemSizingStrategy) : base(itemsLayout, itemSizingStrategy)
		{
			_itemsLayout = itemsLayout;
		}
 
		protected override void HandlePropertyChanged(PropertyChangedEventArgs propertyChanged)
		{
			if (propertyChanged.IsOneOf(GridItemsLayout.SpanProperty, GridItemsLayout.HorizontalItemSpacingProperty,
				GridItemsLayout.VerticalItemSpacingProperty))
			{
				// Update the constraints; ConstrainTo will pick up the new span
				ConstrainTo(CollectionView.Frame.Size);
 
				// And force the UICollectionView to reload everything with the new span
				CollectionView.ReloadData();
			}
 
			base.HandlePropertyChanged(propertyChanged);
		}
 
		public override void ConstrainTo(CGSize size)
		{
			var availableSpace = ScrollDirection == UICollectionViewScrollDirection.Vertical
					? size.Width : size.Height;
 
			var spacing = (nfloat)(ScrollDirection == UICollectionViewScrollDirection.Vertical
					? _itemsLayout.HorizontalItemSpacing
					: _itemsLayout.VerticalItemSpacing);
 
			spacing = ReduceSpacingToFitIfNeeded(availableSpace, spacing, _itemsLayout.Span);
 
			spacing *= (_itemsLayout.Span - 1);
 
			ConstrainedDimension = (availableSpace - spacing) / _itemsLayout.Span;
 
			// We need to truncate the decimal part of ConstrainedDimension
			// or we occasionally run into situations where the rows/columns don't fit	
			// But this can run into situations where we have an extra gap because we're cutting off too much
			// and we have a small gap; need to determine where the cut-off is that leads to layout dropping a whole row/column
			// and see if we can adjust for that
 
			// E.G.: We have a CollectionView that's 532 units tall, and we have a span of 3
			// So we end up with ConstrainedDimension of 177.3333333333333...
			// When UICollectionView lays that out, it can't fit all the rows in so it just gives us two rows.
			// Truncating to 177 means the rows fit, but there's a very slight gap
			// There may not be anything we can do about this.
 
			// Possibly the solution is to round to the tenths or hundredths place, we should look into that. 
			// But for the moment, we need a special case for dimensions < 1, because upon transition from invisible to visible,
			// Forms will briefly layout the CollectionView at a size of 1,1. For a spanned collectionview, that means we 
			// need to accept a constrained dimension of 1/span. If we don't, autolayout will start throwing a flurry of 
			// exceptions (which we can't catch) and either crash the app or spin until we kill the app. 
			if (ConstrainedDimension > 1)
			{
				ConstrainedDimension = (int)ConstrainedDimension;
			}
 
			DetermineCellSize();
		}
 
		/* `CollectionViewContentSize` and `LayoutAttributesForElementsInRect` are overridden here to work around what 
		 * appears to be a bug in the UICollectionViewFlowLayout implementation: for horizontally scrolling grid
		 * layouts with auto-sized cells, trailing items which don't fill out a column are never displayed. 
		 * For example, with a span of 3 and either 4 or 5 items, the resulting layout looks like
		 *
		 * 		Item1 
		 * 		Item2
		 * 		Item3
		 * 
		 * But with 6 items, it looks like
		 * 
		 * 		Item1 Item4
		 * 		Item2 Item5
		 * 		Item3 Item6
		 * 
		 * IOW, if there are not enough items to fill out the last column, the last column is ignored.
		 * 
		 * These overrides detect and correct that situation.	 
		 */
 
		public override CGSize CollectionViewContentSize
		{
			get
			{
				if (!NeedsPartialColumnAdjustment())
				{
					return base.CollectionViewContentSize;
				}
 
				var contentSize = base.CollectionViewContentSize;
 
				// Add space for the missing column at the end
				var correctedSize = new CGSize(contentSize.Width + EstimatedItemSize.Width, contentSize.Height);
 
				return correctedSize;
			}
		}
 
		public override UICollectionViewLayoutAttributes[] LayoutAttributesForElementsInRect(CGRect rect)
		{
			var layoutAttributesForRectElements = base.LayoutAttributesForElementsInRect(rect);
 
			if (NeedsSingleItemHorizontalAlignmentAdjustment(layoutAttributesForRectElements))
			{
				// If there's exactly one item in a vertically scrolling grid, for some reason UICollectionViewFlowLayout
				// tries to center it. This corrects that issue.
				var currentFrame = layoutAttributesForRectElements[0].Frame;
				var newFrame = new CGRect(CollectionView.Frame.Left + CollectionView.ContentInset.Right,
				currentFrame.Top, currentFrame.Width, currentFrame.Height);
				layoutAttributesForRectElements[0].Frame = newFrame;
			}
 
			if (!NeedsPartialColumnAdjustment())
			{
				return layoutAttributesForRectElements;
			}
 
			// When we implement Groups, we'll have to iterate over all of them to adjust and this will
			// be a lot more complicated. But until then, we only have to worry about section 0
			var section = 0;
 
			var itemCount = CollectionView.NumberOfItemsInSection(section);
 
			if (layoutAttributesForRectElements.Length == itemCount)
			{
				return layoutAttributesForRectElements;
			}
 
			var layoutAttributesForAllCells = new UICollectionViewLayoutAttributes[itemCount];
 
			layoutAttributesForRectElements.CopyTo(layoutAttributesForAllCells, 0);
 
			for (int i = layoutAttributesForRectElements.Length; i < layoutAttributesForAllCells.Length; i++)
			{
				layoutAttributesForAllCells[i] = LayoutAttributesForItem(NSIndexPath.FromItemSection(i, section));
			}
 
			return layoutAttributesForAllCells;
		}
 
		public override UICollectionViewLayoutInvalidationContext GetInvalidationContext(UICollectionViewLayoutAttributes preferredAttributes, UICollectionViewLayoutAttributes originalAttributes)
		{
			var invalidationContext = base.GetInvalidationContext(preferredAttributes, originalAttributes);
 
			if (invalidationContext.InvalidatedItemIndexPaths == null)
			{
				return invalidationContext;
			}
 
			if (invalidationContext.InvalidatedItemIndexPaths.Length == 0)
			{
				return invalidationContext;
			}
 
			if (ScrollDirection == UICollectionViewScrollDirection.Horizontal
				&& preferredAttributes.Frame.Width - originalAttributes.Frame.Width > 1)
			{
				// If this is a horizontal grid and we're laying out or adjusting a cell 
				// and we've decided it needs to be wider, then this might throw off the alignment of
				// any cells above it in the layout. We'll need to recenter those cells
				CenterAlignCellsInColumn(preferredAttributes);
 
				// (Technically speaking, we _could_ simply add the cells above the current cell to the invalidationContext;
				// after invalidation, they would be realigned correctly. But doing that causes subsequent calls to 
				// GetInvalidationContext to happen every time a new column needs layout, and those calls will include
				// _every single subsequent cell in the collection_ in the invalidation list. For very large collections,
				// this gets really slow and the scrolling becomes jerky. This one-time realignment is much faster.
			}
 
			return invalidationContext;
		}
 
		public override nfloat GetMinimumInteritemSpacingForSection(UICollectionView collectionView, UICollectionViewLayout layout, nint section)
		{
			var requestedSpacing = ScrollDirection == UICollectionViewScrollDirection.Horizontal
				? (nfloat)_itemsLayout.VerticalItemSpacing
				: (nfloat)_itemsLayout.HorizontalItemSpacing;
 
			var availableSpace = ScrollDirection == UICollectionViewScrollDirection.Horizontal
				? collectionView.Frame.Height
				: collectionView.Frame.Width;
 
			return ReduceSpacingToFitIfNeeded(availableSpace, requestedSpacing, _itemsLayout.Span);
		}
 
		void CenterAlignCellsInColumn(UICollectionViewLayoutAttributes preferredAttributes)
		{
			// Determine the set of cells above this one
			var index = preferredAttributes.IndexPath;
			var span = _itemsLayout.Span;
 
			var column = index.Item / span;
			var start = (int)column * span;
 
			// If this is the first cell in the column, we don't need to adjust
			if (index.Item > start)
			{
				var currentCenter = preferredAttributes.Frame.GetMidX();
 
				// Work our way through the column
				for (int n = start; n < index.Item; n++)
				{
					// Get the layout attributes for each cell
					var path = NSIndexPath.FromItemSection(n, index.Section);
					var attr = LayoutAttributesForItem(path);
 
					// And see if the midpoints line up with the new layout attributes for the current cell
					var center = attr.Frame.GetMidX();
 
					if (currentCenter - center > 1)
					{
						// If the midpoints don't line up (withing a tolerance), adjust the cell's frame
						var cell = CollectionView.CellForItem(path);
						cell.Frame = new CGRect(currentCenter - cell.Frame.Width / 2, cell.Frame.Top, cell.Frame.Width, cell.Frame.Height);
					}
				}
			}
		}
 
		bool NeedsSingleItemHorizontalAlignmentAdjustment(UICollectionViewLayoutAttributes[] layoutAttributesForRectElements)
		{
			if (ScrollDirection == UICollectionViewScrollDirection.Horizontal)
			{
				return false;
			}
 
			if (layoutAttributesForRectElements.Length != 1)
			{
				return false;
			}
 
			if (layoutAttributesForRectElements[0].Frame.Top != CollectionView.Frame.Top + CollectionView.ContentInset.Bottom)
			{
				return false;
			}
 
			return true;
		}
 
		bool NeedsPartialColumnAdjustment(int section = 0)
		{
			if (ScrollDirection == UICollectionViewScrollDirection.Vertical)
			{
				// The bug only occurs with Horizontal scrolling
				return false;
			}
 
			if (CollectionView.NumberOfSections() == 0)
			{
				// And it only happens if there are items
				return false;
			}
 
			if (EstimatedItemSize.IsEmpty)
			{
				// The bug only occurs when using Autolayout; with a set ItemSize, we don't have to worry about it
				return false;
			}
 
			if (CollectionView.NumberOfSections() == 0)
				return false;
 
			var itemCount = CollectionView.NumberOfItemsInSection(section);
 
			if (itemCount < _itemsLayout.Span)
			{
				// If there is just one partial column, no problem; UICollectionViewFlowLayout gets it right
				return false;
			}
 
			if (itemCount % _itemsLayout.Span == 0)
			{
				// All of the columns are full; the bug only occurs when we have a partial column
				return false;
			}
 
			return true;
		}
 
		static nfloat ReduceSpacingToFitIfNeeded(nfloat available, nfloat requestedSpacing, int span)
		{
			if (span == 1)
			{
				return requestedSpacing;
			}
 
			var maxSpacing = (available - span) / (span - 1);
 
			if (maxSpacing < 0)
			{
				return 0;
			}
 
			return (nfloat)Math.Min(requestedSpacing, maxSpacing);
		}
	}
}