Skip to content

Commit

Permalink
SortAndBind fixes and improvements (#939)
Browse files Browse the repository at this point in the history
* SortAndBind: Enable opt-in to ResetOnFirstTimeLoad + SortAndBind specialized handling for binding list
* Add MainThreadScheduler option to SortAndBind Options and use this for binding
  • Loading branch information
RolandPheasant authored Jan 20, 2025
1 parent 1ab7c1c commit 353a316
Show file tree
Hide file tree
Showing 9 changed files with 124 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
Expand Down
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
1 change: 1 addition & 0 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 Down
1 change: 1 addition & 0 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 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();
}
44 changes: 37 additions & 7 deletions src/DynamicData/Binding/SortAndBind.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.ComponentModel;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using DynamicData.Cache;
Expand Down Expand Up @@ -30,15 +31,22 @@ public SortAndBind(IObservable<IChangeSet<TObject, TKey>> source,
SortAndBindOptions options,
IList<TObject> 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;
});
}

Expand All @@ -48,6 +56,14 @@ public SortAndBind(IObservable<IChangeSet<TObject, TKey>> source,
IList<TObject> target)
=> _sorted = Observable.Create<IChangeSet<TObject, TKey>>(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;

Expand All @@ -61,14 +77,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 +111,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 +143,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
16 changes: 16 additions & 0 deletions src/DynamicData/Binding/SortAndBindOptions.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.Reactive.Concurrency;

namespace DynamicData.Binding;

/// <summary>
Expand All @@ -28,4 +30,18 @@ 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; }

/// <summary>
/// The default main thread scheduler. If left null, it is the responsibility of the consumer
/// to ensure binding takes place on the main thread.
/// </summary>
public IScheduler? Scheduler { get; init; }
}
1 change: 1 addition & 0 deletions src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/DynamicData/List/ListEx.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -101,7 +101,8 @@ public static void AddRange<T>(this IList<T> source, IEnumerable<T> items)
extendedList.AddRange(items);
break;
default:
items.ForEach(source.Add);
foreach (var t in items)
source.Add(t);
break;
}
}
Expand Down

0 comments on commit 353a316

Please sign in to comment.