Prevent iOS CollectionView size shifts from clearing the cell size cache (#18464)

* Prevent iOS CollectionView size shifts from clearing the cell size cache
Fixes #17890

* Try to exclude tests from macOS
This commit is contained in:
E.Z. Hart 2023-11-22 14:54:31 -07:00 коммит произвёл GitHub
Родитель 95468235b0
Коммит 6a5bbf0806
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
7 изменённых файлов: 210 добавлений и 73 удалений

Просмотреть файл

@ -7,7 +7,6 @@ using CoreGraphics;
using Foundation; using Foundation;
using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Controls.Internals;
using Microsoft.Maui.Graphics; using Microsoft.Maui.Graphics;
using ObjCRuntime;
using UIKit; using UIKit;
namespace Microsoft.Maui.Controls.Handlers.Items namespace Microsoft.Maui.Controls.Handlers.Items
@ -178,12 +177,12 @@ namespace Microsoft.Maui.Controls.Handlers.Items
public override void ViewWillAppear(bool animated) public override void ViewWillAppear(bool animated)
{ {
base.ViewWillAppear(animated); base.ViewWillAppear(animated);
ConstrainToItemsView(); ConstrainItemsToBounds();
} }
public override void ViewWillLayoutSubviews() public override void ViewWillLayoutSubviews()
{ {
ConstrainToItemsView(); ConstrainItemsToBounds();
base.ViewWillLayoutSubviews(); base.ViewWillLayoutSubviews();
InvalidateMeasureIfContentSizeChanged(); InvalidateMeasureIfContentSizeChanged();
LayoutEmptyView(); LayoutEmptyView();
@ -243,7 +242,7 @@ namespace Microsoft.Maui.Controls.Handlers.Items
internal Size? GetSize() internal Size? GetSize()
{ {
if (_emptyViewDisplayed) if (_emptyViewDisplayed)
{ {
return _emptyUIView.Frame.Size.ToSize(); return _emptyUIView.Frame.Size.ToSize();
} }
@ -251,18 +250,11 @@ namespace Microsoft.Maui.Controls.Handlers.Items
return CollectionView.CollectionViewLayout.CollectionViewContentSize.ToSize(); return CollectionView.CollectionViewLayout.CollectionViewContentSize.ToSize();
} }
void ConstrainToItemsView() void ConstrainItemsToBounds()
{ {
var itemsViewWidth = ItemsView.Width; var contentBounds = CollectionView.AdjustedContentInset.InsetRect(CollectionView.Bounds);
var itemsViewHeight = ItemsView.Height; var constrainedSize = contentBounds.Size;
ItemsViewLayout.UpdateConstraints(constrainedSize);
if (itemsViewHeight < 0 || itemsViewWidth < 0)
{
ItemsViewLayout.UpdateConstraints(CollectionView.Bounds.Size);
return;
}
ItemsViewLayout.UpdateConstraints(new CGSize(itemsViewWidth, itemsViewHeight));
} }
void EnsureLayoutInitialized() void EnsureLayoutInitialized()
@ -321,7 +313,6 @@ namespace Microsoft.Maui.Controls.Handlers.Items
Layout.InvalidateLayout(); Layout.InvalidateLayout();
} }
public override nint NumberOfSections(UICollectionView collectionView) public override nint NumberOfSections(UICollectionView collectionView)
{ {
CheckForEmptySource(); CheckForEmptySource();

Просмотреть файл

@ -5,8 +5,6 @@ using System.ComponentModel;
using CoreGraphics; using CoreGraphics;
using Foundation; using Foundation;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Maui.Controls.Internals;
using ObjCRuntime;
using UIKit; using UIKit;
namespace Microsoft.Maui.Controls.Handlers.Items namespace Microsoft.Maui.Controls.Handlers.Items
@ -21,9 +19,7 @@ namespace Microsoft.Maui.Controls.Handlers.Items
CGSize _currentSize; CGSize _currentSize;
WeakReference<Func<UICollectionViewCell>> _getPrototype; WeakReference<Func<UICollectionViewCell>> _getPrototype;
const double ConstraintSizeTolerance = 0.00001; readonly Dictionary<object, CGSize> _cellSizeCache = new();
Dictionary<object, CGSize> _cellSizeCache = new Dictionary<object, CGSize>();
public ItemsUpdatingScrollMode ItemsUpdatingScrollMode { get; set; } public ItemsUpdatingScrollMode ItemsUpdatingScrollMode { get; set; }
@ -102,7 +98,7 @@ namespace Microsoft.Maui.Controls.Handlers.Items
internal virtual void UpdateConstraints(CGSize size) internal virtual void UpdateConstraints(CGSize size)
{ {
if (!RequiresConstraintUpdate(size, _currentSize)) if (size.IsCloseTo(_currentSize))
{ {
return; return;
} }
@ -568,23 +564,12 @@ namespace Microsoft.Maui.Controls.Handlers.Items
public override bool ShouldInvalidateLayoutForBoundsChange(CGRect newBounds) public override bool ShouldInvalidateLayoutForBoundsChange(CGRect newBounds)
{ {
if (newBounds.Size == _currentSize) if(newBounds.Size == _currentSize)
{ {
return base.ShouldInvalidateLayoutForBoundsChange(newBounds); return base.ShouldInvalidateLayoutForBoundsChange(newBounds);
} }
if (OperatingSystem.IsIOSVersionAtLeast(11) || OperatingSystem.IsMacCatalystVersionAtLeast(11) UpdateConstraints(CollectionView.AdjustedContentInset.InsetRect(newBounds).Size);
#if TVOS
|| OperatingSystem.IsTvOSVersionAtLeast(11)
#endif
)
{
UpdateConstraints(CollectionView.AdjustedContentInset.InsetRect(newBounds).Size);
}
else
{
UpdateConstraints(CollectionView.Bounds.Size);
}
return true; return true;
} }
@ -610,20 +595,5 @@ namespace Microsoft.Maui.Controls.Handlers.Items
{ {
_cellSizeCache.Clear(); _cellSizeCache.Clear();
} }
bool RequiresConstraintUpdate(CGSize newSize, CGSize current)
{
if (Math.Abs(newSize.Width - current.Width) > ConstraintSizeTolerance)
{
return true;
}
if (Math.Abs(newSize.Height - current.Height) > ConstraintSizeTolerance)
{
return true;
}
return false;
}
} }
} }

Просмотреть файл

@ -0,0 +1,42 @@
#nullable disable
using System;
using CoreGraphics;
using Microsoft.Maui.Graphics;
namespace Microsoft.Maui.Controls.Handlers.Items
{
internal static class SizeExtensions
{
const double Tolerance = 0.001;
public static bool IsCloseTo(this CGSize sizeA, CGSize sizeB)
{
if (Math.Abs(sizeA.Height - sizeB.Height) > Tolerance)
{
return false;
}
if (Math.Abs(sizeA.Width - sizeB.Width) > Tolerance)
{
return false;
}
return true;
}
public static bool IsCloseTo(this CGSize sizeA, Size sizeB)
{
if (Math.Abs(sizeA.Height - sizeB.Height) > Tolerance)
{
return false;
}
if (Math.Abs(sizeA.Width - sizeB.Width) > Tolerance)
{
return false;
}
return true;
}
}
}

Просмотреть файл

@ -5,7 +5,6 @@ using CoreGraphics;
using Foundation; using Foundation;
using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Controls.Internals;
using Microsoft.Maui.Graphics; using Microsoft.Maui.Graphics;
using ObjCRuntime;
using UIKit; using UIKit;
namespace Microsoft.Maui.Controls.Handlers.Items namespace Microsoft.Maui.Controls.Handlers.Items
@ -66,7 +65,7 @@ namespace Microsoft.Maui.Controls.Handlers.Items
var preferredSize = preferredAttributes.Frame.Size; var preferredSize = preferredAttributes.Frame.Size;
if (SizesAreSame(preferredSize, _size) if (preferredSize.IsCloseTo(_size)
&& AttributesConsistentWithConstrainedDimension(preferredAttributes)) && AttributesConsistentWithConstrainedDimension(preferredAttributes))
{ {
return preferredAttributes; return preferredAttributes;
@ -79,8 +78,6 @@ namespace Microsoft.Maui.Controls.Handlers.Items
OnLayoutAttributesChanged(preferredAttributes); OnLayoutAttributesChanged(preferredAttributes);
//_isMeasured = true;
return preferredAttributes; return preferredAttributes;
} }
@ -290,23 +287,6 @@ namespace Microsoft.Maui.Controls.Handlers.Items
protected abstract bool AttributesConsistentWithConstrainedDimension(UICollectionViewLayoutAttributes attributes); protected abstract bool AttributesConsistentWithConstrainedDimension(UICollectionViewLayoutAttributes attributes);
bool SizesAreSame(CGSize preferredSize, Size elementSize)
{
const double tolerance = 0.000001;
if (Math.Abs(preferredSize.Height - elementSize.Height) > tolerance)
{
return false;
}
if (Math.Abs(preferredSize.Width - elementSize.Width) > tolerance)
{
return false;
}
return true;
}
void UpdateVisualStates() void UpdateVisualStates()
{ {
if (PlatformHandler?.VirtualView is VisualElement element) if (PlatformHandler?.VirtualView is VisualElement element)

Просмотреть файл

@ -0,0 +1,152 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CoreGraphics;
using Foundation;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Handlers.Items;
using UIKit;
using Xunit;
namespace Microsoft.Maui.DeviceTests
{
#if !MACCATALYST
public partial class CollectionViewTests
{
public class CacheTestCollectionView : CollectionView { }
class CacheTestCollectionViewHandler : ReorderableItemsViewHandler<CacheTestCollectionView>
{
protected override ItemsViewController<CacheTestCollectionView> CreateController(CacheTestCollectionView itemsView, ItemsViewLayout layout)
{
return new CacheTestItemsViewController(itemsView, layout);
}
}
class CacheTestItemsViewController : ItemsViewController<CacheTestCollectionView>
{
protected override bool IsHorizontal { get; }
public UICollectionViewDelegateFlowLayout DelegateFlowLayout { get; private set; }
public CacheTestItemsViewController(CacheTestCollectionView reorderableItemsView, ItemsViewLayout layout) : base(reorderableItemsView, layout)
{
}
protected override UICollectionViewDelegateFlowLayout CreateDelegator()
{
DelegateFlowLayout = new CacheMissCountingDelegate(ItemsViewLayout, this);
return DelegateFlowLayout;
}
}
internal class CacheMissCountingDelegate : ItemsViewDelegator<CacheTestCollectionView, ItemsViewController<CacheTestCollectionView>>
{
public int CacheMissCount { get; set; }
public CacheMissCountingDelegate(ItemsViewLayout itemsViewLayout, ItemsViewController<CacheTestCollectionView> itemsViewController) : base(itemsViewLayout, itemsViewController)
{
}
public override CGSize GetSizeForItem(UICollectionView collectionView, UICollectionViewLayout layout, NSIndexPath indexPath)
{
var itemSize = base.GetSizeForItem(collectionView, layout, indexPath);
if (ViewController.Layout is UICollectionViewFlowLayout flowLayout)
{
// This is totally a cheat from a unit-testing perspective; we know how the item size cache
// functions internally (that it will return the estimated size if the item size is not found in the cache),
// and we're relying on that for this test. Normally we wouldn't do this, but we need to ensure that further
// code changes don't break the cache again, and the only observable outside consequence is that the scrolling
// for the CollectionView becomes "janky". We don't want to rely entirely on human perception of "jankiness" to
// catch this problem.
// If the size comes back as EstimatedItemSize, we'll know that the actual value was not found in the cache.
if (itemSize == flowLayout.EstimatedItemSize)
{
CacheMissCount += 1;
}
}
return itemSize;
}
}
internal class ItemModel
{
public int Index { get; set; }
public double HeightRequest => 40 * (Index + 1);
}
[Fact]
public async Task EnsureCellSizesAreCached()
{
SetupBuilder();
var collectionView = new CacheTestCollectionView()
{
// Deliberately choosing a height which will cause rounding issues which can caused pathological
// sizing/layout loops if handled wrong
HeightRequest = 654.66666
};
var template = new DataTemplate(() => {
var content = new Label() { Text = "Howdy" };
content.SetBinding(VisualElement.HeightRequestProperty, new Binding(nameof(VisualElement.HeightRequest)));
return content;
});
// Build up a view model that's got enough items to ensure scrolling and template reuse
int itemCount = 15;
var source = new List<ItemModel>();
for (int n = 0; n < itemCount; n++)
{
source.Add(new ItemModel() { Index = n });
}
collectionView.ItemTemplate = template;
collectionView.ItemsSource = source;
var frame = collectionView.Frame;
await CreateHandlerAndAddToWindow<CacheTestCollectionViewHandler>(collectionView, async handler =>
{
// Wait until the CollectionView is actually rendering
await WaitForUIUpdate(frame, collectionView);
// Tell it to scroll to the bottom (and give it time to do so)
collectionView.ScrollTo(itemCount - 1);
await Task.Delay(1000);
// Now back to the top
collectionView.ScrollTo(0);
await Task.Delay(1000);
if (handler.Controller is CacheTestItemsViewController controller
&& controller.DelegateFlowLayout is CacheMissCountingDelegate cacheMissCounter)
{
// Different screen sizes and timings mean this isn't 100% predictable. But we can work out some conditions
// which will tell us that the cache is completely broken and test for those.
// With 15 items in the list, we can assume a minimum of 15 size cache misses until the cache is populated.
// Plus at least one for the initial proxy item measurement.
// The bugs we are trying to avoid clear the cache prematurely and cause _every_ GetSizeForItem call
// to miss the cache, so scrolling top to bottom and back with 15 items we would see at _least_ 30
// cache misses, likely more (since item sizes will be retrieved more than once as items scroll).
// If we have fewer than 20 cache misses, we at least know that the cache isn't being wiped as we scroll.
int missCountThreshold = 20;
int actualMissCount = cacheMissCounter.CacheMissCount;
Assert.True(actualMissCount < missCountThreshold, $"Cache miss count {actualMissCount} was higher than the threshold value of {missCountThreshold}");
}
else
{
// Something went wrong with this test
Assert.Fail("Wrong controller type in the test; is the handler registration broken?");
}
});
}
}
#endif
}

Просмотреть файл

@ -34,6 +34,10 @@ namespace Microsoft.Maui.DeviceTests
handlers.AddHandler<VerticalStackLayout, LayoutHandler>(); handlers.AddHandler<VerticalStackLayout, LayoutHandler>();
handlers.AddHandler<Grid, LayoutHandler>(); handlers.AddHandler<Grid, LayoutHandler>();
handlers.AddHandler<Label, LabelHandler>(); handlers.AddHandler<Label, LabelHandler>();
#if IOS && !MACCATALYST
handlers.AddHandler<CacheTestCollectionView, CacheTestCollectionViewHandler>();
#endif
}); });
}); });
} }

Просмотреть файл

@ -1,9 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows.Input;
using CoreGraphics; using CoreGraphics;
using Microsoft.Maui.Controls; using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Handlers.Items; using Microsoft.Maui.Controls.Handlers.Items;