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..d103803 100644 --- a/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs +++ b/Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs @@ -1,3 +1,6 @@ +using System.Runtime.CompilerServices; +using Stellar.UnitTests; + namespace Stellar.Maui.UnitTests; /// @@ -302,6 +305,115 @@ public void RecycledView_WhoseViewModelWasReplacedMeanwhile_LetsGoOfBothWhenTheL Assert.Equal(0, source.HandlerCount); } + [Fact] + public void RecycledView_WhoseViewModelWasReplacedMeanwhile_DeactivatesTheViewModelItActivatedWhenItReturns() + { + var source = new LongLivedSource(); + var list = new ListHarness(); + var cell = new GridDataModelCell(source); + list.BindNew(cell, new TestItem("Chair", 3)); + var original = cell.ViewModel!; + var replacement = new TestCellViewModel(source); + list.Recycle(cell); + cell.ViewModel = replacement; + cell.Events.Clear(); + original.Events.Clear(); + + list.Bind(cell, new TestItem("Table", 7)); + + Assert.Equal(new[] { LifecycleEvent.Deactivated }, original.Events); + Assert.Equal( + new[] { LifecycleEvent.Initialized, LifecycleEvent.Activated, LifecycleEvent.Attached }, + replacement.Events); + Assert.Equal( + new[] + { + LifecycleEvent.Deactivated, + LifecycleEvent.Initialized, + LifecycleEvent.Activated, + LifecycleEvent.Attached, + }, + cell.Events); + } + + [Fact] + public void RecycledView_WhoseViewModelWasReplacedMeanwhile_DeactivatesTheViewModelItActivatedWhenTheListLeavesTheWindow() + { + var source = new LongLivedSource(); + var list = new ListHarness(); + var cell = new GridDataModelCell(source); + list.BindNew(cell, new TestItem("Chair", 3)); + var original = cell.ViewModel!; + var replacement = new TestCellViewModel(source); + list.Recycle(cell); + cell.ViewModel = replacement; + cell.Events.Clear(); + original.Events.Clear(); + + list.RemoveFromWindow(); + + Assert.Equal(new[] { LifecycleEvent.Deactivated }, original.Events); + Assert.Empty(replacement.Events); + Assert.Equal(new[] { LifecycleEvent.Deactivated }, cell.Events); + } + + [Fact] + public void RecycledView_WhoseViewModelWasTakenAwayMeanwhile_DeactivatesTheViewModelItActivated() + { + var source = new LongLivedSource(); + var list = new ListHarness(); + var cell = new GridDataModelCell(source); + list.BindNew(cell, new TestItem("Chair", 3)); + var original = cell.ViewModel!; + list.Recycle(cell); + cell.ViewModel = null; + cell.Events.Clear(); + + list.RemoveFromWindow(); + + Assert.Equal( + new[] + { + LifecycleEvent.Initialized, + LifecycleEvent.Activated, + LifecycleEvent.Attached, + LifecycleEvent.Detached, + LifecycleEvent.Deactivated, + }, + original.Events); + Assert.Equal(new[] { LifecycleEvent.Deactivated }, cell.Events); + Assert.False(original.BindingsRegistered); + Assert.False(cell.ViewManager.ControlsBound); + Assert.Equal(0, source.HandlerCount); + } + + [Fact] + public void RecycledView_WhoseReplacedViewModelIsNoLongerAlive_RaisesDeactivatedOnceAndOnlyOnTheView() + { + var list = new ListHarness(); + var cell = new GridDataModelCell(); + var replacement = new TestCellViewModel(); + var original = BindRecycleAndReplace(list, cell, replacement); + GcHelpers.ForceFullCollection(); + cell.Events.Clear(); + + list.Bind(cell, new TestItem("Table", 7)); + + Assert.False(original.IsAlive); + Assert.Equal( + new[] + { + LifecycleEvent.Deactivated, + LifecycleEvent.Initialized, + LifecycleEvent.Activated, + LifecycleEvent.Attached, + }, + cell.Events); + Assert.Equal( + new[] { LifecycleEvent.Initialized, LifecycleEvent.Activated, LifecycleEvent.Attached }, + replacement.Events); + } + [Fact] public void RecyclingMoreViewsThanARecyclerRetains_TearsDownTheRestImmediately() { @@ -357,6 +469,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() { @@ -850,6 +1004,21 @@ public void RecycledView_ReturningToADifferentList_IsCountedThereFromThenOn() Assert.Equal(0, source.HandlerCount); } + // Not inlined, so that no local of the caller keeps the original view model alive. + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference BindRecycleAndReplace( + ListHarness list, + GridDataModelCell cell, + TestCellViewModel replacement) + { + list.BindNew(cell, new TestItem("Chair", 3)); + var original = new WeakReference(cell.ViewModel); + list.Recycle(cell); + cell.ViewModel = replacement; + + return original; + } + private static List BindCells( ListHarness list, LongLivedSource source, 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..d292e40 100644 --- a/Stellar.Maui/MauiViewManager.cs +++ b/Stellar.Maui/MauiViewManager.cs @@ -58,7 +58,7 @@ public override void PropertyChanged(TView view, string? propertyName = n return; } - this.Deactivate(isv, parkedViewModel); + this.DeactivateParked(isv, parkedViewModel); } this.HandleActivated(isv); @@ -93,7 +93,7 @@ void IParkedItemView.DeactivateParked() if (this.Unpark(out var parkedViewModel) && view is not null) { - this.Deactivate(view, parkedViewModel); + this.DeactivateParked(view, parkedViewModel); } } @@ -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; @@ -177,11 +177,30 @@ private bool Unpark(out TViewModel? parkedViewModel) return true; } - private void Deactivate(IStellarView view, TViewModel? parkedViewModel = null) + private void Deactivate(IStellarView view) { this.HandleDeactivated(view); - if (parkedViewModel is ViewModelBase replaced && !ReferenceEquals(replaced, view.ViewModel)) + view.DisposeView(); + } + + private void DeactivateParked(IStellarView view, TViewModel? parkedViewModel) + { + if (parkedViewModel is not null && ReferenceEquals(parkedViewModel, view.ViewModel)) + { + this.Deactivate(view); + return; + } + + // The view was given another view model while it was recycled. The one it was + // parked with is the one that was activated, so Deactivated is its to receive. The + // one the view has now was never activated: it is either about to be, or is being + // let go with the view. + this.OnLifecycle(parkedViewModel, LifecycleEvent.Deactivated); + + this.UnregisterBindings(view); + + if (parkedViewModel is ViewModelBase replaced) { replaced.Unregister(); } diff --git a/Stellar/ViewManager.cs b/Stellar/ViewManager.cs index b8a9b3b..58f9c05 100644 --- a/Stellar/ViewManager.cs +++ b/Stellar/ViewManager.cs @@ -301,14 +301,25 @@ public virtual void PropertyChanged(TView view, string? propertyName = nu } } - public void OnLifecycle(IStellarView view, LifecycleEvent lifecycleEvent) + public void OnLifecycle(IStellarView view, LifecycleEvent lifecycleEvent) => + OnLifecycle(view.ViewModel, lifecycleEvent); + + /// + /// Raises a lifecycle event to a view model and to this manager's lifecycle stream. The + /// view model is a parameter so that a derived manager can address one the view no + /// longer holds, such as the view model a recycled view was activated with before it + /// was given another. + /// + /// The view model to notify, if it is . + /// The event. + protected void OnLifecycle(TViewModel? viewModel, LifecycleEvent lifecycleEvent) { if (Volatile.Read(ref _isDisposed)) { return; // Silently return if disposed to avoid exceptions during cleanup } - if (view.ViewModel is ILifecycleEventAware lea) + if (viewModel is ILifecycleEventAware lea) { lea.OnLifecycleEvent(lifecycleEvent); }