Virtualization support (#24179)
This commit is contained in:
parent
3f15d26851
commit
4ef5e10e6d
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,7 @@
|
|||
import { navigateTo, internalFunctions as navigationManagerInternalFunctions } from './Services/NavigationManager';
|
||||
import { attachRootComponentToElement } from './Rendering/Renderer';
|
||||
import { domFunctions } from './DomWrapper';
|
||||
import { Virtualize } from './Virtualize';
|
||||
|
||||
// Make the following APIs available in global scope for invocation from JS
|
||||
window['Blazor'] = {
|
||||
|
|
@ -10,5 +11,6 @@ window['Blazor'] = {
|
|||
attachRootComponentToElement,
|
||||
navigationManager: navigationManagerInternalFunctions,
|
||||
domWrapper: domFunctions,
|
||||
Virtualize,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
export const Virtualize = {
|
||||
init,
|
||||
dispose,
|
||||
};
|
||||
|
||||
const observersByDotNetId = {};
|
||||
|
||||
function findClosestScrollContainer(element: Element | null): Element | null {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
if (style.overflowY !== 'visible') {
|
||||
return element;
|
||||
}
|
||||
|
||||
return findClosestScrollContainer(element.parentElement);
|
||||
}
|
||||
|
||||
function init(dotNetHelper: any, spacerBefore: HTMLElement, spacerAfter: HTMLElement, rootMargin = 50): void {
|
||||
const intersectionObserver = new IntersectionObserver(intersectionCallback, {
|
||||
root: findClosestScrollContainer(spacerBefore),
|
||||
rootMargin: `${rootMargin}px`,
|
||||
});
|
||||
|
||||
intersectionObserver.observe(spacerBefore);
|
||||
intersectionObserver.observe(spacerAfter);
|
||||
|
||||
const mutationObserverBefore = createSpacerMutationObserver(spacerBefore);
|
||||
const mutationObserverAfter = createSpacerMutationObserver(spacerAfter);
|
||||
|
||||
observersByDotNetId[dotNetHelper._id] = {
|
||||
intersectionObserver,
|
||||
mutationObserverBefore,
|
||||
mutationObserverAfter,
|
||||
};
|
||||
|
||||
function createSpacerMutationObserver(spacer: HTMLElement): MutationObserver {
|
||||
// Without the use of thresholds, IntersectionObserver only detects binary changes in visibility,
|
||||
// so if a spacer gets resized but remains visible, no additional callbacks will occur. By unobserving
|
||||
// and reobserving spacers when they get resized, the intersection callback will re-run if they remain visible.
|
||||
const mutationObserver = new MutationObserver((): void => {
|
||||
intersectionObserver.unobserve(spacer);
|
||||
intersectionObserver.observe(spacer);
|
||||
});
|
||||
|
||||
mutationObserver.observe(spacer, { attributes: true });
|
||||
|
||||
return mutationObserver;
|
||||
}
|
||||
|
||||
function intersectionCallback(entries: IntersectionObserverEntry[]): void {
|
||||
entries.forEach((entry): void => {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerSize = entry.rootBounds?.height;
|
||||
|
||||
if (entry.target === spacerBefore) {
|
||||
dotNetHelper.invokeMethodAsync('OnSpacerBeforeVisible', entry.intersectionRect.top - entry.boundingClientRect.top, containerSize);
|
||||
} else if (entry.target === spacerAfter && spacerAfter.offsetHeight > 0) {
|
||||
// When we first start up, both the "before" and "after" spacers will be visible, but it's only relevant to raise a
|
||||
// single event to load the initial data. To avoid raising two events, skip the one for the "after" spacer if we know
|
||||
// it's meaningless to talk about any overlap into it.
|
||||
dotNetHelper.invokeMethodAsync('OnSpacerAfterVisible', entry.boundingClientRect.bottom - entry.intersectionRect.bottom, containerSize);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function dispose(dotNetHelper: any): void {
|
||||
const observers = observersByDotNetId[dotNetHelper._id];
|
||||
|
||||
if (observers) {
|
||||
observers.intersectionObserver.disconnect();
|
||||
observers.mutationObserverBefore.disconnect();
|
||||
observers.mutationObserverAfter.disconnect();
|
||||
|
||||
dotNetHelper.dispose();
|
||||
|
||||
delete observersByDotNetId[dotNetHelper._id];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
{
|
||||
internal interface IVirtualizeJsCallbacks
|
||||
{
|
||||
void OnBeforeSpacerVisible(float spacerSize, float containerSize);
|
||||
void OnAfterSpacerVisible(float spacerSize, float containerSize);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
{
|
||||
/// <summary>
|
||||
/// A function that provides items to a virtualized source.
|
||||
/// </summary>
|
||||
/// <typeparam name="TItem">The type of the context for each item in the list.</typeparam>
|
||||
/// <param name="request">The <see cref="ItemsProviderRequest"/> defining the request details.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> whose result is a <see cref="ItemsProviderResult{TItem}"/> upon successful completion.</returns>
|
||||
public delegate ValueTask<ItemsProviderResult<TItem>> ItemsProviderDelegate<TItem>(ItemsProviderRequest request);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System.Threading;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a request to an <see cref="ItemsProviderDelegate{TItem}"/>.
|
||||
/// </summary>
|
||||
public readonly struct ItemsProviderRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The start index of the data segment requested.
|
||||
/// </summary>
|
||||
public int StartIndex { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The requested number of items to be provided. The actual number of provided items does not need to match
|
||||
/// this value.
|
||||
/// </summary>
|
||||
public int Count { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="System.Threading.CancellationToken"/> used to relay cancellation of the request.
|
||||
/// </summary>
|
||||
public CancellationToken CancellationToken { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a new <see cref="ItemsProviderRequest"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="startIndex">The start index of the data segment requested.</param>
|
||||
/// <param name="count">The requested number of items to be provided.</param>
|
||||
/// <param name="cancellationToken">
|
||||
/// The <see cref="System.Threading.CancellationToken"/> used to relay cancellation of the request.
|
||||
/// </param>
|
||||
public ItemsProviderRequest(int startIndex, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
StartIndex = startIndex;
|
||||
Count = count;
|
||||
CancellationToken = cancellationToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents the result of a <see cref="ItemsProviderDelegate{TItem}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TItem">The type of the context for each item in the list.</typeparam>
|
||||
public readonly struct ItemsProviderResult<TItem>
|
||||
{
|
||||
/// <summary>
|
||||
/// The items to provide.
|
||||
/// </summary>
|
||||
public IEnumerable<TItem> Items { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The total item count in the source generating the items provided.
|
||||
/// </summary>
|
||||
public int TotalItemCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Instantiates a new <see cref="ItemsProviderResult{TItem}"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to provide.</param>
|
||||
/// <param name="totalItemCount">The total item count in the source generating the items provided.</param>
|
||||
public ItemsProviderResult(IEnumerable<TItem> items, int totalItemCount)
|
||||
{
|
||||
Items = items;
|
||||
TotalItemCount = totalItemCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
{
|
||||
/// <summary>
|
||||
/// Contains context for a placeholder in a virtualized list.
|
||||
/// </summary>
|
||||
public readonly struct PlaceholderContext
|
||||
{
|
||||
/// <summary>
|
||||
/// The item index of the placeholder.
|
||||
/// </summary>
|
||||
public int Index { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a new <see cref="PlaceholderContext"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="index">The item index of the placeholder.</param>
|
||||
public PlaceholderContext(int index)
|
||||
{
|
||||
Index = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides functionality for rendering a virtualized list of items.
|
||||
/// </summary>
|
||||
/// <typeparam name="TItem">The <c>context</c> type for the items being rendered.</typeparam>
|
||||
public sealed class Virtualize<TItem> : ComponentBase, IVirtualizeJsCallbacks, IAsyncDisposable
|
||||
{
|
||||
private VirtualizeJsInterop? _jsInterop;
|
||||
|
||||
private ElementReference _spacerBefore;
|
||||
|
||||
private ElementReference _spacerAfter;
|
||||
|
||||
private int _itemsBefore;
|
||||
|
||||
private int _visibleItemCapacity;
|
||||
|
||||
private int _itemCount;
|
||||
|
||||
private int _loadedItemsStartIndex;
|
||||
|
||||
private IEnumerable<TItem>? _loadedItems;
|
||||
|
||||
private CancellationTokenSource? _refreshCts;
|
||||
|
||||
private Exception? _refreshException;
|
||||
|
||||
private ItemsProviderDelegate<TItem> _itemsProvider = default!;
|
||||
|
||||
private RenderFragment<TItem>? _itemTemplate;
|
||||
|
||||
private RenderFragment<PlaceholderContext>? _placeholder;
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item template for the list.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment<TItem>? ChildContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item template for the list.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment<TItem>? ItemContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the template for items that have not yet been loaded in memory.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment<PlaceholderContext>? Placeholder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the size of each item in pixels.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public float ItemSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the function providing items to the list.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public ItemsProviderDelegate<TItem>? ItemsProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the fixed item source.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public ICollection<TItem>? Items { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ItemSize <= 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{GetType()} requires a positive value for parameter '{nameof(ItemSize)}' to perform virtualization.");
|
||||
}
|
||||
|
||||
if (ItemsProvider != null)
|
||||
{
|
||||
if (Items != null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{GetType()} can only accept one item source from its parameters. " +
|
||||
$"Do not supply both '{nameof(Items)}' and '{nameof(ItemsProvider)}'.");
|
||||
}
|
||||
|
||||
_itemsProvider = ItemsProvider;
|
||||
}
|
||||
else if (Items != null)
|
||||
{
|
||||
_itemsProvider = DefaultItemsProvider;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{GetType()} requires either the '{nameof(Items)}' or '{nameof(ItemsProvider)}' parameters to be specified " +
|
||||
$"and non-null.");
|
||||
}
|
||||
|
||||
_itemTemplate = ItemContent ?? ChildContent;
|
||||
_placeholder = Placeholder ?? DefaultPlaceholder;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
_jsInterop = new VirtualizeJsInterop(this, JSRuntime);
|
||||
await _jsInterop.InitializeAsync(_spacerBefore, _spacerAfter);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void BuildRenderTree(RenderTreeBuilder builder)
|
||||
{
|
||||
if (_refreshException != null)
|
||||
{
|
||||
var oldRefreshException = _refreshException;
|
||||
_refreshException = null;
|
||||
|
||||
throw oldRefreshException;
|
||||
}
|
||||
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "style", GetSpacerStyle(_itemsBefore));
|
||||
builder.AddElementReferenceCapture(2, elementReference => _spacerBefore = elementReference);
|
||||
builder.CloseElement();
|
||||
|
||||
var lastItemIndex = Math.Min(_itemsBefore + _visibleItemCapacity, _itemCount);
|
||||
var renderIndex = _itemsBefore;
|
||||
var placeholdersBeforeCount = Math.Min(_loadedItemsStartIndex, lastItemIndex);
|
||||
|
||||
builder.OpenRegion(3);
|
||||
|
||||
// Render placeholders before the loaded items.
|
||||
for (; renderIndex < placeholdersBeforeCount; renderIndex++)
|
||||
{
|
||||
// This is a rare case where it's valid for the sequence number to be programmatically incremented.
|
||||
// This is only true because we know for certain that no other content will be alongside it.
|
||||
builder.AddContent(renderIndex, _placeholder, new PlaceholderContext(renderIndex));
|
||||
}
|
||||
|
||||
builder.CloseRegion();
|
||||
|
||||
// Render the loaded items.
|
||||
if (_loadedItems != null && _itemTemplate != null)
|
||||
{
|
||||
var itemsToShow = _loadedItems
|
||||
.Skip(_itemsBefore - _loadedItemsStartIndex)
|
||||
.Take(lastItemIndex - _loadedItemsStartIndex);
|
||||
|
||||
builder.OpenRegion(4);
|
||||
|
||||
foreach (var item in itemsToShow)
|
||||
{
|
||||
_itemTemplate(item)(builder);
|
||||
renderIndex++;
|
||||
}
|
||||
|
||||
builder.CloseRegion();
|
||||
}
|
||||
|
||||
builder.OpenRegion(5);
|
||||
|
||||
// Render the placeholders after the loaded items.
|
||||
for (; renderIndex < lastItemIndex; renderIndex++)
|
||||
{
|
||||
builder.AddContent(renderIndex, _placeholder, new PlaceholderContext(renderIndex));
|
||||
}
|
||||
|
||||
builder.CloseRegion();
|
||||
|
||||
var itemsAfter = Math.Max(0, _itemCount - _visibleItemCapacity - _itemsBefore);
|
||||
|
||||
builder.OpenElement(6, "div");
|
||||
builder.AddAttribute(7, "style", GetSpacerStyle(itemsAfter));
|
||||
builder.AddElementReferenceCapture(8, elementReference => _spacerAfter = elementReference);
|
||||
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
private string GetSpacerStyle(int itemsInSpacer)
|
||||
=> $"height: {itemsInSpacer * ItemSize}px;";
|
||||
|
||||
void IVirtualizeJsCallbacks.OnBeforeSpacerVisible(float spacerSize, float containerSize)
|
||||
{
|
||||
CalcualteItemDistribution(spacerSize, containerSize, out var itemsBefore, out var visibleItemCapacity);
|
||||
|
||||
UpdateItemDistribution(itemsBefore, visibleItemCapacity);
|
||||
}
|
||||
|
||||
void IVirtualizeJsCallbacks.OnAfterSpacerVisible(float spacerSize, float containerSize)
|
||||
{
|
||||
CalcualteItemDistribution(spacerSize, containerSize, out var itemsAfter, out var visibleItemCapacity);
|
||||
|
||||
var itemsBefore = Math.Max(0, _itemCount - itemsAfter - visibleItemCapacity);
|
||||
|
||||
UpdateItemDistribution(itemsBefore, visibleItemCapacity);
|
||||
}
|
||||
|
||||
private void CalcualteItemDistribution(float spacerSize, float containerSize, out int itemsInSpacer, out int visibleItemCapacity)
|
||||
{
|
||||
itemsInSpacer = Math.Max(0, (int)Math.Floor(spacerSize / ItemSize) - 1);
|
||||
visibleItemCapacity = (int)Math.Ceiling(containerSize / ItemSize) + 2;
|
||||
}
|
||||
|
||||
private void UpdateItemDistribution(int itemsBefore, int visibleItemCapacity)
|
||||
{
|
||||
if (itemsBefore != _itemsBefore || visibleItemCapacity != _visibleItemCapacity)
|
||||
{
|
||||
_itemsBefore = itemsBefore;
|
||||
_visibleItemCapacity = visibleItemCapacity;
|
||||
var refreshTask = RefreshDataAsync();
|
||||
|
||||
if (!refreshTask.IsCompleted)
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshDataAsync()
|
||||
{
|
||||
_refreshCts?.Cancel();
|
||||
_refreshCts = new CancellationTokenSource();
|
||||
|
||||
var cancellationToken = _refreshCts.Token;
|
||||
var request = new ItemsProviderRequest(_itemsBefore, _visibleItemCapacity, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _itemsProvider(request);
|
||||
|
||||
// Only apply result if the task was not canceled.
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_itemCount = result.TotalItemCount;
|
||||
_loadedItems = result.Items;
|
||||
_loadedItemsStartIndex = request.StartIndex;
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (e is OperationCanceledException oce && oce.CancellationToken == cancellationToken)
|
||||
{
|
||||
// No-op; we canceled the operation, so it's fine to suppress this exception.
|
||||
}
|
||||
else
|
||||
{
|
||||
// Cache this exception so the renderer can throw it.
|
||||
_refreshException = e;
|
||||
|
||||
// Re-render the component to throw the exception.
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ValueTask<ItemsProviderResult<TItem>> DefaultItemsProvider(ItemsProviderRequest request)
|
||||
{
|
||||
return ValueTask.FromResult(new ItemsProviderResult<TItem>(
|
||||
Items!.Skip(request.StartIndex).Take(request.Count),
|
||||
Items!.Count));
|
||||
}
|
||||
|
||||
private RenderFragment DefaultPlaceholder(PlaceholderContext context) => (builder) =>
|
||||
{
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "style", $"height: {ItemSize}px;");
|
||||
builder.CloseElement();
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_refreshCts?.Cancel();
|
||||
|
||||
if (_jsInterop != null)
|
||||
{
|
||||
await _jsInterop.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
{
|
||||
internal class VirtualizeJsInterop : IAsyncDisposable
|
||||
{
|
||||
private const string JsFunctionsPrefix = "Blazor._internal.Virtualize";
|
||||
|
||||
private readonly IVirtualizeJsCallbacks _owner;
|
||||
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
|
||||
private DotNetObjectReference<VirtualizeJsInterop>? _selfReference;
|
||||
|
||||
public VirtualizeJsInterop(IVirtualizeJsCallbacks owner, IJSRuntime jsRuntime)
|
||||
{
|
||||
_owner = owner;
|
||||
_jsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync(ElementReference spacerBefore, ElementReference spacerAfter)
|
||||
{
|
||||
_selfReference = DotNetObjectReference.Create(this);
|
||||
await _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.init", _selfReference, spacerBefore, spacerAfter);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void OnSpacerBeforeVisible(float spacerSize, float containerSize)
|
||||
{
|
||||
_owner.OnBeforeSpacerVisible(spacerSize, containerSize);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public void OnSpacerAfterVisible(float spacerSize, float containerSize)
|
||||
{
|
||||
_owner.OnAfterSpacerVisible(spacerSize, containerSize);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_selfReference != null)
|
||||
{
|
||||
await _jsRuntime.InvokeVoidAsync($"{JsFunctionsPrefix}.dispose", _selfReference);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
using Microsoft.AspNetCore.Components.Test.Helpers;
|
||||
using Microsoft.AspNetCore.Components.Web.Virtualization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.JSInterop;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Virtualization
|
||||
{
|
||||
public class VirtualizeTest
|
||||
{
|
||||
[Fact]
|
||||
public async Task Virtualize_ThrowsWhenGivenNonPositiveItemSize()
|
||||
{
|
||||
var rootComponent = new VirtualizeTestHostcomponent
|
||||
{
|
||||
InnerContent = BuildVirtualize(0f, EmptyItemsProvider<int>, null)
|
||||
};
|
||||
|
||||
var serviceProvider = new ServiceCollection()
|
||||
.AddTransient((sp) => Mock.Of<IJSRuntime>())
|
||||
.BuildServiceProvider();
|
||||
|
||||
var testRenderer = new TestRenderer(serviceProvider);
|
||||
var componentId = testRenderer.AssignRootComponentId(rootComponent);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await testRenderer.RenderRootComponentAsync(componentId));
|
||||
Assert.Contains("requires a positive value for parameter", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Virtualize_ThrowsWhenGivenMultipleItemSources()
|
||||
{
|
||||
var rootComponent = new VirtualizeTestHostcomponent
|
||||
{
|
||||
InnerContent = BuildVirtualize(10f, EmptyItemsProvider<int>, new List<int>())
|
||||
};
|
||||
|
||||
var serviceProvider = new ServiceCollection()
|
||||
.AddTransient((sp) => Mock.Of<IJSRuntime>())
|
||||
.BuildServiceProvider();
|
||||
|
||||
var testRenderer = new TestRenderer(serviceProvider);
|
||||
var componentId = testRenderer.AssignRootComponentId(rootComponent);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await testRenderer.RenderRootComponentAsync(componentId));
|
||||
Assert.Contains("can only accept one item source from its parameters", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Virtualize_ThrowsWhenGivenNoItemSources()
|
||||
{
|
||||
var rootComponent = new VirtualizeTestHostcomponent
|
||||
{
|
||||
InnerContent = BuildVirtualize<int>(10f, null, null)
|
||||
};
|
||||
|
||||
var serviceProvider = new ServiceCollection()
|
||||
.AddTransient((sp) => Mock.Of<IJSRuntime>())
|
||||
.BuildServiceProvider();
|
||||
|
||||
var testRenderer = new TestRenderer(serviceProvider);
|
||||
var componentId = testRenderer.AssignRootComponentId(rootComponent);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await testRenderer.RenderRootComponentAsync(componentId));
|
||||
Assert.Contains("parameters to be specified and non-null", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Virtualize_DispatchesExceptionsFromItemsProviderThroughRenderer()
|
||||
{
|
||||
Virtualize<int> renderedVirtualize = null;
|
||||
|
||||
var rootComponent = new VirtualizeTestHostcomponent
|
||||
{
|
||||
InnerContent = BuildVirtualize(10f, AlwaysThrowsItemsProvider<int>, null, virtualize => renderedVirtualize = virtualize)
|
||||
};
|
||||
|
||||
var serviceProvider = new ServiceCollection()
|
||||
.AddTransient((sp) => Mock.Of<IJSRuntime>())
|
||||
.BuildServiceProvider();
|
||||
|
||||
var testRenderer = new TestRenderer(serviceProvider);
|
||||
var componentId = testRenderer.AssignRootComponentId(rootComponent);
|
||||
|
||||
// Render to populate the component reference.
|
||||
await testRenderer.RenderRootComponentAsync(componentId);
|
||||
|
||||
Assert.NotNull(renderedVirtualize);
|
||||
|
||||
// Simulate a JS spacer callback.
|
||||
((IVirtualizeJsCallbacks)renderedVirtualize).OnAfterSpacerVisible(10f, 100f);
|
||||
|
||||
// Validate that the exception is dispatched through the renderer.
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await testRenderer.RenderRootComponentAsync(componentId));
|
||||
Assert.Equal("Thrown from items provider.", ex.Message);
|
||||
}
|
||||
|
||||
private ValueTask<ItemsProviderResult<TItem>> EmptyItemsProvider<TItem>(ItemsProviderRequest request)
|
||||
=> ValueTask.FromResult(new ItemsProviderResult<TItem>(Enumerable.Empty<TItem>(), 0));
|
||||
|
||||
private ValueTask<ItemsProviderResult<TItem>> AlwaysThrowsItemsProvider<TItem>(ItemsProviderRequest request)
|
||||
=> throw new InvalidOperationException("Thrown from items provider.");
|
||||
|
||||
private RenderFragment BuildVirtualize<TItem>(
|
||||
float itemSize,
|
||||
ItemsProviderDelegate<TItem> itemsProvider,
|
||||
ICollection<TItem> items,
|
||||
Action<Virtualize<TItem>> captureRenderedVirtualize = null)
|
||||
=> builder =>
|
||||
{
|
||||
builder.OpenComponent<Virtualize<TItem>>(0);
|
||||
builder.AddAttribute(1, "ItemSize", itemSize);
|
||||
builder.AddAttribute(2, "ItemsProvider", itemsProvider);
|
||||
builder.AddAttribute(3, "Items", items);
|
||||
|
||||
if (captureRenderedVirtualize != null)
|
||||
{
|
||||
builder.AddComponentReferenceCapture(4, component => captureRenderedVirtualize(component as Virtualize<TItem>));
|
||||
}
|
||||
|
||||
builder.CloseComponent();
|
||||
};
|
||||
|
||||
private class VirtualizeTestHostcomponent : AutoRenderComponent
|
||||
{
|
||||
public RenderFragment InnerContent { get; set; }
|
||||
|
||||
protected override void BuildRenderTree(RenderTreeBuilder builder)
|
||||
{
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "style", "overflow: auto; height: 800px;");
|
||||
builder.AddContent(2, InnerContent);
|
||||
builder.CloseElement();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using BasicTestApp;
|
||||
using Microsoft.AspNetCore.Components.E2ETest;
|
||||
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
|
||||
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
|
||||
using Microsoft.AspNetCore.E2ETesting;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Support.Extensions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.E2ETests.Tests
|
||||
{
|
||||
public class VirtualizationTest : ServerTestBase<ToggleExecutionModeServerFixture<Program>>
|
||||
{
|
||||
public VirtualizationTest(
|
||||
BrowserFixture browserFixture,
|
||||
ToggleExecutionModeServerFixture<Program> serverFixture,
|
||||
ITestOutputHelper output)
|
||||
: base(browserFixture, serverFixture, output)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void InitializeAsyncCore()
|
||||
{
|
||||
Navigate(ServerPathBase, noReload: _serverFixture.ExecutionMode == ExecutionMode.Client);
|
||||
Browser.MountTestComponent<VirtualizationComponent>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlwaysFillsVisibleCapacity_Sync()
|
||||
{
|
||||
var topSpacer = Browser.FindElement(By.Id("sync-container")).FindElement(By.TagName("div"));
|
||||
var expectedInitialSpacerStyle = "height: 0px;";
|
||||
|
||||
int initialItemCount = 0;
|
||||
|
||||
// Wait until items have been rendered.
|
||||
Browser.True(() => (initialItemCount = GetItemCount()) > 0);
|
||||
Browser.Equal(expectedInitialSpacerStyle, () => topSpacer.GetAttribute("style"));
|
||||
|
||||
// Scroll halfway.
|
||||
Browser.ExecuteJavaScript("const container = document.getElementById('sync-container');container.scrollTop = container.scrollHeight * 0.5;");
|
||||
|
||||
// Validate that we get the same item count after scrolling halfway.
|
||||
Browser.Equal(initialItemCount, GetItemCount);
|
||||
Browser.NotEqual(expectedInitialSpacerStyle, () => topSpacer.GetAttribute("style"));
|
||||
|
||||
// Scroll to the bottom.
|
||||
Browser.ExecuteJavaScript("const container = document.getElementById('sync-container');container.scrollTop = container.scrollHeight;");
|
||||
|
||||
// Validate that we get the same item count after scrolling to the bottom.
|
||||
Browser.Equal(initialItemCount, GetItemCount);
|
||||
Browser.NotEqual(expectedInitialSpacerStyle, () => topSpacer.GetAttribute("style"));
|
||||
|
||||
int GetItemCount() => Browser.FindElements(By.Id("sync-item")).Count;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlwaysFillsVisibleCapacity_Async()
|
||||
{
|
||||
var finishLoadingButton = Browser.FindElement(By.Id("finish-loading-button"));
|
||||
|
||||
// Check that no items or placeholders are visible.
|
||||
// No data fetches have happened so we don't know how many items there are.
|
||||
Browser.Equal(0, GetItemCount);
|
||||
Browser.Equal(0, GetPlaceholderCount);
|
||||
|
||||
// Load the initial set of items.
|
||||
finishLoadingButton.Click();
|
||||
|
||||
var initialItemCount = 0;
|
||||
|
||||
// Validate that items appear and placeholders aren't rendered.
|
||||
Browser.True(() => (initialItemCount = GetItemCount()) > 0);
|
||||
Browser.Equal(0, GetPlaceholderCount);
|
||||
|
||||
// Scroll halfway.
|
||||
Browser.ExecuteJavaScript("const container = document.getElementById('async-container');container.scrollTop = container.scrollHeight * 0.5;");
|
||||
|
||||
// Validate that items are replaced by the same number of placeholders.
|
||||
Browser.Equal(0, GetItemCount);
|
||||
Browser.Equal(initialItemCount, GetPlaceholderCount);
|
||||
|
||||
// Load the new set of items.
|
||||
finishLoadingButton.Click();
|
||||
|
||||
// Validate that the placeholders are replaced by the same number of items.
|
||||
Browser.Equal(initialItemCount, GetItemCount);
|
||||
Browser.Equal(0, GetPlaceholderCount);
|
||||
|
||||
// Scroll to the bottom.
|
||||
Browser.ExecuteJavaScript("const container = document.getElementById('async-container');container.scrollTop = container.scrollHeight;");
|
||||
|
||||
// Validate that items are replaced by the same number of placeholders.
|
||||
Browser.Equal(0, GetItemCount);
|
||||
Browser.Equal(initialItemCount, GetPlaceholderCount);
|
||||
|
||||
// Load the new set of items.
|
||||
finishLoadingButton.Click();
|
||||
|
||||
// Validate that the placeholders are replaced by the same number of items.
|
||||
Browser.Equal(initialItemCount, GetItemCount);
|
||||
Browser.Equal(0, GetPlaceholderCount);
|
||||
|
||||
int GetItemCount() => Browser.FindElements(By.Id("async-item")).Count;
|
||||
int GetPlaceholderCount() => Browser.FindElements(By.Id("async-placeholder")).Count;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RerendersWhenItemSizeShrinks_Sync()
|
||||
{
|
||||
int initialItemCount = 0;
|
||||
|
||||
// Wait until items have been rendered.
|
||||
Browser.True(() => (initialItemCount = GetItemCount()) > 0);
|
||||
|
||||
var itemSizeInput = Browser.FindElement(By.Id("item-size-input"));
|
||||
|
||||
// Change the item size.
|
||||
itemSizeInput.SendKeys("\b\b\b50\n");
|
||||
|
||||
// Validate that the list has been re-rendered to show more items.
|
||||
Browser.True(() => GetItemCount() > initialItemCount);
|
||||
|
||||
int GetItemCount() => Browser.FindElements(By.Id("sync-item")).Count;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RerendersWhenItemSizeShrinks_Async()
|
||||
{
|
||||
var finishLoadingButton = Browser.FindElement(By.Id("finish-loading-button"));
|
||||
|
||||
// Load the initial set of items.
|
||||
finishLoadingButton.Click();
|
||||
|
||||
int initialItemCount = 0;
|
||||
|
||||
// Validate that items appear and placeholders aren't rendered.
|
||||
Browser.True(() => (initialItemCount = GetItemCount()) > 0);
|
||||
Browser.Equal(0, GetPlaceholderCount);
|
||||
|
||||
var itemSizeInput = Browser.FindElement(By.Id("item-size-input"));
|
||||
|
||||
// Change the item size.
|
||||
itemSizeInput.SendKeys("\b\b\b50\n");
|
||||
|
||||
// Validate that the same number of loaded items is rendered.
|
||||
Browser.Equal(initialItemCount, GetItemCount);
|
||||
Browser.True(() => GetPlaceholderCount() > 0);
|
||||
|
||||
// Load the new set of items.
|
||||
finishLoadingButton.Click();
|
||||
|
||||
// Validate that the placeholders have been replaced with more loaded items.
|
||||
Browser.True(() => GetItemCount() > initialItemCount);
|
||||
Browser.Equal(0, GetPlaceholderCount);
|
||||
|
||||
int GetItemCount() => Browser.FindElements(By.Id("async-item")).Count;
|
||||
int GetPlaceholderCount() => Browser.FindElements(By.Id("async-placeholder")).Count;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelsOutdatedRefreshes_Async()
|
||||
{
|
||||
var cancellationCount = Browser.FindElement(By.Id("cancellation-count"));
|
||||
var finishLoadingButton = Browser.FindElement(By.Id("finish-loading-button"));
|
||||
|
||||
// Load the initial set of items.
|
||||
finishLoadingButton.Click();
|
||||
|
||||
// Validate that there are no initial cancellations.
|
||||
Browser.Equal("0", () => cancellationCount.Text);
|
||||
|
||||
// Validate that there is no initial fetch to cancel.
|
||||
Browser.ExecuteJavaScript("const container = document.getElementById('async-container');container.scrollTop = 1000;");
|
||||
Browser.Equal("0", () => cancellationCount.Text);
|
||||
|
||||
// Validate that scrolling again cancels the first fetch.
|
||||
Browser.ExecuteJavaScript("const container = document.getElementById('async-container');container.scrollTop = 2000;");
|
||||
Browser.Equal("1", () => cancellationCount.Text);
|
||||
|
||||
// Validate that scrolling again cancels the second fetch.
|
||||
Browser.ExecuteJavaScript("const container = document.getElementById('async-container');container.scrollTop = 3000;");
|
||||
Browser.Equal("2", () => cancellationCount.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanUseViewportAsContainer()
|
||||
{
|
||||
var expectedInitialSpacerStyle = "height: 0px;";
|
||||
var topSpacer = Browser.FindElement(By.Id("viewport-as-root")).FindElement(By.TagName("div"));
|
||||
|
||||
Browser.ExecuteJavaScript("const element = document.getElementById('viewport-as-root'); element.scrollIntoView();");
|
||||
|
||||
// Validate that the top spacer has a height of zero.
|
||||
Browser.Equal(expectedInitialSpacerStyle, () => topSpacer.GetAttribute("style"));
|
||||
|
||||
Browser.ExecuteJavaScript("window.scrollTo(0, document.body.scrollHeight);");
|
||||
|
||||
// Validate that the top spacer has expanded.
|
||||
Browser.NotEqual(expectedInitialSpacerStyle, () => topSpacer.GetAttribute("style"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@
|
|||
<option value="BasicTestApp.SvgWithChildComponent">SVG with child component</option>
|
||||
<option value="BasicTestApp.TextOnlyComponent">Plain text</option>
|
||||
<option value="BasicTestApp.TouchEventComponent">Touch events</option>
|
||||
<option value="BasicTestApp.VirtualizationComponent">Virtualization</option>
|
||||
<option value="BasicTestApp.SelectVariantsComponent">Select with component options</option>
|
||||
<option value="BasicTestApp.ToggleEventComponent">Toggle Event</option>
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
<p>
|
||||
Item size:<br />
|
||||
<input id="item-size-input" type="number" @bind-value="itemSize" />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Synchronous:<br />
|
||||
<div id="sync-container" style="background-color: #eee; height: 500px; overflow-y: auto">
|
||||
<Virtualize Items="@fixedItems" ItemSize="itemSize">
|
||||
<div @key="context" id="sync-item" style="height: @(itemSize)px; background-color: rgb(@((context % 2) * 255), @((1-(context % 2)) * 255), 255);">Item @context</div>
|
||||
</Virtualize>
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Asynchronous:<br />
|
||||
<button id="finish-loading-button" @onclick="FinishLoadingAsync">Finish loading</button><br />
|
||||
Cancellation count: <span id="cancellation-count">@asyncCancellationCount</span><br />
|
||||
<div id="async-container" style="background-color: #eee; height: 500px; overflow-y: auto">
|
||||
<Virtualize ItemsProvider="GetItemsAsync" ItemSize="itemSize">
|
||||
<ItemContent>
|
||||
<div @key="context" id="async-item" style="height: @(itemSize)px; background-color: rgb(@((context % 2) * 255), @((1-(context % 2)) * 255), 255);">Item @context</div>
|
||||
</ItemContent>
|
||||
<Placeholder>
|
||||
<div id="async-placeholder" style="height: @(itemSize)px; background-color: orange;">Loading item @context.Index...</div>
|
||||
</Placeholder>
|
||||
</Virtualize>
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<p id="viewport-as-root">
|
||||
Viewport as root:<br />
|
||||
<Virtualize Items="@fixedItems" ItemSize="itemSize">
|
||||
<div @key="context" style="height: @(itemSize)px; background-color: rgb(@((context % 2) * 255), @((1-(context % 2)) * 255), 255);">Item @context</div>
|
||||
</Virtualize>
|
||||
</p>
|
||||
|
||||
@code {
|
||||
float itemSize = 100;
|
||||
ICollection<int> fixedItems = Enumerable.Range(0, 1000).ToList();
|
||||
|
||||
int asyncTotalItemCount = 200;
|
||||
int asyncCancellationCount = 0;
|
||||
TaskCompletionSource asyncTcs = new TaskCompletionSource();
|
||||
|
||||
HashSet<int> cachedItems = new HashSet<int>();
|
||||
|
||||
async ValueTask<ItemsProviderResult<int>> GetItemsAsync(ItemsProviderRequest request)
|
||||
{
|
||||
var loadingTask = asyncTcs.Task;
|
||||
|
||||
var registration = request.CancellationToken.Register(() => CancelLoadingAsync(request.CancellationToken));
|
||||
|
||||
await loadingTask;
|
||||
|
||||
registration.Dispose();
|
||||
|
||||
return new ItemsProviderResult<int>(Enumerable.Range(request.StartIndex, request.Count), asyncTotalItemCount);
|
||||
}
|
||||
|
||||
void FinishLoadingAsync()
|
||||
{
|
||||
asyncTcs.SetResult();
|
||||
asyncTcs = new TaskCompletionSource();
|
||||
}
|
||||
|
||||
void CancelLoadingAsync(System.Threading.CancellationToken cancellationToken)
|
||||
{
|
||||
asyncTcs.TrySetCanceled(cancellationToken);
|
||||
asyncTcs = new TaskCompletionSource();
|
||||
|
||||
asyncCancellationCount++;
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using BlazorServerWeb_CSharp
|
||||
@using BlazorServerWeb_CSharp.Shared
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
||||
@using Microsoft.JSInterop
|
||||
@*#if (!Hosted)
|
||||
|
|
|
|||
Loading…
Reference in New Issue