[iOS] Clear BindingContext when cell is queued for reuse (#14619)

* Clear BindingContext when cell is queued for reuse

This avoid holding references to objects that were already removed.

* Auto-format source code

* Update src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt to list PrepareForReuse override.

* Empty commit

* Apply code review feedback

* Update public API for maccatalyst

* Add test that shows the fix is not fully working yet

* [iOS] Unbind TemplatedCell when it goes out of display and the bound item is no longer present in items source.

Extend the test to check rebinding after clearing the source.

* Relax the test to make it pass on Android.

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

Co-authored-by: Rui Marinho <me@ruimarinho.net>

* Use Equals to compare item list elements

* Auto-format source code

* PR feedback

---------

Co-authored-by: GitHub Actions Autoformatter <autoformat@example.com>
Co-authored-by: Rui Marinho <me@ruimarinho.net>
This commit is contained in:
Filip Navara 2024-02-12 12:12:20 +01:00 коммит произвёл GitHub
Родитель b43f2650b9
Коммит 170a7a9832
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
5 изменённых файлов: 99 добавлений и 11 удалений

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

@ -108,6 +108,22 @@ namespace Microsoft.Maui.Controls.Handlers.Items
public override void CellDisplayingEnded(UICollectionView collectionView, UICollectionViewCell cell, NSIndexPath indexPath)
{
if (cell is TemplatedCell templatedCell &&
(templatedCell.PlatformHandler?.VirtualView as View)?.BindingContext is object bindingContext)
{
// We want to unbind a cell that is no longer present in the items source. Unfortunately
// it's too expensive to check directly, so let's check that the current binding context
// matches the item at a given position.
var itemsSource = ViewController?.ItemsSource;
if (itemsSource is null ||
!itemsSource.IsIndexPathValid(indexPath) ||
!Equals(itemsSource[indexPath], bindingContext))
{
templatedCell.Unbind();
}
}
if (ItemsViewLayout.ScrollDirection == UICollectionViewScrollDirection.Horizontal)
{
var actualWidth = collectionView.ContentSize.Width - collectionView.Bounds.Size.Width;

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

@ -58,6 +58,15 @@ namespace Microsoft.Maui.Controls.Handlers.Items
ConstrainedDimension = default;
}
internal void Unbind()
{
if (PlatformHandler?.VirtualView is View view)
{
view.MeasureInvalidated -= MeasureInvalidated;
view.BindingContext = null;
}
}
public override UICollectionViewLayoutAttributes PreferredLayoutAttributesFittingAttributes(
UICollectionViewLayoutAttributes layoutAttributes)
{
@ -116,6 +125,12 @@ namespace Microsoft.Maui.Controls.Handlers.Items
_size = rectangle.Size;
}
public override void PrepareForReuse()
{
Unbind();
base.PrepareForReuse();
}
public void Bind(DataTemplate template, object bindingContext, ItemsView itemsView)
{
var oldElement = PlatformHandler?.VirtualView as View;
@ -157,18 +172,10 @@ namespace Microsoft.Maui.Controls.Handlers.Items
// Same template
if (oldElement != null)
{
if (oldElement.BindingContext == null || !(oldElement.BindingContext.Equals(bindingContext)))
{
// If the data is different, update it
oldElement.BindingContext = bindingContext;
oldElement.MeasureInvalidated += MeasureInvalidated;
// Unhook the MeasureInvalidated handler, otherwise it'll fire for every invalidation during the
// BindingContext change
oldElement.MeasureInvalidated -= MeasureInvalidated;
oldElement.BindingContext = bindingContext;
oldElement.MeasureInvalidated += MeasureInvalidated;
UpdateCellSize();
}
UpdateCellSize();
}
}

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

@ -106,6 +106,7 @@ Microsoft.Maui.Controls.Region.Equals(Microsoft.Maui.Controls.Region other) -> b
Microsoft.Maui.Controls.Shapes.Matrix.Equals(Microsoft.Maui.Controls.Shapes.Matrix other) -> bool
Microsoft.Maui.Controls.Shapes.Shape.~Shape() -> void
Microsoft.Maui.Controls.VisualElement.~VisualElement() -> void
override Microsoft.Maui.Controls.Handlers.Items.TemplatedCell.PrepareForReuse() -> void
override Microsoft.Maui.Controls.Handlers.Compatibility.PhoneFlyoutPageRenderer.ViewWillLayoutSubviews() -> void
override Microsoft.Maui.Controls.LayoutOptions.GetHashCode() -> int
override Microsoft.Maui.Controls.Platform.Compatibility.ShellPageRendererTracker.TitleViewContainer.LayoutSubviews() -> void

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

@ -105,6 +105,7 @@ Microsoft.Maui.Controls.Region.Equals(Microsoft.Maui.Controls.Region other) -> b
Microsoft.Maui.Controls.Shapes.Matrix.Equals(Microsoft.Maui.Controls.Shapes.Matrix other) -> bool
Microsoft.Maui.Controls.Shapes.Shape.~Shape() -> void
Microsoft.Maui.Controls.VisualElement.~VisualElement() -> void
override Microsoft.Maui.Controls.Handlers.Items.TemplatedCell.PrepareForReuse() -> void
override Microsoft.Maui.Controls.Handlers.Compatibility.PhoneFlyoutPageRenderer.ViewWillLayoutSubviews() -> void
override Microsoft.Maui.Controls.LayoutOptions.GetHashCode() -> int
override Microsoft.Maui.Controls.Platform.Compatibility.ShellPageRendererTracker.TitleViewContainer.LayoutSubviews() -> void

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

@ -397,5 +397,68 @@ namespace Microsoft.Maui.DeviceTests
timeout -= interval;
}
}
[Fact]
public async Task ClearingItemsSourceClearsBindingContext()
{
SetupBuilder();
IReadOnlyList<Element> logicalChildren = null;
var collectionView = new CollectionView
{
ItemTemplate = new DataTemplate(() => new Label() { HeightRequest = 30, WidthRequest = 200 }),
WidthRequest = 200,
HeightRequest = 200,
};
await CreateHandlerAndAddToWindow<CollectionViewHandler>(collectionView, async handler =>
{
var data = new ObservableCollection<MyRecord>()
{
new MyRecord("Item 1"),
new MyRecord("Item 2"),
new MyRecord("Item 3"),
};
collectionView.ItemsSource = data;
await Task.Delay(100);
logicalChildren = collectionView.LogicalChildrenInternal;
Assert.NotNull(logicalChildren);
Assert.True(logicalChildren.Count == 3);
// Clear collection
var savedItems = data.ToArray();
data.Clear();
await Task.Delay(100);
// Check that all logical children have no binding context
foreach (var logicalChild in logicalChildren)
{
Assert.Null(logicalChild.BindingContext);
}
// Re-add the old children
foreach (var savedItem in savedItems)
{
data.Add(savedItem);
}
await Task.Delay(100);
// Check that the right number of logical children have binding context again
int boundChildren = 0;
foreach (var logicalChild in logicalChildren)
{
if (logicalChild.BindingContext is not null)
{
boundChildren++;
}
}
Assert.Equal(3, boundChildren);
});
}
record MyRecord(string Name);
}
}