diff --git a/README.md b/README.md index c6a19d1..b2931ab 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,29 @@ var removed = await _cache.RemoveAsync(cacheKey: "my-key"); await _cache.ClearCacheAsync("group"); // null clears the default group ``` +### Keeping Recycled List Rows Bound (MAUI) + +A `CollectionView` reuses its row views. By default StellarUI tears a row's bindings down +when the row is recycled and builds them again when it is reused. A list can opt in to +keeping a number of recycled rows bound instead, which removes that work while scrolling: + +```csharp +var list = new CollectionView().RecycledItemViewLimit(5); +``` + +- The default is `0`: nothing is kept, and a list that never calls the method is unaffected. +- `5` matches the number of views an Android `RecyclerView` pool holds per view type. If you + have resized that pool, pass the same number here. +- Kept rows are released when the list leaves its window, or when the limit is lowered. +- Only rows whose view maps a data model onto its own view model are kept + (`GridBase` and the other two-parameter base classes). A row that + is handed a view model per item is torn down as before. + +A kept row does **not** run `Bind`, `Initialized`, `Activated` or `Deactivated` again when it +is given its next item; it only receives `Detached` and `Attached`, and its view model's +item changes. Opt in only for rows that take everything they show from bindings to the view +model. A row that reads its item imperatively inside `Bind` would show the previous item. + ### Hot Reload StellarUI has built-in support for hot reload during development: diff --git a/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs b/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs index 2f252f2..9be45ae 100644 --- a/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs +++ b/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs @@ -357,6 +357,48 @@ public void ListThatHasNotOptedIn_TearsEveryViewDownAsBefore() Assert.Equal(0, source.HandlerCount); } + [Fact] + public void ListThatHasNotOptedIn_IsNotWatched() + { + var source = new LongLivedSource(); + var list = new ListHarness(recycledItemViewLimit: null); + var cells = BindCells(list, source, 2); + + foreach (var cell in cells) + { + list.Recycle(cell); + } + + Assert.Null(ItemsHostWatcher.Find(list.List)); + } + + [Fact] + public void RecycledItemViewLimit_OfZero_OnAListThatHasNotOptedIn_DoesNotWatchIt() + { + var list = new ListHarness(recycledItemViewLimit: null); + + list.List.RecycledItemViewLimit(0); + + Assert.Null(ItemsHostWatcher.Find(list.List)); + } + + [Fact] + public void RecycledItemViewLimit_SetAfterViewsAreBound_KeepsThemFromTheirNextRecycle() + { + var source = new LongLivedSource(); + var list = new ListHarness(recycledItemViewLimit: null); + var cells = BindCells(list, source, 2); + + list.List.RecycledItemViewLimit(Cap); + + foreach (var cell in cells) + { + list.Recycle(cell); + } + + Assert.All(cells, static cell => Assert.True(cell.ViewManager.ControlsBound)); + } + [Fact] public void RecycledItemViewLimit_Lowered_ReleasesTheViewsAlreadyKept() { diff --git a/Stellar.Maui.UnitTests/MauiTestBase.cs b/Stellar.Maui.UnitTests/MauiTestBase.cs index aeaaad1..24e6777 100644 --- a/Stellar.Maui.UnitTests/MauiTestBase.cs +++ b/Stellar.Maui.UnitTests/MauiTestBase.cs @@ -1,19 +1,11 @@ -using ReactiveUI.Reactive.Builder; - namespace Stellar.Maui.UnitTests; -public abstract class MauiTestBase +public abstract class MauiTestBase : StellarTestBase { - // RecyclerView.RecycledViewPool.DEFAULT_MAX_SCRAP, which the view manager mirrors. + // The limit ListHarness opts its list in with, which is + // RecyclerView.RecycledViewPool.DEFAULT_MAX_SCRAP. protected const int Cap = 5; - static MauiTestBase() - { - // ReactiveUI throws on first use unless it has been built. This is the same - // registration UseStellarComponents performs, without a MauiAppBuilder. - RxAppBuilder.CreateReactiveUIBuilder().WithMaui().BuildApp(); - } - public static TheoryData CellKinds => new() { diff --git a/Stellar.Maui.UnitTests/StellarTestBase.cs b/Stellar.Maui.UnitTests/StellarTestBase.cs index 2b076bc..790e8c8 100644 --- a/Stellar.Maui.UnitTests/StellarTestBase.cs +++ b/Stellar.Maui.UnitTests/StellarTestBase.cs @@ -4,6 +4,9 @@ namespace Stellar.Maui.UnitTests; public abstract class StellarTestBase { + // ReactiveUI throws on first use unless it has been built. Every test class goes + // through the registration an app performs, so the result cannot depend on which + // class happens to run first. static StellarTestBase() { MauiApp.CreateBuilder(useDefaults: false).UseStellarComponents(); diff --git a/Stellar.Maui/Extensions/ItemsViewExtensions.cs b/Stellar.Maui/Extensions/ItemsViewExtensions.cs index 7af481e..17fbbc6 100644 --- a/Stellar.Maui/Extensions/ItemsViewExtensions.cs +++ b/Stellar.Maui/Extensions/ItemsViewExtensions.cs @@ -27,6 +27,15 @@ public static TItemsView RecycledItemViewLimit(this TItemsView items ArgumentNullException.ThrowIfNull(itemsView); ArgumentOutOfRangeException.ThrowIfNegative(limit); + // Zero is what a list that never opted in already does, so it must not be what + // gives the list a watcher. + if (limit == 0) + { + ItemsHostWatcher.Find(itemsView)?.Limit = 0; + + return itemsView; + } + ItemsHostWatcher.For(itemsView).Limit = limit; return itemsView; diff --git a/Stellar.Maui/MauiViewManager.cs b/Stellar.Maui/MauiViewManager.cs index 78f542a..172b0ac 100644 --- a/Stellar.Maui/MauiViewManager.cs +++ b/Stellar.Maui/MauiViewManager.cs @@ -141,7 +141,7 @@ private bool TryPark(VisualElement element, IStellarView view) } // A list has a watcher only once it has opted in through RecycledItemViewLimit, - // so a list that has not costs nothing here. + // so for a list that has not this lookup is the whole cost of a recycle. if (ItemsHostWatcher.Find(host) is not { } watcher) { return false;