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 { navigateTo, internalFunctions as navigationManagerInternalFunctions } from './Services/NavigationManager';
|
||||||
import { attachRootComponentToElement } from './Rendering/Renderer';
|
import { attachRootComponentToElement } from './Rendering/Renderer';
|
||||||
import { domFunctions } from './DomWrapper';
|
import { domFunctions } from './DomWrapper';
|
||||||
|
import { Virtualize } from './Virtualize';
|
||||||
|
|
||||||
// Make the following APIs available in global scope for invocation from JS
|
// Make the following APIs available in global scope for invocation from JS
|
||||||
window['Blazor'] = {
|
window['Blazor'] = {
|
||||||
|
|
@ -10,5 +11,6 @@ window['Blazor'] = {
|
||||||
attachRootComponentToElement,
|
attachRootComponentToElement,
|
||||||
navigationManager: navigationManagerInternalFunctions,
|
navigationManager: navigationManagerInternalFunctions,
|
||||||
domWrapper: domFunctions,
|
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.SvgWithChildComponent">SVG with child component</option>
|
||||||
<option value="BasicTestApp.TextOnlyComponent">Plain text</option>
|
<option value="BasicTestApp.TextOnlyComponent">Plain text</option>
|
||||||
<option value="BasicTestApp.TouchEventComponent">Touch events</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.SelectVariantsComponent">Select with component options</option>
|
||||||
<option value="BasicTestApp.ToggleEventComponent">Toggle Event</option>
|
<option value="BasicTestApp.ToggleEventComponent">Toggle Event</option>
|
||||||
</select>
|
</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
|
||||||
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using BlazorServerWeb_CSharp
|
@using BlazorServerWeb_CSharp
|
||||||
@using BlazorServerWeb_CSharp.Shared
|
@using BlazorServerWeb_CSharp.Shared
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@*#if (!Hosted)
|
@*#if (!Hosted)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue