Skip to content

Large diffs are not rendered by default.

49 changes: 48 additions & 1 deletion src/DynamicData.Tests/Cache/SortAndBindFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ protected override (ChangeSetAggregator<Person, string> Aggregrator, IList<Perso
}


// Bind to a binding list
public sealed class SortAndBindToBindingList : SortAndBindFixture

{
protected override (ChangeSetAggregator<Person, string> Aggregrator, IList<Person> List) SetUpTests()
{
var list = new ObservableCollection<Person>(new BindingList<Person>());
var aggregator = _source.Connect().SortAndBind(list, _comparer).AsAggregator();
return (aggregator, list);
}
}


// Bind to a readonly observable collection
public sealed class SortAndBindToReadOnlyObservableCollection: SortAndBindFixture
{
Expand Down Expand Up @@ -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();
Expand All @@ -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();
}
Expand Down
8 changes: 5 additions & 3 deletions src/DynamicData/Binding/BindPaged.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,7 +17,8 @@ namespace DynamicData.Binding;
internal sealed class BindPaged<TObject, TKey>(
IObservable<IChangeSet<TObject, TKey, PageContext<TObject>>> source,
IList<TObject> targetList,
SortAndBindOptions? options)
SortAndBindOptions? options,
IScheduler? scheduler)
where TObject : notnull
where TKey : notnull
{
Expand All @@ -31,7 +33,7 @@ private IObservable<IChangeSet<TObject, TKey>> UseProvidedOptions(SortAndBindOpt
.Select(changesWithContext => changesWithContext.Context.Comparer)
.DistinctUntilChanged();

return changes.SortAndBind(targetList, comparedChanged, sortAndBindOptions);
return changes.SortAndBind(targetList, comparedChanged, sortAndBindOptions, scheduler);
});

private IObservable<IChangeSet<TObject, TKey>> UseContextSortOptions() =>
Expand Down Expand Up @@ -68,7 +70,7 @@ private IObservable<IChangeSet<TObject, TKey>> UseContextSortOptions() =>
};

subscriber.Disposable = changesSubject
.SortAndBind(targetList, comparerSubject.DistinctUntilChanged(), extractedOptions)
.SortAndBind(targetList, comparerSubject.DistinctUntilChanged(), extractedOptions, scheduler)
.SubscribeSafe(observer);

comparerSubject.OnNext(changesWithContext.Context.Comparer);
Expand Down
8 changes: 5 additions & 3 deletions src/DynamicData/Binding/BindVirtualized.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,7 +15,8 @@ namespace DynamicData.Binding;
internal sealed class BindVirtualized<TObject, TKey>(
IObservable<IChangeSet<TObject, TKey, VirtualContext<TObject>>> source,
IList<TObject> targetList,
SortAndBindOptions? options)
SortAndBindOptions? options,
IScheduler? scheduler)
where TObject : notnull
where TKey : notnull
{
Expand All @@ -29,7 +31,7 @@ private IObservable<IChangeSet<TObject, TKey>> UseProvidedOptions(SortAndBindOpt
.Select(changesWithContext => changesWithContext.Context.Comparer)
.DistinctUntilChanged();

return changes.SortAndBind(targetList, comparedChanged, sortAndBindOptions);
return changes.SortAndBind(targetList, comparedChanged, sortAndBindOptions, scheduler);
});

private IObservable<IChangeSet<TObject, TKey>> UseVirtualSortOptions() =>
Expand Down Expand Up @@ -66,7 +68,7 @@ private IObservable<IChangeSet<TObject, TKey>> UseVirtualSortOptions() =>
};

subscriber.Disposable = changesSubject
.SortAndBind(targetList, comparerSubject.DistinctUntilChanged(), extractedOptions)
.SortAndBind(targetList, comparerSubject.DistinctUntilChanged(), extractedOptions, scheduler)
.SubscribeSafe(observer);

comparerSubject.OnNext(changesWithContext.Context.Comparer);
Expand Down
34 changes: 15 additions & 19 deletions src/DynamicData/Binding/BindingListEventsSuspender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> : IDisposable
{
private readonly IDisposable _cleanUp;
namespace DynamicData.Binding;

public BindingListEventsSuspender(BindingList<T> list)
{
list.RaiseListChangedEvents = false;
internal sealed class BindingListEventsSuspender<T> : IDisposable
{
private readonly IDisposable _cleanUp;

_cleanUp = Disposable.Create(
() =>
{
list.RaiseListChangedEvents = true;
list.ResetBindings();
});
}
public BindingListEventsSuspender(BindingList<T> list)
{
list.RaiseListChangedEvents = false;

public void Dispose() => _cleanUp.Dispose();
_cleanUp = Disposable.Create(
() =>
{
list.RaiseListChangedEvents = true;
list.ResetBindings();
});
}
}

#endif
public void Dispose() => _cleanUp.Dispose();
}
51 changes: 42 additions & 9 deletions src/DynamicData/Binding/SortAndBind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.ComponentModel;
using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using DynamicData.Cache;
Expand All @@ -28,26 +30,43 @@ internal sealed class SortAndBind<TObject, TKey>
public SortAndBind(IObservable<IChangeSet<TObject, TKey>> source,
IComparer<TObject> comparer,
SortAndBindOptions options,
IList<TObject> target)
IList<TObject> target,
IScheduler? scheduler)
{
scheduler ??= DynamicDataOptions.BindingScheduler;

// 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;
});
}

public SortAndBind(IObservable<IChangeSet<TObject, TKey>> source,
IObservable<IComparer<TObject>> comparerChanged,
SortAndBindOptions options,
IList<TObject> target)
IList<TObject> target,
IScheduler? scheduler)
=> _sorted = Observable.Create<IChangeSet<TObject, TKey>>(observer =>
{
scheduler ??= DynamicDataOptions.BindingScheduler;

if (scheduler is not null)
{
source = source.ObserveOn(scheduler);
comparerChanged = comparerChanged.ObserveOn(scheduler);
}

var locker = new object();
SortApplicator? sortApplicator = null;

Expand All @@ -61,14 +80,17 @@ public SortAndBind(IObservable<IChangeSet<TObject, TKey>> 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);
});
Expand All @@ -92,10 +114,12 @@ public void ApplySort()
}

// apply sorting as a side effect of the observable stream.
public void ProcessChanges(IChangeSet<TObject, TKey> changeSet)
public void ProcessChanges(IChangeSet<TObject, TKey> 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);
}
Expand All @@ -122,6 +146,15 @@ private void Reset(IEnumerable<TObject> sorted, bool fireReset)
observableCollectionExtended.Load(sorted);
}
}
else if (fireReset && target is BindingList<TObject> bindingList)
{
// suspend count as it can result in a flood of binding updates.
using (new BindingListEventsSuspender<TObject>(bindingList))
{
target.Clear();
target.AddRange(sorted);
}
}
else
{
target.Clear();
Expand Down
8 changes: 8 additions & 0 deletions src/DynamicData/Binding/SortAndBindOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,12 @@ public record struct SortAndBindOptions()
/// Set the initial capacity of the readonly observable collection.
/// </summary>
public int InitialCapacity { get; init; } = -1;

/// <summary>
/// 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.
/// </summary>
public bool ResetOnFirstTimeLoad { get; init; }
}
Loading