diff --git a/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet8_0.verified.txt b/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet8_0.verified.txt index 18fc3a1e..7e468376 100644 --- a/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet8_0.verified.txt +++ b/src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet8_0.verified.txt @@ -473,7 +473,9 @@ namespace DynamicData.Binding { public SortAndBindOptions() { } public int InitialCapacity { get; init; } + public bool ResetOnFirstTimeLoad { get; init; } public int ResetThreshold { get; init; } + public System.Reactive.Concurrency.IScheduler? Scheduler { get; init; } public bool UseBinarySearch { get; init; } public bool UseReplaceForUpdates { get; init; } } diff --git a/src/DynamicData.Tests/Cache/SortAndBindFixture.cs b/src/DynamicData.Tests/Cache/SortAndBindFixture.cs index db254be1..214e24bd 100644 --- a/src/DynamicData.Tests/Cache/SortAndBindFixture.cs +++ b/src/DynamicData.Tests/Cache/SortAndBindFixture.cs @@ -66,6 +66,19 @@ protected override (ChangeSetAggregator Aggregrator, IList Aggregrator, IList List) SetUpTests() + { + var list = new ObservableCollection(new BindingList()); + var aggregator = _source.Connect().SortAndBind(list, _comparer).AsAggregator(); + return (aggregator, list); + } +} + + // Bind to a readonly observable collection public sealed class SortAndBindToReadOnlyObservableCollection: SortAndBindFixture { @@ -151,7 +164,7 @@ public void NeverFireReset() using var sorted = _source.Connect().SortAndBind(out var list, _comparer, options).Subscribe(); using var collectionChangedEvents = list.ObserveCollectionChanges().Select(e => e.EventArgs).Subscribe(_collectionChangedEventArgs.Add); - // fire 5 changes, should always reset because it's below the threshold + // fire 5 changes, should not reset because it's below the threshold _source.AddOrUpdate(Enumerable.Range(0, 5).Select(i => new Person($"P{i}", i))); _collectionChangedEventArgs.Count.Should().Be(5); _collectionChangedEventArgs.All(a => a.Action == NotifyCollectionChangedAction.Add).Should().BeTrue(); @@ -168,6 +181,40 @@ public void NeverFireReset() } + [Fact] + [Description("Check reset is fired on first time load. This checks historic first time load opt-in.")] + public void FireResetOnFirstTimeLoad() + { + var options = new SortAndBindOptions { ResetThreshold = 10, ResetOnFirstTimeLoad = true}; + + using var sorted = _source.Connect().SortAndBind(out var list, _comparer, options).Subscribe(); + using var collectionChangedEvents = list.ObserveCollectionChanges().Select(e => e.EventArgs).Subscribe(_collectionChangedEventArgs.Add); + + // fire 5 changes, should always reset even though it's below the threshold + _source.AddOrUpdate(Enumerable.Range(0, 5).Select(i => new Person($"P{i}", i))); + _collectionChangedEventArgs.Count.Should().Be(1); + _collectionChangedEventArgs.All(a => a.Action == NotifyCollectionChangedAction.Reset).Should().BeTrue(); + + + _collectionChangedEventArgs.Clear(); + + // fire 15 changes, we should get a refresh event + _source.AddOrUpdate(Enumerable.Range(10, 15).Select(i => new Person($"P{i}", i))); + _collectionChangedEventArgs.Count.Should().Be(1); + _collectionChangedEventArgs[0].Action.Should().Be(NotifyCollectionChangedAction.Reset); + + _collectionChangedEventArgs.Clear(); + + // fires further 5 changes, should result individual notifications + _source.AddOrUpdate(Enumerable.Range(-10, 5).Select(i => new Person($"P{i}", i))); + _collectionChangedEventArgs.Count.Should().Be(5); + _collectionChangedEventArgs.All(a => a.Action == NotifyCollectionChangedAction.Add).Should().BeTrue(); + + list.Count.Should().Be(25); + + } + + public void Dispose() => _source.Dispose(); } diff --git a/src/DynamicData/Binding/BindPaged.cs b/src/DynamicData/Binding/BindPaged.cs index c8d309a3..1f681456 100644 --- a/src/DynamicData/Binding/BindPaged.cs +++ b/src/DynamicData/Binding/BindPaged.cs @@ -2,6 +2,7 @@ // Roland Pheasant licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive.Concurrency; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Reactive.Subjects; diff --git a/src/DynamicData/Binding/BindVirtualized.cs b/src/DynamicData/Binding/BindVirtualized.cs index dac893e5..8c0fe07c 100644 --- a/src/DynamicData/Binding/BindVirtualized.cs +++ b/src/DynamicData/Binding/BindVirtualized.cs @@ -2,6 +2,7 @@ // Roland Pheasant licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive.Concurrency; using System.Reactive.Disposables; using System.Reactive.Linq; using System.Reactive.Subjects; diff --git a/src/DynamicData/Binding/BindingListEventsSuspender.cs b/src/DynamicData/Binding/BindingListEventsSuspender.cs index fb2f9958..f2fa1859 100644 --- a/src/DynamicData/Binding/BindingListEventsSuspender.cs +++ b/src/DynamicData/Binding/BindingListEventsSuspender.cs @@ -2,30 +2,26 @@ // Roland Pheasant licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -#if SUPPORTS_BINDINGLIST using System.ComponentModel; using System.Reactive.Disposables; -namespace DynamicData.Binding -{ - internal sealed class BindingListEventsSuspender : IDisposable - { - private readonly IDisposable _cleanUp; +namespace DynamicData.Binding; - public BindingListEventsSuspender(BindingList list) - { - list.RaiseListChangedEvents = false; +internal sealed class BindingListEventsSuspender : IDisposable +{ + private readonly IDisposable _cleanUp; - _cleanUp = Disposable.Create( - () => - { - list.RaiseListChangedEvents = true; - list.ResetBindings(); - }); - } + public BindingListEventsSuspender(BindingList list) + { + list.RaiseListChangedEvents = false; - public void Dispose() => _cleanUp.Dispose(); + _cleanUp = Disposable.Create( + () => + { + list.RaiseListChangedEvents = true; + list.ResetBindings(); + }); } -} -#endif + public void Dispose() => _cleanUp.Dispose(); +} diff --git a/src/DynamicData/Binding/SortAndBind.cs b/src/DynamicData/Binding/SortAndBind.cs index c22ef8bf..09756fe2 100644 --- a/src/DynamicData/Binding/SortAndBind.cs +++ b/src/DynamicData/Binding/SortAndBind.cs @@ -2,6 +2,7 @@ // Roland Pheasant licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.ComponentModel; using System.Reactive.Disposables; using System.Reactive.Linq; using DynamicData.Cache; @@ -30,15 +31,22 @@ public SortAndBind(IObservable> source, SortAndBindOptions options, IList target) { + var scheduler = options.Scheduler; + // static one time comparer var applicator = new SortApplicator(_cache, target, comparer, options); - _sorted = source.Do(changes => + if (scheduler is not null) + source = source.ObserveOn(scheduler); + + _sorted = source.Select((changes, index) => { // clone to local cache so that we can sort the entire set when threshold is over a certain size. _cache.Clone(changes); - applicator.ProcessChanges(changes); + applicator.ProcessChanges(changes, index == 0); + + return changes; }); } @@ -48,6 +56,14 @@ public SortAndBind(IObservable> source, IList target) => _sorted = Observable.Create>(observer => { + var scheduler = options.Scheduler; + + if (scheduler is not null) + { + source = source.ObserveOn(scheduler); + comparerChanged = comparerChanged.ObserveOn(scheduler); + } + var locker = new object(); SortApplicator? sortApplicator = null; @@ -61,14 +77,17 @@ public SortAndBind(IObservable> source, // Listen to changes and apply the sorting var subscriber = source.Synchronize(locker) - .Do(changes => + .Select((changes, index) => { _cache.Clone(changes); // the sort applicator will be null until the comparer change observable fires. if (sortApplicator is not null) - sortApplicator.ProcessChanges(changes); - }).SubscribeSafe(observer); + sortApplicator.ProcessChanges(changes, index == 0); + + return changes; + }) + .SubscribeSafe(observer); return new CompositeDisposable(latestComparer, subscriber); }); @@ -92,10 +111,12 @@ public void ApplySort() } // apply sorting as a side effect of the observable stream. - public void ProcessChanges(IChangeSet changeSet) + public void ProcessChanges(IChangeSet changeSet, bool isFirstTimeLoad) { + var forceReset = isFirstTimeLoad && options.ResetOnFirstTimeLoad; + // apply sorted changes to the target collection - if (options.ResetThreshold > 0 && options.ResetThreshold < changeSet.Count) + if (forceReset || (options.ResetThreshold > 0 && options.ResetThreshold < changeSet.Count)) { Reset(cache.Items.OrderBy(t => t, comparer), true); } @@ -122,6 +143,15 @@ private void Reset(IEnumerable sorted, bool fireReset) observableCollectionExtended.Load(sorted); } } + else if (fireReset && target is BindingList bindingList) + { + // suspend count as it can result in a flood of binding updates. + using (new BindingListEventsSuspender(bindingList)) + { + target.Clear(); + target.AddRange(sorted); + } + } else { target.Clear(); diff --git a/src/DynamicData/Binding/SortAndBindOptions.cs b/src/DynamicData/Binding/SortAndBindOptions.cs index dc060d89..584b57e4 100644 --- a/src/DynamicData/Binding/SortAndBindOptions.cs +++ b/src/DynamicData/Binding/SortAndBindOptions.cs @@ -2,6 +2,8 @@ // Roland Pheasant licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive.Concurrency; + namespace DynamicData.Binding; /// @@ -28,4 +30,18 @@ public record struct SortAndBindOptions() /// Set the initial capacity of the readonly observable collection. /// public int InitialCapacity { get; init; } = -1; + + /// + /// Reset on first time load. + /// + /// This is opt-in only and is only required for consumers who need to maintain + /// backwards compatibility will the former BindingOptions.ResetOnFirstTimeLoad. + /// + public bool ResetOnFirstTimeLoad { get; init; } + + /// + /// The default main thread scheduler. If left null, it is the responsibility of the consumer + /// to ensure binding takes place on the main thread. + /// + public IScheduler? Scheduler { get; init; } } diff --git a/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs b/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs index c89376a6..00d93fc7 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.Collections.ObjectModel; +using System.Reactive.Concurrency; using DynamicData.Binding; namespace DynamicData; diff --git a/src/DynamicData/List/ListEx.cs b/src/DynamicData/List/ListEx.cs index b24bdabc..f6e8bbf7 100644 --- a/src/DynamicData/List/ListEx.cs +++ b/src/DynamicData/List/ListEx.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.Collections.ObjectModel; - +using System.ComponentModel; using DynamicData.Kernel; // ReSharper disable once CheckNamespace @@ -101,7 +101,8 @@ public static void AddRange(this IList source, IEnumerable items) extendedList.AddRange(items); break; default: - items.ForEach(source.Add); + foreach (var t in items) + source.Add(t); break; } }