Skip to content

Commit

Permalink
feat(listview): [WASM] [MACOS] ListView observable collection support
Browse files Browse the repository at this point in the history
Item insertion and removal from an ItemsSource implementing INotifyCollectionChanged is now handled correctly, only updating the items that need to be updated and improving performance.
  • Loading branch information
davidjohnoliver committed Jun 10, 2020
1 parent eddf717 commit a181d40
Show file tree
Hide file tree
Showing 5 changed files with 234 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Text;
using Microsoft.Extensions.Logging;
using Uno.Extensions;
using Uno.UI;

namespace Windows.UI.Xaml.Controls
Expand Down Expand Up @@ -29,6 +31,86 @@ public CollectionChangedOperation(Uno.UI.IndexPath startingIndex, int range, Not
ElementType = elementType;
}

/// <summary>
/// Apply the offset to a collection index resulting from this collection operation.
/// </summary>
/// <param name="indexPath">The index in the collection prior to the operation</param>
/// <returns>The offset position, or null if this position is no longer valid (ie because it has been removed by the operation).</returns>
public IndexPath? Offset(IndexPath indexPath)
{
var section = indexPath.Section;
var row = indexPath.Row;

switch (this)
{
case var itemAdd when itemAdd.ElementType == CollectionChangedOperation.Element.Item &&
itemAdd.Action == NotifyCollectionChangedAction.Add &&
itemAdd.StartingIndex.Section == section &&
itemAdd.EndIndex.Row <= row:
row += itemAdd.Range;
break;

case var itemRemove when itemRemove.ElementType == CollectionChangedOperation.Element.Item &&
itemRemove.Action == NotifyCollectionChangedAction.Remove &&
itemRemove.StartingIndex.Section == section &&
itemRemove.EndIndex.Row < row:
row -= itemRemove.Range;
break;

case var thisItemRemoved when thisItemRemoved.ElementType == CollectionChangedOperation.Element.Item &&
(thisItemRemoved.Action == NotifyCollectionChangedAction.Remove || thisItemRemoved.Action == NotifyCollectionChangedAction.Replace) &&
thisItemRemoved.StartingIndex.Section == section &&
thisItemRemoved.StartingIndex.Row <= row && thisItemRemoved.EndIndex.Row >= row:
// This item has been removed or replaced, the index is no longer valid
return null;

// Group operations are currently unsupported
case var groupAdd when groupAdd.ElementType == CollectionChangedOperation.Element.Group &&
groupAdd.Action == NotifyCollectionChangedAction.Add &&
groupAdd.EndIndex.Section <= section:
case var groupRemove when groupRemove.ElementType == CollectionChangedOperation.Element.Group &&
groupRemove.Action == NotifyCollectionChangedAction.Remove &&
groupRemove.EndIndex.Section < section:
case var thisGroupRemoved when thisGroupRemoved.ElementType == CollectionChangedOperation.Element.Group &&
(thisGroupRemoved.Action == NotifyCollectionChangedAction.Remove || thisGroupRemoved.Action == NotifyCollectionChangedAction.Replace) &&
thisGroupRemoved.StartingIndex.Section <= section && thisGroupRemoved.EndIndex.Section >= section:
if (this.Log().IsEnabled(LogLevel.Warning))
{
this.Log().LogWarning("Collection change not supported");
}
break;
}

return IndexPath.FromRowSection(row, section);
}

/// <summary>
/// Sequentially applies offsets to a collection index resulting from multiple collection operations.
/// </summary>
/// <param name="index">The index in the collection prior to the operation</param>
/// <param name="collectionChanges">The changes to be applied, in order from oldest to newest.</param>
/// <returns>The offset position, or null if this position is no longer valid (ie because it has been removed by one of the operations).</returns>
public static IndexPath? Offset(IndexPath index, IEnumerable<CollectionChangedOperation> collectionChanges)
{
IndexPath? newIndex = index;

foreach (var change in collectionChanges)
{
if (newIndex is IndexPath newIndexValue)
{
newIndex = change.Offset(newIndexValue);
}
else
{
break;
}
}

return newIndex;
}

public static int? Offset(int index, IEnumerable<CollectionChangedOperation> collectionChanges) => Offset(IndexPath.FromRowSection(index, section: 0), collectionChanges)?.Row;

public enum Element
{
Item,
Expand Down
10 changes: 2 additions & 8 deletions src/Uno.UI/UI/Xaml/Controls/ListViewBase/ListViewBase.iOS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,20 +161,14 @@ private void AddItems(int firstItem, int count, int section)
{
NativePanel?.InsertItems(GetIndexPathsFromStartAndCount(firstItem, count, section));

if (ManagedVirtualizingPanel != null)
{
Refresh();
}
ManagedVirtualizingPanel?.GetLayouter().AddItems(firstItem, count, section);
}

private void RemoveItems(int firstItem, int count, int section)
{
NativePanel?.DeleteItems(GetIndexPathsFromStartAndCount(firstItem, count, section));

if (ManagedVirtualizingPanel != null)
{
Refresh();
}
ManagedVirtualizingPanel?.GetLayouter().RemoveItems(firstItem, count, section);
}

/// <summary>
Expand Down
18 changes: 16 additions & 2 deletions src/Uno.UI/UI/Xaml/Controls/ListViewBase/ListViewBase.managed.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,26 @@ private void Refresh()

private void AddItems(int firstItem, int count, int section)
{
Refresh();
if (VirtualizingPanel != null)
{
VirtualizingPanel.GetLayouter().AddItems(firstItem, count, section);
}
else
{
Refresh();
}
}

private void RemoveItems(int firstItem, int count, int section)
{
Refresh();
if (VirtualizingPanel != null)
{
VirtualizingPanel.GetLayouter().RemoveItems(firstItem, count, section);
}
else
{
Refresh();
}
}

private void AddGroup(int groupIndexInView)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#if !NET461
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.Extensions.Logging;
Expand All @@ -27,8 +28,10 @@ internal class VirtualizingPanelGenerator
private readonly Dictionary<int, Stack<FrameworkElement>> _itemContainerCache = new Dictionary<int, Stack<FrameworkElement>>();
/// <summary>
/// Caching the id is more efficient, and also important in the case of the ItemsSource changing, when the (former) item may no longer be in the new collection.
///
/// Key == item index, Value = template id
/// </summary>
private Dictionary<int, int> _idCache = new Dictionary<int, int>();
private readonly Dictionary<int, int> _idCache = new Dictionary<int, int>();

/// <summary>
/// Items that have been temporarily scrapped and can be reused without being rebound.
Expand Down Expand Up @@ -198,6 +201,39 @@ public void UpdateVisibilities()
}
}

/// <summary>
/// Update cache indices for pending collection updates.
/// </summary>
public void UpdateForCollectionChanges(Queue<CollectionChangedOperation> collectionChanges)
{
var scrapCacheCopy = _scrapCache.ToList();
_scrapCache.Clear();
foreach (var kvp in scrapCacheCopy)
{
if (CollectionChangedOperation.Offset(kvp.Key, collectionChanges) is int finalNewIndexValue)
{
_scrapCache[finalNewIndexValue] = kvp.Value;
}
else
{
// Item has been removed, take out container from scrap so that we don't reuse it without rebinding.
// Note: we update scrap before _idCache, so we can access the correct, cached template id in RecycleViewForItem()
RecycleViewForItem(kvp.Value, kvp.Key);
}
}

var idCacheCopy = _idCache.ToList();
_idCache.Clear();
foreach (var kvp in idCacheCopy)
{
if (CollectionChangedOperation.Offset(kvp.Key, collectionChanges) is int finalNewIndexValue)
{
_idCache[finalNewIndexValue] = kvp.Value;
}
// else - item has been removed, the cached id is no longer valid so we don't restore it
}
}

/// <summary>
/// Get the container reuse ID associated with the item <paramref name="index"/>, based on the template associated with it (eg if <see cref="ItemsControl.ItemTemplateSelector"/> is set)
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using static System.Math;
using static Windows.UI.Xaml.Controls.Primitives.GeneratorDirection;
using Uno.UI.Extensions;
using System.Collections.Specialized;
#if __MACOS__
using AppKit;
#elif __IOS__
Expand Down Expand Up @@ -47,6 +48,11 @@ public abstract partial class VirtualizingPanelLayout : DependencyObject
private Size _lastMeasuredSize;
private double _lastScrollOffset;

/// <summary>
/// The current average line height based on materialized lines. Used to estimate scroll extent of unmaterialized items.
/// </summary>
private double _averageLineHeight;

/// <summary>
/// The previous item to the old first visible item, used when a lightweight layout rebuild is called.
/// </summary>
Expand All @@ -56,6 +62,11 @@ public abstract partial class VirtualizingPanelLayout : DependencyObject
/// </summary>
private double? _dynamicSeedStart;

/// <summary>
/// Pending collection changes to be processed when the list is re-measured.
/// </summary>
private readonly Queue<CollectionChangedOperation> _pendingCollectionChanges = new Queue<CollectionChangedOperation>();

private double AvailableBreadth => ScrollOrientation == Orientation.Vertical ?
_availableSize.Width :
_availableSize.Height;
Expand Down Expand Up @@ -258,6 +269,7 @@ internal Size MeasureOverride(Size availableSize)
}

_availableSize = availableSize;
UpdateAverageLineHeight(); // Must be called before ScrapLayout(), or there won't be items to measure
ScrapLayout();
UpdateLayout();

Expand Down Expand Up @@ -296,6 +308,8 @@ private void UpdateLayout(double? extentAdjustment = null)
{
OwnerPanel.ShouldInterceptInvalidate = true;

ApplyCollectionChanges();

UnfillLayout(extentAdjustment ?? 0);
FillLayout(extentAdjustment ?? 0);

Expand Down Expand Up @@ -425,6 +439,50 @@ private void ScrapLine(Line line)
}
}

/// <summary>
/// If there are pending collection changes, update values and prepare the layouter accordingly.
/// </summary>
private void ApplyCollectionChanges()
{
if (_pendingCollectionChanges.Count == 0)
{
return;
}

if (_dynamicSeedIndex is IndexPath dynamicSeedIndex)
{
var updated = CollectionChangedOperation.Offset(dynamicSeedIndex, _pendingCollectionChanges);
if (updated is IndexPath updatedValue)
{
_dynamicSeedIndex = updated;

var itemOffset = updatedValue.Row - dynamicSeedIndex.Row; // TODO: This will need to change when grouping is supported
var scrollAdjustment = itemOffset * _averageLineHeight; // TODO: not appropriate for ItemsWrapGrid
_dynamicSeedStart += scrollAdjustment;
ApplyScrollAdjustment(scrollAdjustment);
}
else
{
// TODO NOW: handle the case where seed was removed (the first subsequent remaining item should be used)
}
}

Generator.UpdateForCollectionChanges(_pendingCollectionChanges);
_pendingCollectionChanges.Clear();
}

private void ApplyScrollAdjustment(double scrollAdjustment)
{
if (ScrollOrientation == Orientation.Vertical)
{
ScrollViewer.ChangeView(null, ScrollViewer.VerticalOffset + scrollAdjustment, null, disableAnimation: true);
}
else
{
ScrollViewer.ChangeView(ScrollViewer.HorizontalOffset + scrollAdjustment, null, null, disableAnimation: true);
}
}

/// <summary>
/// Estimate the 'correct' size of the panel.
/// </summary>
Expand Down Expand Up @@ -470,22 +528,27 @@ private double EstimatePanelExtent()
var lastItem = GetFlatItemIndex(lastIndexPath.Value);

var remainingItems = ItemsControl.NumberOfItems - lastItem - 1;

var averageLineHeight = _materializedLines.Select(l => GetExtent(l.FirstView)).Average();
UpdateAverageLineHeight();

int itemsPerLine = GetItemsPerLine();
var remainingLines = remainingItems / itemsPerLine + remainingItems % itemsPerLine;

double estimatedExtent = GetContentEnd() + remainingLines * averageLineHeight;
double estimatedExtent = GetContentEnd() + remainingLines * _averageLineHeight;

if (this.Log().IsEnabled(LogLevel.Debug))
{
this.Log().LogDebug($"{GetMethodTag()}=>{estimatedExtent}, GetContentEnd()={GetContentEnd()}, remainingLines={remainingLines}, averageLineHeight={averageLineHeight}");
this.Log().LogDebug($"{GetMethodTag()}=>{estimatedExtent}, GetContentEnd()={GetContentEnd()}, remainingLines={remainingLines}, averageLineHeight={_averageLineHeight}");
}

return estimatedExtent;
}

private void UpdateAverageLineHeight()
{
_averageLineHeight = _materializedLines.Count > 0 ? _materializedLines.Select(l => GetExtent(l.FirstView)).Average()
: 0;
}

private double CalculatePanelMeasureBreadth() => ShouldMeasuredBreadthStretch ? AvailableBreadth :
_materializedLines.Select(l => GetDesiredBreadth(l.FirstView)).MaxOrDefault()
#if __WASM__
Expand All @@ -496,6 +559,35 @@ private double CalculatePanelMeasureBreadth() => ShouldMeasuredBreadthStretch ?
private double CalculatePanelArrangeBreadth() => ShouldMeasuredBreadthStretch ? AvailableBreadth :
_materializedLines.Select(l => GetActualBreadth(l.FirstView)).MaxOrDefault();

internal void AddItems(int firstItem, int count, int section)
{
_pendingCollectionChanges.Enqueue(new CollectionChangedOperation(
startingIndex: IndexPath.FromRowSection(firstItem, section),
range: count,
action: NotifyCollectionChangedAction.Add,
elementType: CollectionChangedOperation.Element.Item
));

LightRefresh();
}

internal void RemoveItems(int firstItem, int count, int section)
{
_pendingCollectionChanges.Enqueue(new CollectionChangedOperation(
startingIndex: IndexPath.FromRowSection(firstItem, section),
range: count,
action: NotifyCollectionChangedAction.Remove,
elementType: CollectionChangedOperation.Element.Item
));

LightRefresh();
}

/// <summary>
/// Update the display of the panel without clearing caches.
/// </summary>
private void LightRefresh() => OwnerPanel?.InvalidateMeasure();

internal void Refresh()
{
if (this.Log().IsEnabled(LogLevel.Debug))
Expand All @@ -507,6 +599,7 @@ internal void Refresh()

UpdateCompleted();
Generator.ClearIdCache();
_pendingCollectionChanges.Clear();
OwnerPanel?.InvalidateMeasure();
}

Expand Down

0 comments on commit a181d40

Please sign in to comment.