Skip to content
Closed
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
69 changes: 69 additions & 0 deletions src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,73 @@
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Maui.Controls.Sample.MainPage"
xmlns:local="clr-namespace:Maui.Controls.Sample">
<Grid Padding="20" RowSpacing="10">

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Regression Prevention -- This commits a full CarouselView debugging page into the shared Sandbox app. The sandbox is intended to stay as temporary local repro space; merging this replaces the clean sandbox startup with issue-specific UI and the paired code-behind/MauiProgram debug scaffolding. Please revert the Controls.Sample.Sandbox changes and keep the regression coverage in TestCases.HostApp/TestCases.Shared.Tests.

<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="400"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>

<!-- Title -->
<Label Grid.Row="0"
Text="Vertical CarouselView Centering Test"
FontSize="18"
FontAttributes="Bold"
HorizontalOptions="Center"/>

<!-- Handler info -->
<Label x:Name="HandlerLabel"
Grid.Row="1"
Text="Handler: Unknown"
FontSize="14"
HorizontalOptions="Center"/>

<!-- CarouselView with Vertical Orientation -->
<CarouselView x:Name="TestCarouselView"
Grid.Row="2"
BackgroundColor="LightGray"
Loop="True">
<CarouselView.ItemsLayout>
<LinearItemsLayout Orientation="Vertical"/>
</CarouselView.ItemsLayout>
<CarouselView.ItemTemplate>
<DataTemplate>
<Grid BackgroundColor="LightBlue" Padding="20">
<Label Text="{Binding}"
FontSize="24"
FontAttributes="Bold"
HorizontalOptions="Center"
VerticalOptions="Center"/>
</Grid>
</DataTemplate>
</CarouselView.ItemTemplate>
</CarouselView>

<!-- Current Item Display -->
<Label x:Name="CurrentItemLabel"
AutomationId="CurrentItemLabel"
Grid.Row="3"
Text="CurrentItem: (waiting...)"
FontSize="16"
FontAttributes="Bold"
HorizontalOptions="Center"/>

<!-- Instructions -->
<Label Grid.Row="4"
Text="Loop=True - Test scrolling past last item"
FontSize="12"
HorizontalOptions="Center"
TextColor="Gray"/>

<!-- Scroll Button -->
<Button x:Name="ScrollButton"
AutomationId="ScrollButton"
Grid.Row="5"
Text="Scroll to Next Item"
Clicked="OnScrollButtonClicked"
HorizontalOptions="Center"/>
</Grid>
</ContentPage>
104 changes: 103 additions & 1 deletion src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,111 @@
namespace Maui.Controls.Sample;
using System.Collections.ObjectModel;

namespace Maui.Controls.Sample;

public partial class MainPage : ContentPage
{
private ObservableCollection<string> _items;
private int _scrollCount = 0;

public MainPage()
{
InitializeComponent();

// Initialize data source
_items = new ObservableCollection<string>
{
"Item 1: Baboon",
"Item 2: Capuchin Monkey",
"Item 3: Blue Monkey",
"Item 4: Squirrel Monkey",
"Item 5: Golden Lion Tamarin"
};

TestCarouselView.ItemsSource = _items;
TestCarouselView.CurrentItem = _items[0];

// Subscribe to CurrentItemChanged event
TestCarouselView.CurrentItemChanged += OnCurrentItemChanged;

// Display handler info after layout
TestCarouselView.Loaded += (s, e) =>
{
var handlerType = TestCarouselView.Handler?.GetType().Name ?? "Unknown";
HandlerLabel.Text = $"Handler: {handlerType}";

Console.WriteLine("========== VERTICAL CAROUSELVIEW CENTERING TEST ==========");
Console.WriteLine($"Handler Type: {handlerType}");
Console.WriteLine($"Initial CurrentItem: {TestCarouselView.CurrentItem}");
Console.WriteLine($"Initial Position: {TestCarouselView.Position}");
Console.WriteLine($"Total Items: {_items.Count}");
Console.WriteLine($"Orientation: Vertical");
Console.WriteLine($"Loop: {TestCarouselView.Loop}");
Console.WriteLine("");
Console.WriteLine("INSTRUCTIONS:");
Console.WriteLine("1. Tap 'Scroll to Next Item' button repeatedly");
Console.WriteLine("2. Test scrolling past last item (Item 5) to see if it loops to Item 1");
Console.WriteLine("3. Watch CurrentItem updates in loop scenario");
Console.WriteLine("==========================================================");
};
}

private void OnScrollButtonClicked(object sender, EventArgs e)
{
_scrollCount++;
var targetIndex = _scrollCount % _items.Count;

Console.WriteLine("");
Console.WriteLine($"========== SCROLL #{_scrollCount} ==========");
Console.WriteLine($"Current Position: {TestCarouselView.Position}");
Console.WriteLine($"Current Item: {TestCarouselView.CurrentItem}");
Console.WriteLine($"Scrolling to index: {targetIndex} ({_items[targetIndex]})");

if (_scrollCount > _items.Count)
{
Console.WriteLine("⚠️ TESTING LOOP BEHAVIOR - scrolling past end of collection");
}

TestCarouselView.ScrollTo(targetIndex, position: ScrollToPosition.Center, animate: true);

// Wait for scroll animation and log results
Task.Run(async () =>
{
await Task.Delay(1500);
MainThread.BeginInvokeOnMainThread(() =>
{
Console.WriteLine($"After scroll - Position: {TestCarouselView.Position}, CurrentItem: {TestCarouselView.CurrentItem}");

if (TestCarouselView.CurrentItem?.ToString() == _items[targetIndex])
{
Console.WriteLine("✅ CurrentItem updated correctly");
}
else
{
Console.WriteLine($"❌ CurrentItem mismatch! Expected: {_items[targetIndex]}, Got: {TestCarouselView.CurrentItem}");
}
Console.WriteLine("==========================================");
});
});
}

private void OnCurrentItemChanged(object? sender, CurrentItemChangedEventArgs e)
{
Console.WriteLine("");
Console.WriteLine("========== CurrentItemChanged EVENT ==========");
Console.WriteLine($"Previous: {e.PreviousItem ?? "null"}");
Console.WriteLine($"Current: {e.CurrentItem ?? "null"}");
Console.WriteLine($"Position: {TestCarouselView.Position}");
Console.WriteLine("==============================================");

if (e.CurrentItem != null)
{
CurrentItemLabel.Text = $"CurrentItem: {e.CurrentItem}";
Console.WriteLine($"✅ CurrentItem updated to: {e.CurrentItem}");
}
else
{
CurrentItemLabel.Text = "CurrentItem: (null)";
Console.WriteLine("⚠️ WARNING: CurrentItem is null");
}
}
}
22 changes: 21 additions & 1 deletion src/Controls/samples/Controls.Sample.Sandbox/MauiProgram.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
namespace Maui.Controls.Sample;
namespace Maui.Controls.Sample;

public static class MauiProgram
{
// Toggle this to test different handlers
// true = Use Handler2 (CURRENT DEFAULT - the one being fixed in PR)
// false = Use Handler1 (LEGACY - to compare behavior)
private static bool UseHandler2 = true;

public static MauiApp CreateMauiApp() =>
MauiApp
.CreateBuilder()
#if __ANDROID__ || __IOS__
.UseMauiMaps()
#endif
.UseMauiApp<App>()
.ConfigureMauiHandlers(handlers =>
{
#if IOS || MACCATALYST
if (!UseHandler2)
{
// Force use of legacy Handler1 by overriding default
handlers.AddHandler<Microsoft.Maui.Controls.CarouselView, Microsoft.Maui.Controls.Handlers.Items.CarouselViewHandler>();
Console.WriteLine("✅ Forcing CarouselViewHandler1 (legacy)");
}
else
{
Console.WriteLine("✅ Using CarouselViewHandler2 (current default)");
}
#endif
})
.ConfigureFonts(fonts =>
{
fonts.AddFont("Dokdo-Regular.ttf", "Dokdo");
Expand Down
19 changes: 10 additions & 9 deletions src/Controls/src/Core/Handlers/Items2/iOS/LayoutFactory2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -361,23 +361,24 @@ public static UICollectionViewLayout CreateCarouselLayout(
return;
}

// Calculate page index accounting for ItemSpacing
// Calculate page index accounting for ItemSpacing on the active axis.
var itemSpacing = itemsView.ItemsLayout is LinearItemsLayout linearLayout ? linearLayout.ItemSpacing : 0;
var effectivePageSize = (isHorizontal
? env.Container.ContentSize.Width
: env.Container.ContentSize.Height) - sectionMargin * 2 + itemSpacing;

var effectiveItemWidth = env.Container.ContentSize.Width - sectionMargin * 2 + itemSpacing;

if (effectiveItemWidth <= 0)
if (effectivePageSize <= 0)
{
return;
}

var pageOffset = isHorizontal ? offset.X : offset.Y;
var pageSize = isHorizontal
? env.Container.ContentSize.Width
: env.Container.ContentSize.Height;
double page = (pageOffset + sectionMargin) / effectiveItemWidth;
double page = (pageOffset + sectionMargin) / effectivePageSize;

if (Math.Abs(page % 1) > (double.Epsilon * 100) || cv2Controller.ItemsSource.ItemCount <= 0)
// Vertical: Scroll stops wherever touch is released (no auto-centering), so use 0.1 (10%) threshold
// to accept positions near page boundaries where scroll naturally settles
Comment thread
PureWeen marked this conversation as resolved.
double pageThreshold = isHorizontal ? (double.Epsilon * 100) : 0.1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] CollectionView iOS/MacCatalyst -- VisibleItemsInvalidationHandler runs continuously while scrolling, not only after the scroll settles. With a 10% vertical threshold this can update Position/CurrentItem while the offset is still just past a page boundary; for Loop=true the same callback can enter the fake-item correction path and issue a non-animated ScrollToItem, interrupting the in-flight scroll. Please keep page acceptance tied to a settled/snap-complete state, or make vertical snapping produce exact page boundaries, rather than accepting the first 10% of every page.

if (Math.Abs(page % 1) > pageThreshold || cv2Controller.ItemsSource.ItemCount <= 0)
{
return;
}
Expand Down
76 changes: 76 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue32136.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 32136, "CarouselView CurrentItem Not Updating with Vertical LinearItemsLayout", PlatformAffected.iOS)]
public class Issue32136 : ContentPage
{
public Issue32136()
{
CarouselView2 carouselView = new CarouselView2
{
HeightRequest = 400,
Loop = false,
BackgroundColor = Colors.LightGray,
AutomationId = "TestCarouselView",
ItemsLayout = new LinearItemsLayout(ItemsLayoutOrientation.Vertical),
ItemTemplate = new DataTemplate(() =>
{
Label label = new Label
{
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center
};
label.SetBinding(Label.TextProperty, ".");

return new Grid
{
Children = { label }
};
}),
ItemsSource = new string[]
{
"Baboon",
"Capuchin Monkey",
"Blue Monkey",
"Squirrel Monkey",
"Golden Lion Tamarin"
}
};

Label currentItemLabel = new Label();
currentItemLabel.AutomationId = "CurrentItemLabel";
currentItemLabel.Text = "CurrentItem = Baboon";

Button button = new Button
{
Text = "Next Item",
AutomationId = "ScrollButton"
};
button.Clicked += (s, e) =>
{
currentItemLabel.Text = "Button was clicked";
carouselView.ScrollTo(carouselView.Position + 1, position: ScrollToPosition.Center, animate: true);
};

carouselView.CurrentItemChanged += (s, e) =>
{
currentItemLabel.Text = $"CurrentItem = {e.CurrentItem}";
};

Grid grid = new Grid
{
Padding = 25,
RowSpacing = 10,
RowDefinitions =
{
new RowDefinition(GridLength.Auto),
new RowDefinition(GridLength.Auto),
new RowDefinition(GridLength.Auto)
}
};

grid.Add(carouselView);
grid.Add(currentItemLabel, row: 1);
grid.Add(button, row: 2);
Content = grid;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

public class Issue32136 : _IssuesUITest
{
public Issue32136(TestDevice device) : base(device)
{
}

public override string Issue => "CarouselView CurrentItem Not Updating with Vertical LinearItemsLayout";

[Test]
[Category(UITestCategories.CarouselView)]
public void CurrentItemShouldUpdateWhenScrollingVerticalCarouselView()
{
App.WaitForElement("ScrollButton");
App.Tap("ScrollButton");
var currentItemText = App.WaitForElement("CurrentItemLabel").GetText();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] Regression Prevention and Test Coverage -- WaitForElement("CurrentItemLabel") only waits for the label to exist; it is already present before the tap, while the button starts an animated ScrollTo(..., animate: true). On slower CI this can read the initial/intermediate label before CurrentItemChanged fires. Please wait or poll for the expected text before asserting so the regression test is deterministic.

Assert.That(currentItemText, Is.EqualTo("CurrentItem = Capuchin Monkey"));
}
}
Loading