Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,29 @@ var removed = await _cache.RemoveAsync<MyModel>(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<TViewModel, TDataModel>` 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.
Comment on lines +530 to +533

### Hot Reload

StellarUI has built-in support for hot reload during development:
Expand Down
169 changes: 169 additions & 0 deletions Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using System.Runtime.CompilerServices;
using Stellar.UnitTests;

namespace Stellar.Maui.UnitTests;

/// <summary>
Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -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<GridDataModelCell> BindCells(
ListHarness list,
LongLivedSource source,
Expand Down
14 changes: 3 additions & 11 deletions Stellar.Maui.UnitTests/MauiTestBase.cs
Original file line number Diff line number Diff line change
@@ -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<CellKind> CellKinds =>
new()
{
Expand Down
3 changes: 3 additions & 0 deletions Stellar.Maui.UnitTests/StellarTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions Stellar.Maui/Extensions/ItemsViewExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ public static TItemsView RecycledItemViewLimit<TItemsView>(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;
Expand Down
29 changes: 24 additions & 5 deletions Stellar.Maui/MauiViewManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public override void PropertyChanged<TView>(TView view, string? propertyName = n
return;
}

this.Deactivate(isv, parkedViewModel);
this.DeactivateParked(isv, parkedViewModel);
}

this.HandleActivated(isv);
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -141,7 +141,7 @@ private bool TryPark(VisualElement element, IStellarView<TViewModel> 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;
Expand Down Expand Up @@ -177,11 +177,30 @@ private bool Unpark(out TViewModel? parkedViewModel)
return true;
}

private void Deactivate(IStellarView<TViewModel> view, TViewModel? parkedViewModel = null)
private void Deactivate(IStellarView<TViewModel> view)
{
this.HandleDeactivated(view);

if (parkedViewModel is ViewModelBase replaced && !ReferenceEquals(replaced, view.ViewModel))
view.DisposeView();
}

private void DeactivateParked(IStellarView<TViewModel> 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();
}
Expand Down
15 changes: 13 additions & 2 deletions Stellar/ViewManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -301,14 +301,25 @@ public virtual void PropertyChanged<TView>(TView view, string? propertyName = nu
}
}

public void OnLifecycle(IStellarView<TViewModel> view, LifecycleEvent lifecycleEvent)
public void OnLifecycle(IStellarView<TViewModel> view, LifecycleEvent lifecycleEvent) =>
OnLifecycle(view.ViewModel, lifecycleEvent);

/// <summary>
/// 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.
/// </summary>
/// <param name="viewModel">The view model to notify, if it is <see cref="ILifecycleEventAware"/>.</param>
/// <param name="lifecycleEvent">The event.</param>
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);
}
Expand Down
Loading