diff --git a/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor b/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor index 49382045ad1..b79e6086a89 100644 --- a/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor +++ b/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor @@ -1,4 +1,4 @@ -@namespace BootstrapBlazor.Components +@namespace BootstrapBlazor.Components @using Microsoft.AspNetCore.Components.Web.Virtualization @typeparam TValue @inherits PopoverCompleteBase @@ -39,9 +39,7 @@ } else { - - + @RenderVirtualize() } } diff --git a/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor.cs b/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor.cs index dc9f02e1ca1..5ef877e1c94 100644 --- a/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor.cs +++ b/src/BootstrapBlazor/Components/AutoFill/AutoFill.razor.cs @@ -135,6 +135,17 @@ public partial class AutoFill [NotNull] public Func>>? OnQueryAsync { get; set; } +#if NET11_0_OR_GREATER + /// + /// 获得/设置虚拟滚动项目比较器。远程数据源返回新实例时,应按唯一标识进行比较 + /// Gets or sets the virtualized item comparer. It should compare unique identifiers when the remote data source returns new instances. + /// v11.0.0 + /// + [Parameter] + [NotNull] + public IEqualityComparer? ItemComparer { get; set; } +#endif + /// /// 获得/设置 点击清除按钮回调方法 默认为 null /// Gets or sets the callback method when the clear button is clicked. Default is null @@ -198,6 +209,10 @@ protected override void OnParametersSet() _displayText = GetDisplayText(Value); _clientValue = _displayText; Items ??= []; + +#if NET11_0_OR_GREATER + ItemComparer ??= EqualityComparer.Default; +#endif } /// @@ -343,4 +358,10 @@ protected override async Task OnBeforeBlurAsync() await InvokeVoidAsync("setValue", Id, ""); } } + + private RenderFragment RenderVirtualize() => VirtualizeHelper.Render(LoadItems, RenderRow, RenderPlaceholderRow, RowHeight, OverscanCount, +#if NET11_0_OR_GREATER + ItemComparer, +#endif + element => _virtualizeElement = element); } diff --git a/src/BootstrapBlazor/Components/Select/MultiSelect.razor b/src/BootstrapBlazor/Components/Select/MultiSelect.razor index 977a816630e..167e98c10cf 100644 --- a/src/BootstrapBlazor/Components/Select/MultiSelect.razor +++ b/src/BootstrapBlazor/Components/Select/MultiSelect.razor @@ -84,9 +84,7 @@ } else { - - + @RenderVirtualize() } } diff --git a/src/BootstrapBlazor/Components/Select/MultiSelect.razor.cs b/src/BootstrapBlazor/Components/Select/MultiSelect.razor.cs index 46387fbd94e..ec9b15f320b 100644 --- a/src/BootstrapBlazor/Components/Select/MultiSelect.razor.cs +++ b/src/BootstrapBlazor/Components/Select/MultiSelect.razor.cs @@ -293,6 +293,12 @@ private async ValueTask> LoadItems(ItemsProvid return _result; } + private RenderFragment RenderVirtualize() => VirtualizeHelper.Render(LoadItems, RenderRow, RenderPlaceHolderRow, RowHeight, OverscanCount, +#if NET11_0_OR_GREATER + VirtualizeItemComparer, +#endif + element => _virtualizeElement = element); + /// /// /// diff --git a/src/BootstrapBlazor/Components/Select/Select.razor b/src/BootstrapBlazor/Components/Select/Select.razor index 47a5358528d..495277f30f4 100644 --- a/src/BootstrapBlazor/Components/Select/Select.razor +++ b/src/BootstrapBlazor/Components/Select/Select.razor @@ -1,4 +1,4 @@ -@namespace BootstrapBlazor.Components +@namespace BootstrapBlazor.Components @using Microsoft.AspNetCore.Components.Web.Virtualization @typeparam TValue @inherits SimpleSelectBase @@ -49,9 +49,7 @@ } else { - - + @RenderVirtualize() } } diff --git a/src/BootstrapBlazor/Components/Select/Select.razor.cs b/src/BootstrapBlazor/Components/Select/Select.razor.cs index 01cc586ae50..1c017f2c1d4 100644 --- a/src/BootstrapBlazor/Components/Select/Select.razor.cs +++ b/src/BootstrapBlazor/Components/Select/Select.razor.cs @@ -270,6 +270,12 @@ private async ValueTask> LoadItems(ItemsProvid return _result; } + private RenderFragment RenderVirtualize() => VirtualizeHelper.Render(LoadItems, RenderRow, RenderPlaceHolderRow, RowHeight, OverscanCount, +#if NET11_0_OR_GREATER + VirtualizeItemComparer, +#endif + element => _virtualizeElement = element); + /// /// /// diff --git a/src/BootstrapBlazor/Components/Select/SimpleSelectBase.cs b/src/BootstrapBlazor/Components/Select/SimpleSelectBase.cs index 7d15a928d8f..e8cc3e521bd 100644 --- a/src/BootstrapBlazor/Components/Select/SimpleSelectBase.cs +++ b/src/BootstrapBlazor/Components/Select/SimpleSelectBase.cs @@ -21,6 +21,14 @@ public abstract class SimpleSelectBase : SelectBase [NotNull] protected Virtualize? _virtualizeElement = default; +#if NET11_0_OR_GREATER + /// + /// 获得虚拟滚动项目比较器 + /// Gets the virtualized item comparer + /// + protected static IEqualityComparer VirtualizeItemComparer { get; } = new SelectedItemComparer(); +#endif + /// /// 获得/设置 最后选中的值字符串 /// Gets or sets the last selected value string diff --git a/src/BootstrapBlazor/Components/SelectGeneric/MultiSelectGeneric.razor b/src/BootstrapBlazor/Components/SelectGeneric/MultiSelectGeneric.razor index dcc2efdf595..c19c0666b5f 100644 --- a/src/BootstrapBlazor/Components/SelectGeneric/MultiSelectGeneric.razor +++ b/src/BootstrapBlazor/Components/SelectGeneric/MultiSelectGeneric.razor @@ -1,4 +1,4 @@ -@namespace BootstrapBlazor.Components +@namespace BootstrapBlazor.Components @using Microsoft.AspNetCore.Components.Web.Virtualization @typeparam TValue @inherits SelectBase> @@ -77,9 +77,7 @@ } else { - - + @RenderVirtualize() } } diff --git a/src/BootstrapBlazor/Components/SelectGeneric/MultiSelectGeneric.razor.cs b/src/BootstrapBlazor/Components/SelectGeneric/MultiSelectGeneric.razor.cs index 3de3aba0418..0187674789a 100644 --- a/src/BootstrapBlazor/Components/SelectGeneric/MultiSelectGeneric.razor.cs +++ b/src/BootstrapBlazor/Components/SelectGeneric/MultiSelectGeneric.razor.cs @@ -245,6 +245,12 @@ public partial class MultiSelectGeneric : IModelEqualityComparer [NotNull] private Virtualize>? _virtualizeElement = default; +#if NET11_0_OR_GREATER + private IEqualityComparer> VirtualizeItemComparer => _virtualizeItemComparer ??= new SelectedItemComparer(this); + + private IEqualityComparer>? _virtualizeItemComparer; +#endif + /// /// /// @@ -357,6 +363,12 @@ private async ValueTask>> LoadItems(Ite int GetCountByTotal() => _totalCount == 0 ? request.Count : Math.Min(request.Count, _totalCount - request.StartIndex); } + private RenderFragment RenderVirtualize() => VirtualizeHelper.Render(LoadItems, RenderRow, RenderPlaceHolderRow, RowHeight, OverscanCount, +#if NET11_0_OR_GREATER + VirtualizeItemComparer, +#endif + element => _virtualizeElement = element); + /// /// /// diff --git a/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor b/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor index 72d2c846123..1a048ce01fa 100644 --- a/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor +++ b/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor @@ -1,4 +1,4 @@ -@namespace BootstrapBlazor.Components +@namespace BootstrapBlazor.Components @using Microsoft.AspNetCore.Components.Web.Virtualization @typeparam TValue @inherits SelectBase @@ -48,7 +48,7 @@ } else { - + @RenderVirtualize() } } diff --git a/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.cs b/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.cs index 9e3bd44d209..303cc62fc2a 100644 --- a/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.cs +++ b/src/BootstrapBlazor/Components/SelectGeneric/SelectGeneric.razor.cs @@ -114,6 +114,12 @@ public partial class SelectGeneric : ISelectGeneric, IModelEqual [NotNull] private Virtualize>? VirtualizeElement { get; set; } +#if NET11_0_OR_GREATER + private IEqualityComparer> VirtualizeItemComparer => _virtualizeItemComparer ??= new SelectedItemComparer(this); + + private IEqualityComparer>? _virtualizeItemComparer; +#endif + /// /// 获得/设置 绑定数据集 /// Gets or sets Bound Dataset @@ -326,6 +332,12 @@ private async ValueTask>> LoadItems(Ite int GetCountByTotal() => TotalCount == 0 ? request.Count : Math.Min(request.Count, TotalCount - request.StartIndex); } + private RenderFragment RenderVirtualize() => VirtualizeHelper.Render(LoadItems, RenderRow, RenderPlaceHolderRow, RowHeight, OverscanCount, +#if NET11_0_OR_GREATER + VirtualizeItemComparer, +#endif + element => VirtualizeElement = element); + private async Task RefreshVirtualizeElement() { if (IsVirtualize && OnQueryAsync != null) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor b/src/BootstrapBlazor/Components/Table/Table.razor index f22d0bbba15..bd53cef7a42 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor +++ b/src/BootstrapBlazor/Components/Table/Table.razor @@ -111,10 +111,7 @@ { @if (OnQueryAsync != null) { - - + @RenderVirtualize() } else { diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 3428c849b2d..17fc745d338 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -68,6 +68,12 @@ public partial class Table : ITable, IModelEqualityComparer where [NotNull] private Virtualize? _virtualizeElement = null; +#if NET11_0_OR_GREATER + private IEqualityComparer VirtualizeItemComparer => _virtualizeItemComparer ??= new ModelHashSetComparer(this); + + private IEqualityComparer? _virtualizeItemComparer; +#endif + /// /// 获得 Table 组件样式表 /// Get Table Component CSS Class @@ -1846,6 +1852,12 @@ private async ValueTask> LoadItems(ItemsProviderReque return new ItemsProviderResult(QueryItems, TotalCount); } + private RenderFragment RenderVirtualize() => VirtualizeHelper.Render(LoadItems, RenderRow, RenderPlaceholderRow, RowHeight, OverscanCount, +#if NET11_0_OR_GREATER + VirtualizeItemComparer, +#endif + element => _virtualizeElement = element); + private Func TriggerDoubleClickCell(ITableColumn col, TItem item) => async () => { if (OnDoubleClickCellCallback != null) diff --git a/src/BootstrapBlazor/Components/Virtualization/VirtualizeHelper.cs b/src/BootstrapBlazor/Components/Virtualization/VirtualizeHelper.cs new file mode 100644 index 00000000000..ebbde954c84 --- /dev/null +++ b/src/BootstrapBlazor/Components/Virtualization/VirtualizeHelper.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.AspNetCore.Components.Web.Virtualization; + +namespace BootstrapBlazor.Components; + +internal static class VirtualizeHelper +{ + public static RenderFragment Render( + ItemsProviderDelegate itemsProvider, + RenderFragment itemContent, + RenderFragment placeholder, + float itemSize, + int overscanCount, +#if NET11_0_OR_GREATER + IEqualityComparer itemComparer, +#endif + Action> componentRef) => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, nameof(Virtualize.ItemsProvider), itemsProvider); + builder.AddAttribute(2, nameof(Virtualize.ItemContent), itemContent); + builder.AddAttribute(3, nameof(Virtualize.Placeholder), placeholder); + builder.AddAttribute(4, nameof(Virtualize.ItemSize), itemSize); + builder.AddAttribute(5, nameof(Virtualize.OverscanCount), overscanCount); +#if NET11_0_OR_GREATER + builder.AddAttribute(6, nameof(Virtualize.ItemComparer), itemComparer); +#endif + builder.AddComponentReferenceCapture(7, component => componentRef((Virtualize)component)); + builder.CloseComponent(); + }; +} diff --git a/src/BootstrapBlazor/Misc/SelectedItemComparer.cs b/src/BootstrapBlazor/Misc/SelectedItemComparer.cs new file mode 100644 index 00000000000..47c0bd0d8ad --- /dev/null +++ b/src/BootstrapBlazor/Misc/SelectedItemComparer.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Components; + +#if NET11_0_OR_GREATER +internal sealed class SelectedItemComparer : IEqualityComparer +{ + public bool Equals(SelectedItem? x, SelectedItem? y) => ReferenceEquals(x, y) || (x is not null && y is not null && x.Value == y.Value); + + public int GetHashCode(SelectedItem obj) => obj.Value.GetHashCode(); +} + +internal sealed class SelectedItemComparer(IModelEqualityComparer comparer) : IEqualityComparer> +{ + private readonly ModelHashSetComparer _comparer = new(comparer); + + public bool Equals(SelectedItem? x, SelectedItem? y) => ReferenceEquals(x, y) || (x is not null && y is not null && _comparer.Equals(x.Value, y.Value)); + + public int GetHashCode(SelectedItem obj) => obj.Value is null ? 0 : _comparer.GetHashCode(obj.Value); +} +#endif diff --git a/test/UnitTest/Misc/SelectedItemComparerTest.cs b/test/UnitTest/Misc/SelectedItemComparerTest.cs new file mode 100644 index 00000000000..bfac701b705 --- /dev/null +++ b/test/UnitTest/Misc/SelectedItemComparerTest.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +namespace UnitTest.Misc; + +#if NET11_0_OR_GREATER +public class SelectedItemComparerTest : BootstrapBlazorTestBase +{ + [Fact] + public void SelectedItem_Equals_Ok() + { + var comparer = CreateComparer("BootstrapBlazor.Components.SelectedItemComparer"); + var item = new SelectedItem("1", "Test 1"); + + Assert.True(comparer.Equals(null, null)); + Assert.True(comparer.Equals(item, item)); + Assert.False(comparer.Equals(null, item)); + Assert.False(comparer.Equals(item, null)); + Assert.True(comparer.Equals(item, new SelectedItem("1", "Test 2"))); + Assert.False(comparer.Equals(item, new SelectedItem("2", "Test 1"))); + Assert.Equal(item.Value.GetHashCode(), comparer.GetHashCode(item)); + } + + [Fact] + public void SelectedItemGeneric_Equals_Ok() + { + var modelComparer = new MockModelEqualityComparer() + { + ModelEqualityComparer = (x, y) => x.Id == y.Id + }; + var comparer = CreateComparer>( + "BootstrapBlazor.Components.SelectedItemComparer`1", + [typeof(Model)], + modelComparer); + var item = new SelectedItem(new() { Id = 1 }, "Test 1"); + + Assert.True(comparer.Equals(null, null)); + Assert.True(comparer.Equals(item, item)); + Assert.False(comparer.Equals(null, item)); + Assert.False(comparer.Equals(item, null)); + Assert.True(comparer.Equals(item, new SelectedItem(new() { Id = 1 }, "Test 2"))); + Assert.False(comparer.Equals(item, new SelectedItem(new() { Id = 2 }, "Test 1"))); + Assert.Equal(1, comparer.GetHashCode(item)); + Assert.Equal(0, comparer.GetHashCode(new SelectedItem(null!, "Null"))); + } + + private static IEqualityComparer CreateComparer(string typeName, Type[]? genericArguments = null, params object[] arguments) + { + var type = typeof(SelectedItem).Assembly.GetType(typeName, throwOnError: true)!; + if (genericArguments is not null) + { + type = type.MakeGenericType(genericArguments); + } + + return (IEqualityComparer)Activator.CreateInstance( + type, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: arguments, + culture: null)!; + } + + private sealed class Model + { + public int Id { get; set; } + + public override int GetHashCode() => Id; + } + + private sealed class MockModelEqualityComparer : IModelEqualityComparer + { + public Func? ModelEqualityComparer { get; set; } + + public Type CustomKeyAttribute { get; set; } = typeof(KeyAttribute); + + public bool Equals(TModel? x, TModel? y) => this.Equals(x, y); + } +} +#endif