Skip to content

Commit

Permalink
[net7.0] Make CollectionView on iOS measure to content size (#15652)
Browse files Browse the repository at this point in the history
* Make CollectionView on iOS measure to content size (#14951)

* Make CollectionView on iOS measure to content size
Fixes #9135

* Make tests work when device is in landscape

* Auto-format source code

* Removed extra local variable

* Update src/Controls/src/Core/Handlers/Items/ItemsViewHandler.iOS.cs

Co-authored-by: Pedro Jesus <pedrojesus.cefet@gmail.com>

* Handle height/width invalidation checks independently

---------

Co-authored-by: GitHub Actions Autoformatter <autoformat@example.com>
Co-authored-by: Pedro Jesus <pedrojesus.cefet@gmail.com>

* Remove unused member

* Fix alignment check

---------

Co-authored-by: GitHub Actions Autoformatter <autoformat@example.com>
Co-authored-by: Pedro Jesus <pedrojesus.cefet@gmail.com>
Co-authored-by: Javier Suárez <javiersuarezruiz@hotmail.com>
  • Loading branch information
4 people authored Aug 16, 2023
1 parent 17c7d6c commit 20097e8
Show file tree
Hide file tree
Showing 6 changed files with 257 additions and 0 deletions.
30 changes: 30 additions & 0 deletions src/Controls/src/Core/Handlers/Items/ItemsViewHandler.iOS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Text;
using Foundation;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Handlers;
using ObjCRuntime;
using UIKit;
Expand Down Expand Up @@ -144,5 +145,34 @@ protected bool IsIndexPathValid(NSIndexPath indexPath)

return true;
}

public override Size GetDesiredSize(double widthConstraint, double heightConstraint)
{
var size = base.GetDesiredSize(widthConstraint, heightConstraint);

var potentialContentSize = Controller.GetSize();

// If contentSize comes back null, it means none of the content has been realized yet;
// we need to return the expansive size the collection view wants by default to get
// it to start measuring its content
if (potentialContentSize == null)
{
return size;
}

var contentSize = potentialContentSize.Value;

// If contentSize does have a value, our target size is the smaller of it and the constraints

size.Width = contentSize.Width <= widthConstraint ? contentSize.Width : widthConstraint;
size.Height = contentSize.Height <= heightConstraint ? contentSize.Height : heightConstraint;

var virtualView = this.VirtualView as IView;

size.Width = ViewHandlerExtensions.ResolveConstraints(size.Width, virtualView.Width, virtualView.MinimumWidth, virtualView.MaximumWidth);
size.Height = ViewHandlerExtensions.ResolveConstraints(size.Height, virtualView.Height, virtualView.MinimumHeight, virtualView.MaximumHeight);

return size;
}
}
}
58 changes: 58 additions & 0 deletions src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public abstract class ItemsViewController<TItemsView> : UICollectionViewControll
bool _emptyViewDisplayed;
bool _disposed;

CGSize _previousContentSize = CGSize.Empty;

UIView _emptyUIView;
VisualElement _emptyViewFormsElement;
Dictionary<object, TemplatedCell> _measurementCells = new Dictionary<object, TemplatedCell>();
Expand Down Expand Up @@ -173,9 +175,61 @@ public override void ViewWillLayoutSubviews()
{
ConstrainToItemsView();
base.ViewWillLayoutSubviews();
InvalidateMeasureIfContentSizeChanged();
LayoutEmptyView();
}

void InvalidateMeasureIfContentSizeChanged()
{
var contentSize = CollectionView.CollectionViewLayout.CollectionViewContentSize;

bool widthChanged = _previousContentSize.Width != contentSize.Width;
bool heightChanged = _previousContentSize.Height != contentSize.Height;

if (_initialized && (widthChanged || heightChanged))
{
var screenFrame = CollectionView.Window.Frame;
var screenWidth = screenFrame.Width;
var screenHeight = screenFrame.Height;
bool invalidate = false;

// If both the previous content size and the current content size are larger
// than the screen size, then we know that we're already maxed out and the
// CollectionView items are scrollable. There's no reason to force an invalidation
// of the CollectionView to expand/contract it.

// If either size is smaller than that, we need to invalidate to ensure that the
// CollectionView is re-measured and set to the correct size.

if (widthChanged && (contentSize.Width < screenWidth || _previousContentSize.Width < screenWidth))
{
invalidate = true;
}

if (heightChanged && (contentSize.Height < screenHeight || _previousContentSize.Height < screenHeight))
{
invalidate = true;
}

if (invalidate)
{
(ItemsView as IView).InvalidateMeasure();
}
}

_previousContentSize = contentSize;
}

internal Size? GetSize()
{
if (_emptyViewDisplayed)
{
return _emptyUIView.Frame.Size.ToSize();
}

return CollectionView.CollectionViewLayout.CollectionViewContentSize.ToSize();
}

void ConstrainToItemsView()
{
var itemsViewWidth = ItemsView.Width;
Expand Down Expand Up @@ -226,8 +280,11 @@ public virtual void UpdateItemsSource()
ItemsViewLayout?.ClearCellSizeCache();
ItemsSource?.Dispose();
ItemsSource = CreateItemsViewSource();

CollectionView.ReloadData();
CollectionView.CollectionViewLayout.InvalidateLayout();

(ItemsView as IView)?.InvalidateMeasure();
}

public virtual void UpdateFlowDirection()
Expand All @@ -242,6 +299,7 @@ public virtual void UpdateFlowDirection()
Layout.InvalidateLayout();
}


public override nint NumberOfSections(UICollectionView collectionView)
{
CheckForEmptySource();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
override Microsoft.Maui.Controls.Platform.Compatibility.ShellPageRendererTracker.TitleViewContainer.LayoutSubviews() -> void
override Microsoft.Maui.Controls.View.ChangeVisualState() -> void
~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRootRenderer.TraitCollectionDidChange(UIKit.UITraitCollection previousTraitCollection) -> void

override Microsoft.Maui.Controls.Handlers.Items.ItemsViewHandler<TItemsView>.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
override Microsoft.Maui.Controls.Platform.Compatibility.ShellPageRendererTracker.TitleViewContainer.LayoutSubviews() -> void
override Microsoft.Maui.Controls.View.ChangeVisualState() -> void
~override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRootRenderer.TraitCollectionDidChange(UIKit.UITraitCollection previousTraitCollection) -> void

override Microsoft.Maui.Controls.Handlers.Items.ItemsViewHandler<TItemsView>.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using Microsoft.Maui.Controls;
using Xunit.Abstractions;

namespace Microsoft.Maui.DeviceTests
{
public class CollectionViewSizingTestCase : IXunitSerializable
{
public LayoutOptions LayoutOptions { get; private set; }
public LinearItemsLayout ItemsLayout { get; private set; }

public CollectionViewSizingTestCase() { }

public CollectionViewSizingTestCase(LayoutOptions layoutOptions, LinearItemsLayout linearItemsLayout)
{
LayoutOptions = layoutOptions;
ItemsLayout = linearItemsLayout;
}

public void Deserialize(IXunitSerializationInfo info)
{
var orientationString = info.GetValue<string>(nameof(ItemsLayout));
var orientation = (ItemsLayoutOrientation)Enum.Parse(typeof(ItemsLayoutOrientation), orientationString);
ItemsLayout = new LinearItemsLayout(orientation);

var alignmentString = info.GetValue<string>(nameof(LayoutOptions));
var alignment = (LayoutAlignment)Enum.Parse(typeof(LayoutAlignment), alignmentString);
LayoutOptions = new LayoutOptions(alignment, false);
}

public void Serialize(IXunitSerializationInfo info)
{
info.AddValue(nameof(LayoutOptions), LayoutOptions.Alignment.ToString(), typeof(string));
info.AddValue(nameof(ItemsLayout), ItemsLayout.Orientation.ToString(), typeof(string));
}

public override string ToString()
{
var optionsString = LayoutOptions.Alignment.ToString();
return $"{ItemsLayout.Orientation}, {optionsString}";
}
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Handlers.Items;
using Microsoft.Maui.DeviceTests.Stubs;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Handlers;
using Microsoft.Maui.Hosting;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.Maui.DeviceTests
{
Expand All @@ -23,6 +27,7 @@ void SetupBuilder()
{
handlers.AddHandler<CollectionView, CollectionViewHandler>();
handlers.AddHandler<VerticalStackLayout, LayoutHandler>();
handlers.AddHandler<Grid, LayoutHandler>();
handlers.AddHandler<Label, LabelHandler>();
});
});
Expand Down Expand Up @@ -71,5 +76,121 @@ await CreateHandlerAndAddToWindow<CollectionViewHandler>(collectionView, async h
Assert.NotNull(logicalChildren);
Assert.True(logicalChildren.Count <= 3, "_logicalChildren should not grow in size!");
}

[Theory]
[MemberData(nameof(GenerateLayoutOptionsCombos))]
public async Task CollectionViewCanSizeToContent(CollectionViewSizingTestCase testCase)
{
// The goal of this test is to create a CollectionView inside a container with each combination of
// ItemsLayout (vertical or horizontal collection) and LayoutAlignment (Fill, Center, etc).
// And then layout that CollectionView using a fixed-size template and different sizes of collection

// At each collection size, we check the size of the CollectionView to verify that it's laying out
// at its content size, or at the size of the container (if the number of items is sufficiently large)

var itemsLayout = testCase.ItemsLayout;
var layoutOptions = testCase.LayoutOptions;

double templateHeight = 50;
double templateWidth = 50;

double containerHeight = 500;
double containerWidth = 500;

int[] itemCounts = new int[] { 1, 2, 12, 0 };

double tolerance = 1;

SetupBuilder();

var collectionView = new CollectionView
{
ItemsLayout = itemsLayout,
ItemTemplate = new DataTemplate(() => new Label() { HeightRequest = templateHeight, WidthRequest = templateWidth }),
};

if (itemsLayout.Orientation == ItemsLayoutOrientation.Horizontal)
{
collectionView.HorizontalOptions = layoutOptions;
}
else
{
collectionView.VerticalOptions = layoutOptions;
}

var layout = new Grid() { IgnoreSafeArea = true, HeightRequest = containerHeight, WidthRequest = containerWidth };
layout.Add(collectionView);

ObservableCollection<string> data = new();

var frame = collectionView.Frame;

await CreateHandlerAndAddToWindow<LayoutHandler>(layout, async handler =>
{
for (int n = 0; n < itemCounts.Length; n++)
{
int itemsCount = itemCounts[n];
GenerateItems(itemsCount, data);
collectionView.ItemsSource = data;
await WaitForUIUpdate(frame, collectionView);
frame = collectionView.Frame;
double expectedWidth = layoutOptions.Alignment == LayoutAlignment.Fill
? containerWidth
: Math.Min(itemsCount * templateWidth, containerWidth);
double expectedHeight = layoutOptions.Alignment == LayoutAlignment.Fill
? containerHeight
: Math.Min(itemsCount * templateHeight, containerHeight);
if (itemsLayout.Orientation == ItemsLayoutOrientation.Horizontal)
{
Assert.Equal(expectedWidth, collectionView.Width, tolerance);
}
else
{
Assert.Equal(expectedHeight, collectionView.Height, tolerance);
}
}
});
}

public static IEnumerable<object[]> GenerateLayoutOptionsCombos()
{
var layoutOptions = new LayoutOptions[] { LayoutOptions.Center, LayoutOptions.Start, LayoutOptions.End, LayoutOptions.Fill };

foreach (var option in layoutOptions)
{
yield return new object[] { new CollectionViewSizingTestCase(option, new LinearItemsLayout(ItemsLayoutOrientation.Horizontal)) };
yield return new object[] { new CollectionViewSizingTestCase(option, new LinearItemsLayout(ItemsLayoutOrientation.Vertical)) };
yield return new object[] { new CollectionViewSizingTestCase(option, new LinearItemsLayout(ItemsLayoutOrientation.Horizontal)) };
yield return new object[] { new CollectionViewSizingTestCase(option, new LinearItemsLayout(ItemsLayoutOrientation.Vertical)) };
}
}

static void GenerateItems(int count, ObservableCollection<string> data)
{
if (data.Count > count)
{
data.Clear();
}

for (int n = data.Count; n < count; n++)
{
data.Add($"Item {n}");
}
}

static async Task WaitForUIUpdate(Rect frame, CollectionView collectionView, int timeout = 1000, int interval = 100)
{
// Wait for layout to happen
while (collectionView.Frame == frame && timeout >= 0)
{
await Task.Delay(interval);
timeout -= interval;
}
}
}
}

0 comments on commit 20097e8

Please sign in to comment.