Skip to content

Commit 353a316

Browse files
authoredJan 20, 2025··
SortAndBind fixes and improvements (#939)
* SortAndBind: Enable opt-in to ResetOnFirstTimeLoad + SortAndBind specialized handling for binding list * Add MainThreadScheduler option to SortAndBind Options and use this for binding
1 parent 1ab7c1c commit 353a316

9 files changed

+124
-29
lines changed
 

‎src/DynamicData.Tests/API/ApiApprovalTests.DynamicDataTests.DotNet8_0.verified.txt

+2
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,9 @@ namespace DynamicData.Binding
473473
{
474474
public SortAndBindOptions() { }
475475
public int InitialCapacity { get; init; }
476+
public bool ResetOnFirstTimeLoad { get; init; }
476477
public int ResetThreshold { get; init; }
478+
public System.Reactive.Concurrency.IScheduler? Scheduler { get; init; }
477479
public bool UseBinarySearch { get; init; }
478480
public bool UseReplaceForUpdates { get; init; }
479481
}

‎src/DynamicData.Tests/Cache/SortAndBindFixture.cs

+48-1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,19 @@ protected override (ChangeSetAggregator<Person, string> Aggregrator, IList<Perso
6666
}
6767

6868

69+
// Bind to a binding list
70+
public sealed class SortAndBindToBindingList : SortAndBindFixture
71+
72+
{
73+
protected override (ChangeSetAggregator<Person, string> Aggregrator, IList<Person> List) SetUpTests()
74+
{
75+
var list = new ObservableCollection<Person>(new BindingList<Person>());
76+
var aggregator = _source.Connect().SortAndBind(list, _comparer).AsAggregator();
77+
return (aggregator, list);
78+
}
79+
}
80+
81+
6982
// Bind to a readonly observable collection
7083
public sealed class SortAndBindToReadOnlyObservableCollection: SortAndBindFixture
7184
{
@@ -151,7 +164,7 @@ public void NeverFireReset()
151164
using var sorted = _source.Connect().SortAndBind(out var list, _comparer, options).Subscribe();
152165
using var collectionChangedEvents = list.ObserveCollectionChanges().Select(e => e.EventArgs).Subscribe(_collectionChangedEventArgs.Add);
153166

154-
// fire 5 changes, should always reset because it's below the threshold
167+
// fire 5 changes, should not reset because it's below the threshold
155168
_source.AddOrUpdate(Enumerable.Range(0, 5).Select(i => new Person($"P{i}", i)));
156169
_collectionChangedEventArgs.Count.Should().Be(5);
157170
_collectionChangedEventArgs.All(a => a.Action == NotifyCollectionChangedAction.Add).Should().BeTrue();
@@ -168,6 +181,40 @@ public void NeverFireReset()
168181

169182
}
170183

184+
[Fact]
185+
[Description("Check reset is fired on first time load. This checks historic first time load opt-in.")]
186+
public void FireResetOnFirstTimeLoad()
187+
{
188+
var options = new SortAndBindOptions { ResetThreshold = 10, ResetOnFirstTimeLoad = true};
189+
190+
using var sorted = _source.Connect().SortAndBind(out var list, _comparer, options).Subscribe();
191+
using var collectionChangedEvents = list.ObserveCollectionChanges().Select(e => e.EventArgs).Subscribe(_collectionChangedEventArgs.Add);
192+
193+
// fire 5 changes, should always reset even though it's below the threshold
194+
_source.AddOrUpdate(Enumerable.Range(0, 5).Select(i => new Person($"P{i}", i)));
195+
_collectionChangedEventArgs.Count.Should().Be(1);
196+
_collectionChangedEventArgs.All(a => a.Action == NotifyCollectionChangedAction.Reset).Should().BeTrue();
197+
198+
199+
_collectionChangedEventArgs.Clear();
200+
201+
// fire 15 changes, we should get a refresh event
202+
_source.AddOrUpdate(Enumerable.Range(10, 15).Select(i => new Person($"P{i}", i)));
203+
_collectionChangedEventArgs.Count.Should().Be(1);
204+
_collectionChangedEventArgs[0].Action.Should().Be(NotifyCollectionChangedAction.Reset);
205+
206+
_collectionChangedEventArgs.Clear();
207+
208+
// fires further 5 changes, should result individual notifications
209+
_source.AddOrUpdate(Enumerable.Range(-10, 5).Select(i => new Person($"P{i}", i)));
210+
_collectionChangedEventArgs.Count.Should().Be(5);
211+
_collectionChangedEventArgs.All(a => a.Action == NotifyCollectionChangedAction.Add).Should().BeTrue();
212+
213+
list.Count.Should().Be(25);
214+
215+
}
216+
217+
171218

172219
public void Dispose() => _source.Dispose();
173220
}

‎src/DynamicData/Binding/BindPaged.cs

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Roland Pheasant licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for full license information.
44

5+
using System.Reactive.Concurrency;
56
using System.Reactive.Disposables;
67
using System.Reactive.Linq;
78
using System.Reactive.Subjects;

‎src/DynamicData/Binding/BindVirtualized.cs

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Roland Pheasant licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for full license information.
44

5+
using System.Reactive.Concurrency;
56
using System.Reactive.Disposables;
67
using System.Reactive.Linq;
78
using System.Reactive.Subjects;

‎src/DynamicData/Binding/BindingListEventsSuspender.cs

+15-19
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,26 @@
22
// Roland Pheasant licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for full license information.
44

5-
#if SUPPORTS_BINDINGLIST
65
using System.ComponentModel;
76
using System.Reactive.Disposables;
87

9-
namespace DynamicData.Binding
10-
{
11-
internal sealed class BindingListEventsSuspender<T> : IDisposable
12-
{
13-
private readonly IDisposable _cleanUp;
8+
namespace DynamicData.Binding;
149

15-
public BindingListEventsSuspender(BindingList<T> list)
16-
{
17-
list.RaiseListChangedEvents = false;
10+
internal sealed class BindingListEventsSuspender<T> : IDisposable
11+
{
12+
private readonly IDisposable _cleanUp;
1813

19-
_cleanUp = Disposable.Create(
20-
() =>
21-
{
22-
list.RaiseListChangedEvents = true;
23-
list.ResetBindings();
24-
});
25-
}
14+
public BindingListEventsSuspender(BindingList<T> list)
15+
{
16+
list.RaiseListChangedEvents = false;
2617

27-
public void Dispose() => _cleanUp.Dispose();
18+
_cleanUp = Disposable.Create(
19+
() =>
20+
{
21+
list.RaiseListChangedEvents = true;
22+
list.ResetBindings();
23+
});
2824
}
29-
}
3025

31-
#endif
26+
public void Dispose() => _cleanUp.Dispose();
27+
}

‎src/DynamicData/Binding/SortAndBind.cs

+37-7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Roland Pheasant licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for full license information.
44

5+
using System.ComponentModel;
56
using System.Reactive.Disposables;
67
using System.Reactive.Linq;
78
using DynamicData.Cache;
@@ -30,15 +31,22 @@ public SortAndBind(IObservable<IChangeSet<TObject, TKey>> source,
3031
SortAndBindOptions options,
3132
IList<TObject> target)
3233
{
34+
var scheduler = options.Scheduler;
35+
3336
// static one time comparer
3437
var applicator = new SortApplicator(_cache, target, comparer, options);
3538

36-
_sorted = source.Do(changes =>
39+
if (scheduler is not null)
40+
source = source.ObserveOn(scheduler);
41+
42+
_sorted = source.Select((changes, index) =>
3743
{
3844
// clone to local cache so that we can sort the entire set when threshold is over a certain size.
3945
_cache.Clone(changes);
4046

41-
applicator.ProcessChanges(changes);
47+
applicator.ProcessChanges(changes, index == 0);
48+
49+
return changes;
4250
});
4351
}
4452

@@ -48,6 +56,14 @@ public SortAndBind(IObservable<IChangeSet<TObject, TKey>> source,
4856
IList<TObject> target)
4957
=> _sorted = Observable.Create<IChangeSet<TObject, TKey>>(observer =>
5058
{
59+
var scheduler = options.Scheduler;
60+
61+
if (scheduler is not null)
62+
{
63+
source = source.ObserveOn(scheduler);
64+
comparerChanged = comparerChanged.ObserveOn(scheduler);
65+
}
66+
5167
var locker = new object();
5268
SortApplicator? sortApplicator = null;
5369

@@ -61,14 +77,17 @@ public SortAndBind(IObservable<IChangeSet<TObject, TKey>> source,
6177

6278
// Listen to changes and apply the sorting
6379
var subscriber = source.Synchronize(locker)
64-
.Do(changes =>
80+
.Select((changes, index) =>
6581
{
6682
_cache.Clone(changes);
6783

6884
// the sort applicator will be null until the comparer change observable fires.
6985
if (sortApplicator is not null)
70-
sortApplicator.ProcessChanges(changes);
71-
}).SubscribeSafe(observer);
86+
sortApplicator.ProcessChanges(changes, index == 0);
87+
88+
return changes;
89+
})
90+
.SubscribeSafe(observer);
7291

7392
return new CompositeDisposable(latestComparer, subscriber);
7493
});
@@ -92,10 +111,12 @@ public void ApplySort()
92111
}
93112

94113
// apply sorting as a side effect of the observable stream.
95-
public void ProcessChanges(IChangeSet<TObject, TKey> changeSet)
114+
public void ProcessChanges(IChangeSet<TObject, TKey> changeSet, bool isFirstTimeLoad)
96115
{
116+
var forceReset = isFirstTimeLoad && options.ResetOnFirstTimeLoad;
117+
97118
// apply sorted changes to the target collection
98-
if (options.ResetThreshold > 0 && options.ResetThreshold < changeSet.Count)
119+
if (forceReset || (options.ResetThreshold > 0 && options.ResetThreshold < changeSet.Count))
99120
{
100121
Reset(cache.Items.OrderBy(t => t, comparer), true);
101122
}
@@ -122,6 +143,15 @@ private void Reset(IEnumerable<TObject> sorted, bool fireReset)
122143
observableCollectionExtended.Load(sorted);
123144
}
124145
}
146+
else if (fireReset && target is BindingList<TObject> bindingList)
147+
{
148+
// suspend count as it can result in a flood of binding updates.
149+
using (new BindingListEventsSuspender<TObject>(bindingList))
150+
{
151+
target.Clear();
152+
target.AddRange(sorted);
153+
}
154+
}
125155
else
126156
{
127157
target.Clear();

‎src/DynamicData/Binding/SortAndBindOptions.cs

+16
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Roland Pheasant licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for full license information.
44

5+
using System.Reactive.Concurrency;
6+
57
namespace DynamicData.Binding;
68

79
/// <summary>
@@ -28,4 +30,18 @@ public record struct SortAndBindOptions()
2830
/// Set the initial capacity of the readonly observable collection.
2931
/// </summary>
3032
public int InitialCapacity { get; init; } = -1;
33+
34+
/// <summary>
35+
/// Reset on first time load.
36+
///
37+
/// This is opt-in only and is only required for consumers who need to maintain
38+
/// backwards compatibility will the former BindingOptions.ResetOnFirstTimeLoad.
39+
/// </summary>
40+
public bool ResetOnFirstTimeLoad { get; init; }
41+
42+
/// <summary>
43+
/// The default main thread scheduler. If left null, it is the responsibility of the consumer
44+
/// to ensure binding takes place on the main thread.
45+
/// </summary>
46+
public IScheduler? Scheduler { get; init; }
3147
}

‎src/DynamicData/Cache/ObservableCacheEx.SortAndBind.cs

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// See the LICENSE file in the project root for full license information.
44

55
using System.Collections.ObjectModel;
6+
using System.Reactive.Concurrency;
67
using DynamicData.Binding;
78

89
namespace DynamicData;

‎src/DynamicData/List/ListEx.cs

+3-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// See the LICENSE file in the project root for full license information.
44

55
using System.Collections.ObjectModel;
6-
6+
using System.ComponentModel;
77
using DynamicData.Kernel;
88

99
// ReSharper disable once CheckNamespace
@@ -101,7 +101,8 @@ public static void AddRange<T>(this IList<T> source, IEnumerable<T> items)
101101
extendedList.AddRange(items);
102102
break;
103103
default:
104-
items.ForEach(source.Add);
104+
foreach (var t in items)
105+
source.Add(t);
105106
break;
106107
}
107108
}

0 commit comments

Comments
 (0)
Please sign in to comment.