Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ internal class CarouselViewOnScrollListener : RecyclerViewScrollListener<Carouse
{
readonly CarouselView _carouselView;
readonly CarouselViewLoopManager _carouselViewLoopManager;
int _lastDx;
int _lastDy;

// Track programmatic scroll state
bool _isProgrammaticScrolling;

public CarouselViewOnScrollListener(ItemsView itemsView, ItemsViewAdapter<CarouselView, IItemsViewSource> itemsViewAdapter, CarouselViewLoopManager carouselViewLoopManager) : base((CarouselView)itemsView, itemsViewAdapter, true)
{
Expand All @@ -26,18 +31,53 @@ public override void OnScrollStateChanged(RecyclerView recyclerView, int state)
_carouselView.SetIsDragging(false);
}

// Detect programmatic scrolling.
// Note: this also covers the case where a user drag transitions to settling
// (IsDragging becomes false before ScrollStateSettling fires). This intentionally
// aligns Android behavior with iOS: CurrentItem is only updated once scrolling
// fully completes, not during the settling phase.
if (state == RecyclerView.ScrollStateSettling && !_carouselView.IsDragging)
Comment thread
SyedAbdulAzeemSF4852 marked this conversation as resolved.
{
_isProgrammaticScrolling = true;
}
else if (state == RecyclerView.ScrollStateIdle)
{
// When scroll completes, process any cached programmatic scroll data
if (_isProgrammaticScrolling)
{
_isProgrammaticScrolling = false;
OnScrolled(recyclerView, _lastDx, _lastDy);

_lastDx = 0;
Comment thread
SyedAbdulAzeemSF4852 marked this conversation as resolved.
_lastDy = 0;
}
}

_carouselView.IsScrolling = state != RecyclerView.ScrollStateIdle;
}

public override void OnScrolled(RecyclerView recyclerView, int dx, int dy)
{
base.OnScrolled(recyclerView, dx, dy);

if (_carouselView.Loop)
if (_isProgrammaticScrolling)
{
//We could have a race condition where we are scrolling our collection to center the first item
//We save that ScrollToEventARgs and call it again
_carouselViewLoopManager.CheckPendingScrollToEvents(recyclerView);
// Cache scroll data for programmatic scrolls - will be processed when ScrollStateIdle is reached.
// Note: Only the most recent delta is retained. If CurrentItem is changed
// programmatically multiple times during a single animation, earlier intermediate
// positions are not separately finalized. This is an accepted limitation.
_lastDx = dx;
_lastDy = dy;
}
else
{
// Process immediately for manual user scrolling
base.OnScrolled(recyclerView, dx, dy);

if (_carouselView.Loop)
{
//We could have a race condition where we are scrolling our collection to center the first item
//We save that ScrollToEventARgs and call it again
_carouselViewLoopManager.CheckPendingScrollToEvents(recyclerView);
}
}
}

Expand Down
93 changes: 93 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue31874.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.Collections.ObjectModel;
using System.ComponentModel;

namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 31874, "Incorrect Intermediate CurrentItem updates with CarouselView Scroll Animation Enabled", PlatformAffected.Android)]
public class Issue31874 : ContentPage
{
const string InitialItem = "0";
const string TargetItem = "4";

Label resultStatus;
Label selectedItemLabel;
CarouselView carouselView;
public Issue31874()
{
carouselView = new CarouselView
{
HeightRequest = 150,
Loop = false,
IsScrollAnimated = true,
ItemsSource = new ObservableCollection<string>{ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11" },
Copy link

Copilot AI Oct 7, 2025

Choose a reason for hiding this comment

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

[nitpick] Consider using a more readable format for the collection initialization, such as using Enumerable.Range(0, 12).Select(i => i.ToString()) or splitting across multiple lines for better readability.

Copilot uses AI. Check for mistakes.
ItemTemplate = new DataTemplate(() =>
{
Border border = new Border
{
BackgroundColor = Colors.LightGray,
Content = new Label
{
VerticalOptions = LayoutOptions.Center,
HorizontalOptions = LayoutOptions.Center,
FontSize = 80
}
};
border.Content.SetBinding(Label.TextProperty, ".");
return border;
})
};
carouselView.PropertyChanged += CarouselView_PropertyChanged;

selectedItemLabel = new Label
{
Text = $"Selected Item : {InitialItem}",
FontSize = 16
};

Button selectButton = new Button
{
AutomationId = "Issue31874ScrollBtn",
Text = $"Select item {TargetItem}",
FontSize = 12
};

selectButton.Clicked += (s, e) =>
{
carouselView.CurrentItem = TargetItem;
selectedItemLabel.Text = $"Selected Item : {carouselView.CurrentItem}";
};

resultStatus = new Label
{
AutomationId = "Issue31874ResultLabel",
Text = "Success"
};

Content = new VerticalStackLayout
{
Padding = 25,
Spacing = 15,
Children =
{
carouselView,
selectedItemLabel,
selectButton,
resultStatus
}
};
}

void CarouselView_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(CarouselView.CurrentItem))
{
var currentItemValue = carouselView.CurrentItem?.ToString();

if (!string.IsNullOrEmpty(currentItemValue) && currentItemValue != InitialItem &&
currentItemValue != TargetItem)
{
resultStatus.Text = "Failure";
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#if TEST_FAILS_ON_WINDOWS // Issue Link - https://github.com/dotnet/maui/issues/31670
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

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

public override string Issue => "Incorrect Intermediate CurrentItem updates with CarouselView Scroll Animation Enabled";

[Test]
[Category(UITestCategories.CarouselView)]
public void VerifyCurrentItemUpdatesWithScrollAnimation()
{
App.WaitForElement("Issue31874ScrollBtn");
App.Tap("Issue31874ScrollBtn");

var resultLabel = App.WaitForElement("Issue31874ResultLabel").GetText();
Assert.That(resultLabel, Is.EqualTo("Success"));
}
}
#endif
Loading