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.

### Hot Reload

StellarUI has built-in support for hot reload during development:
Expand Down
42 changes: 42 additions & 0 deletions Stellar.Maui.UnitTests/ItemsViewRecyclingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
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
2 changes: 1 addition & 1 deletion Stellar.Maui/MauiViewManager.cs
Original file line number Diff line number Diff line change
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
Loading