Skip to content

Commit dbf3611

Browse files
authored
VirtualScroll Carousel Layout and fixes (#119)
1 parent 8854dda commit dbf3611

34 files changed

+3163
-175
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ If `Nalu.Maui` is valuable to your work, consider supporting its continued devel
2323
* **Controls** [![Nalu.Maui.Controls NuGet Package](https://img.shields.io/nuget/v/Nalu.Maui.Controls.svg)](https://www.nuget.org/packages/Nalu.Maui.Controls/) [![Nalu.Maui NuGet Package Downloads](https://img.shields.io/nuget/dt/Nalu.Maui.Controls)](https://www.nuget.org/packages/Nalu.Maui.Controls/)
2424
* Includes useful cross-platform controls like `InteractableCanvasView` (a `SKCanvasView` with enhanced touch support) and `DurationWheel` (a `TimeSpan?` editor).
2525
* **VirtualScroll** [![Nalu.Maui.VirtualScroll NuGet Package](https://img.shields.io/nuget/v/Nalu.Maui.VirtualScroll.svg)](https://www.nuget.org/packages/Nalu.Maui.VirtualScroll/) [![Nalu.Maui NuGet Package Downloads](https://img.shields.io/nuget/dt/Nalu.Maui.VirtualScroll)](https://www.nuget.org/packages/Nalu.Maui.VirtualScroll/)
26-
* A high-performance alternative to the .NET MAUI `CollectionView`, leveraging native `RecyclerView` (Android) and `UICollectionView` (iOS). Supports dynamic sizing, `ObservableCollection<T>`, pull-to-refresh, and section templates.
26+
* A high-performance alternative to the .NET MAUI `CollectionView`, leveraging native `RecyclerView` (Android) and `UICollectionView` (iOS). Supports dynamic sizing, `ObservableCollection<T>`, pull-to-refresh, section templates and carousel mode.
2727
* ⚖️ **Dual Licensed**:
2828
* **Non-Commercial:** Free under the MIT License (personal, educational, or non-commercial open-source use).
2929
* **Commercial:** Requires an active [GitHub Sponsors subscription](https://github.com/sponsors/albyrock87).

Samples/Nalu.Maui.Sample/AppShell.xaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@
4848
<ShellContent nalu:Navigation.PageType="pages:TenPage"
4949
Title="Page Ten"
5050
Icon="{FontImageSource FontFamily='MaterialOutlined', Glyph='&#xe8ba;', Color='Black', Size=24}" />
51+
<ShellContent nalu:Navigation.PageType="pages:TenCarouselPage"
52+
Title="Page Ten Carousel"
53+
Icon="{FontImageSource FontFamily='MaterialOutlined', Glyph='&#xe8ba;', Color='Black', Size=24}" />
5154
<ShellContent nalu:Navigation.PageType="pages:ElevenPage"
5255
Title="Page Eleven"
5356
Icon="{FontImageSource FontFamily='MaterialOutlined', Glyph='&#xe8bb;', Color='Black', Size=24}" />
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
using CommunityToolkit.Mvvm.ComponentModel;
2+
using CommunityToolkit.Mvvm.Input;
3+
using CommunityToolkit.Mvvm.Messaging;
4+
5+
namespace Nalu.Maui.Sample.PageModels;
6+
7+
public partial class TenCarouselPageModel : ObservableObject, ILeavingAware
8+
{
9+
private readonly IMessenger _messenger;
10+
private static int _instanceCount;
11+
12+
private int _idCounter;
13+
14+
public string Message { get; } = "This is page ten carousel - horizontal carousel layout!";
15+
16+
public int InstanceCount { get; } = Interlocked.Increment(ref _instanceCount);
17+
18+
public ReplaceableObservableCollection<TenItem> Items { get; }
19+
public IReorderableVirtualScrollAdapter Adapter { get; }
20+
21+
[ObservableProperty]
22+
public partial int CurrentIndex { get; set; } = 5;
23+
24+
public TenCarouselPageModel(IMessenger messenger)
25+
{
26+
_messenger = messenger;
27+
Items = new ReplaceableObservableCollection<TenItem>(Enumerable.Range(1, 30).Select(i => new TenItem($"Item {i}")));
28+
Adapter = VirtualScroll.CreateObservableCollectionAdapter(Items);
29+
_idCounter = Items.Count;
30+
}
31+
32+
33+
[RelayCommand]
34+
private void AddItem()
35+
{
36+
if (Items.Count > 0)
37+
{
38+
var randomIndex = Random.Shared.Next(Items.Count);
39+
Items.Insert(randomIndex, new TenItem($"Item {_idCounter++}"));
40+
}
41+
else
42+
{
43+
Items.Add(new TenItem($"Item {_idCounter++}"));
44+
}
45+
}
46+
47+
[RelayCommand]
48+
private void RemoveItem()
49+
{
50+
if (Items.Count > 0)
51+
{
52+
var randomIndex = Random.Shared.Next(Items.Count);
53+
Items.RemoveAt(randomIndex);
54+
}
55+
}
56+
57+
[RelayCommand]
58+
private void ClearItems()
59+
{
60+
Items.Clear();
61+
}
62+
63+
[RelayCommand]
64+
private void ReplaceItems()
65+
{
66+
var newItems = Enumerable.Range(1, Random.Shared.Next(10, 40))
67+
.Select(i => new TenItem($"Replaced Item {i}"));
68+
Items.ReplaceAll(newItems);
69+
_idCounter = Items.Count;
70+
}
71+
72+
[RelayCommand]
73+
private void MoveItem()
74+
{
75+
if (Items.Count > 1)
76+
{
77+
var fromIndex = Random.Shared.Next(Items.Count);
78+
int toIndex;
79+
while ((toIndex = Random.Shared.Next(Items.Count)) == fromIndex)
80+
{
81+
// Ensure toIndex is different from fromIndex
82+
}
83+
84+
Items.Move(fromIndex, toIndex);
85+
}
86+
}
87+
88+
[RelayCommand]
89+
private void ScrollToItem()
90+
{
91+
if (Items.Count > 0)
92+
{
93+
var randomIndex = Random.Shared.Next(Items.Count);
94+
_messenger.Send(new TenCarouselPageScrollToItemMessage(randomIndex));
95+
}
96+
}
97+
98+
[RelayCommand]
99+
private async Task RefreshAsync(Action completionCallback)
100+
{
101+
try
102+
{
103+
// Simulate loading for 2 seconds
104+
await Task.Delay(2000);
105+
106+
// Simulate refreshing data - add a new item at the beginning
107+
Items.Insert(0, new TenItem($"Refreshed Item {_idCounter++}"));
108+
}
109+
finally
110+
{
111+
// Always call completion callback when done
112+
completionCallback();
113+
}
114+
}
115+
116+
private CancellationTokenSource? _autoChangesCts;
117+
118+
[RelayCommand]
119+
private async void ToggleAutoChanges()
120+
{
121+
if (_autoChangesCts != null)
122+
{
123+
await _autoChangesCts.CancelAsync();
124+
_autoChangesCts = null;
125+
return;
126+
}
127+
128+
_autoChangesCts = new CancellationTokenSource();
129+
var token = _autoChangesCts.Token;
130+
131+
while (!token.IsCancellationRequested)
132+
{
133+
// ReSharper disable once MethodSupportsCancellation
134+
await Task.Delay(500);
135+
AddItem();
136+
RemoveItem();
137+
MoveItem();
138+
ScrollToItem();
139+
}
140+
}
141+
142+
public ValueTask OnLeavingAsync()
143+
{
144+
_autoChangesCts?.Cancel();
145+
_autoChangesCts = null;
146+
return ValueTask.CompletedTask;
147+
}
148+
}
149+
150+
public record TenCarouselPageScrollToItemMessage(int ItemIndex);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
3+
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
4+
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
5+
xmlns:pageModels="clr-namespace:Nalu.Maui.Sample.PageModels"
6+
xmlns:nalu="https://nalu-development.github.com/nalu/layouts"
7+
xmlns:vs="https://nalu-development.github.com/nalu/virtualscroll"
8+
x:Class="Nalu.Maui.Sample.Pages.TenCarouselPage"
9+
x:DataType="pageModels:TenCarouselPageModel"
10+
Title="Page Ten Carousel">
11+
<ContentPage.Content>
12+
<Grid IgnoreSafeArea="True">
13+
<Grid.RowDefinitions>
14+
<RowDefinition Height="Auto" />
15+
<RowDefinition Height="*" />
16+
</Grid.RowDefinitions>
17+
18+
<Grid Grid.Row="0"
19+
ColumnDefinitions="*,*,*,*,*,*,*"
20+
Padding="16,8"
21+
ColumnSpacing="8">
22+
<Button Grid.Column="0"
23+
Text="Add"
24+
Command="{Binding AddItemCommand}" />
25+
<Button Grid.Column="1"
26+
Text="Remove"
27+
Command="{Binding RemoveItemCommand}" />
28+
<Button Grid.Column="2"
29+
Text="Move"
30+
Command="{Binding MoveItemCommand}" />
31+
<Button Grid.Column="3"
32+
Text="Scroll"
33+
Command="{Binding ScrollToItemCommand}" />
34+
<Label Grid.Column="4"
35+
Text="{Binding CurrentIndex}"/>
36+
<Button Grid.Column="5"
37+
Text="Clear"
38+
Command="{Binding ClearItemsCommand}" />
39+
<Button Grid.Column="6"
40+
Text="Replace"
41+
Command="{Binding ReplaceItemsCommand}" />
42+
</Grid>
43+
44+
<vs:VirtualScroll Grid.Row="1"
45+
x:Name="VirtualScroll"
46+
DragHandler="{Binding Adapter}"
47+
ItemsSource="{Binding Adapter}"
48+
IsRefreshEnabled="False"
49+
vs:CarouselVirtualScrollLayout.CurrentRange="{Binding CurrentIndex}"
50+
ItemsLayout="{vs:HorizontalCarouselVirtualScrollLayout}">
51+
<vs:VirtualScroll.ItemTemplate>
52+
<DataTemplate x:DataType="pageModels:TenItem">
53+
<nalu:ViewBox>
54+
<Border Shadow="{Shadow Radius=6, Opacity=0.2, Offset='1,1'}"
55+
StrokeShape="RoundRectangle 8"
56+
Margin="8"
57+
Padding="16,8"
58+
BackgroundColor="LightCoral">
59+
<Label Text="{Binding Name}"/>
60+
<Border.GestureRecognizers>
61+
<TapGestureRecognizer Command="{Binding AddLineCommand}"/>
62+
</Border.GestureRecognizers>
63+
</Border>
64+
</nalu:ViewBox>
65+
</DataTemplate>
66+
</vs:VirtualScroll.ItemTemplate>
67+
</vs:VirtualScroll>
68+
</Grid>
69+
</ContentPage.Content>
70+
</ContentPage>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using CommunityToolkit.Maui.Alerts;
2+
using CommunityToolkit.Mvvm.Messaging;
3+
using Nalu.Maui.Sample.PageModels;
4+
5+
namespace Nalu.Maui.Sample.Pages;
6+
7+
public partial class TenCarouselPage : ContentPage, IRecipient<TenCarouselPageScrollToItemMessage>, IDisposable
8+
{
9+
private readonly IMessenger _messenger;
10+
11+
public TenCarouselPage(TenCarouselPageModel viewModel, IMessenger messenger)
12+
{
13+
_messenger = messenger;
14+
_messenger.Register(this);
15+
BindingContext = viewModel;
16+
InitializeComponent();
17+
}
18+
19+
public void Receive(TenCarouselPageScrollToItemMessage message)
20+
{
21+
Dispatcher.Dispatch(() =>
22+
{
23+
Toast.Make($"Scrolling to item at index {message.ItemIndex}").Show();
24+
VirtualScroll.ScrollTo(0, message.ItemIndex);
25+
}
26+
);
27+
}
28+
29+
public void Dispose()
30+
{
31+
_messenger.UnregisterAll(this);
32+
}
33+
}

Source/Nalu.Maui.VirtualScroll/Adapters/VirtualScrollNotifyCollectionChangedAdapter.cs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,20 @@ private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArg
8686
{
8787
changes.Add(VirtualScrollChangeFactory.InsertSection(_sectionIndex));
8888
_isEmpty = false;
89-
}
90-
91-
if (e.NewItems.Count == 1)
92-
{
93-
changes.Add(VirtualScrollChangeFactory.InsertItem(_sectionIndex, e.NewStartingIndex));
89+
// Don't emit InsertItem - the section insert already includes the items
9490
}
9591
else
9692
{
97-
var endIndex = e.NewStartingIndex + e.NewItems.Count - 1;
98-
changes.Add(VirtualScrollChangeFactory.InsertItemRange(_sectionIndex, e.NewStartingIndex, endIndex));
93+
// Section already exists, just insert the items
94+
if (e.NewItems.Count == 1)
95+
{
96+
changes.Add(VirtualScrollChangeFactory.InsertItem(_sectionIndex, e.NewStartingIndex));
97+
}
98+
else
99+
{
100+
var endIndex = e.NewStartingIndex + e.NewItems.Count - 1;
101+
changes.Add(VirtualScrollChangeFactory.InsertItemRange(_sectionIndex, e.NewStartingIndex, endIndex));
102+
}
99103
}
100104
break;
101105

Source/Nalu.Maui.VirtualScroll/FlattenedAdapter/VirtualScrollFlattenedAdapter.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,18 @@ private IEnumerable<VirtualScrollFlattenedChange> ConvertSectionChange(VirtualSc
176176
var sectionCount = change.EndSectionIndex - change.StartSectionIndex + 1;
177177
var itemsToInsert = CalculateItemsForSections(change.StartSectionIndex, sectionCount);
178178
UpdateOffsetsAfterSectionInsert(change.StartSectionIndex, sectionCount);
179+
180+
if (itemsToInsert == 0)
181+
{
182+
// Section has no items (and no headers/footers) - nothing to insert
183+
return [];
184+
}
185+
186+
if (itemsToInsert == 1)
187+
{
188+
return [VirtualScrollFlattenedChangeFactory.InsertItem(startFlattenedIndex)];
189+
}
190+
179191
return [VirtualScrollFlattenedChangeFactory.InsertItemRange(startFlattenedIndex, startFlattenedIndex + itemsToInsert - 1)];
180192
}
181193

Source/Nalu.Maui.VirtualScroll/IVirtualScrollController.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,29 @@ public interface IVirtualScrollController
1919
/// <param name="totalScrollableWidth">The total scrollable width in device-independent units.</param>
2020
/// <param name="totalScrollableHeight">The total scrollable height in device-independent units.</param>
2121
void Scrolled(double scrollX, double scrollY, double totalScrollableWidth, double totalScrollableHeight);
22+
23+
/// <summary>
24+
/// Invoked by the handler when scrolling starts.
25+
/// </summary>
26+
/// <param name="scrollX">The current horizontal scroll position in device-independent units.</param>
27+
/// <param name="scrollY">The current vertical scroll position in device-independent units.</param>
28+
/// <param name="totalScrollableWidth">The total scrollable width in device-independent units.</param>
29+
/// <param name="totalScrollableHeight">The total scrollable height in device-independent units.</param>
30+
void ScrollStarted(double scrollX, double scrollY, double totalScrollableWidth, double totalScrollableHeight);
31+
32+
/// <summary>
33+
/// Invoked by the handler when scrolling ends.
34+
/// </summary>
35+
/// <param name="scrollX">The current horizontal scroll position in device-independent units.</param>
36+
/// <param name="scrollY">The current vertical scroll position in device-independent units.</param>
37+
/// <param name="totalScrollableWidth">The total scrollable width in device-independent units.</param>
38+
/// <param name="totalScrollableHeight">The total scrollable height in device-independent units.</param>
39+
void ScrollEnded(double scrollX, double scrollY, double totalScrollableWidth, double totalScrollableHeight);
40+
41+
/// <summary>
42+
/// Invoked by the handler when a batch layout update (item add/remove/move) completes.
43+
/// This allows layouts to update their state based on the new visible items.
44+
/// </summary>
45+
void LayoutUpdateCompleted();
2246
}
2347

0 commit comments

Comments
 (0)