diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyHorizontalScrollViewPositionAtRuntime.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyHorizontalScrollViewPositionAtRuntime.png new file mode 100644 index 000000000000..b282972948a2 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyHorizontalScrollViewPositionAtRuntime.png differ diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue30081.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue30081.cs new file mode 100644 index 000000000000..5fee42924f74 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue30081.cs @@ -0,0 +1,133 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 30081, "[Android] ScrollView scroll position changes unexpectedly when Orientation is set to Horizontal and FlowDirection is RTL at runtime", PlatformAffected.Android)] + +public class Issue30081 : ContentPage +{ + readonly ScrollViewViewModel _viewModel; + + public Issue30081() + { + _viewModel = new ScrollViewViewModel(); + BindingContext = _viewModel; + + // Create Grid (same as XAML structure) + var grid = new Grid + { + RowDefinitions = + { + new RowDefinition { Height = GridLength.Star }, + new RowDefinition { Height = GridLength.Auto } + } + }; + + // Create ContentView and bind its Content + var scrollViewContent = new ContentView + { + AutomationId = "ScrollViewContent" + }; + scrollViewContent.SetBinding(ContentView.ContentProperty, nameof(ScrollViewViewModel.Content)); + + // Create ScrollView and bind its Orientation + var scrollView = new ScrollView + { + Content = scrollViewContent, + FlowDirection = FlowDirection.RightToLeft + }; + scrollView.SetBinding(ScrollView.OrientationProperty, nameof(ScrollViewViewModel.Orientation)); + + // Add ScrollView to Grid row 0 + grid.Add(scrollView); + Grid.SetRow(scrollView, 0); + + // Create Button + var button = new Button + { + Text = "Toggle Orientation", + AutomationId = "ToggleOrientationButton" + }; + button.Clicked += Button_Clicked; + + // Add Button to Grid row 1 + grid.Add(button); + Grid.SetRow(button, 1); + + // Set grid as the page content + Content = grid; + } + + void Button_Clicked(object sender, EventArgs e) + { + if (_viewModel.Orientation == ScrollOrientation.Vertical) + _viewModel.Orientation = ScrollOrientation.Horizontal; + else + _viewModel.Orientation = ScrollOrientation.Vertical; + } +} + +public class ScrollViewViewModel : INotifyPropertyChanged +{ + string _contentText; + ScrollOrientation _orientation = ScrollOrientation.Vertical; + + public ScrollOrientation Orientation + { + get => _orientation; + set + { + if (_orientation != value) + { + _orientation = value; + OnPropertyChanged(); + } + } + } + + View _content; + + public ScrollViewViewModel() + { + _contentText = string.Empty; + Content = new Label + { + Text = string.Join(Environment.NewLine, Enumerable.Range(1, 100).Select(i => $"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, urna eu tincidunt consectetur, nisi nisl aliquam enim, eget facilisis enim nisl nec elit . Sed euismod, urna eu tincidunt consectetur, nisi nisl aliquam enim Eget facilisis enim nisl nec elit Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae. Nullam ac erat at dui laoreet aliquet. Praesent euismod, justo at dictum facilisis, urna erat dictum enim. {i}")), + FontSize = 18, + Padding = 10 + }; + } + + public string ContentText + { + get => _contentText; + set + { + if (_contentText != value) + { + _contentText = value; + Content = new Label { Text = _contentText }; // Update Content when ContentText changes + OnPropertyChanged(); + } + } + } + + public View Content + { + get => _content; + set + { + if (_content != value) + { + _content = value; + OnPropertyChanged(); + } + } + } + public event PropertyChangedEventHandler PropertyChanged; + + protected void OnPropertyChanged([CallerMemberName] string propertyName = "") => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} \ No newline at end of file diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue30081.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue30081.cs new file mode 100644 index 000000000000..6f15cc182fe1 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue30081.cs @@ -0,0 +1,22 @@ +#if TEST_FAILS_ON_IOS && TEST_FAILS_ON_CATALYST // More Info: https://github.com/dotnet/maui/issues/32271 +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue30081 : _IssuesUITest +{ + public Issue30081(TestDevice device) : base(device) { } + + public override string Issue => "[Android] ScrollView scroll position changes unexpectedly when Orientation is set to Horizontal and FlowDirection is RTL at runtime"; + [Test] + [Category(UITestCategories.ScrollView)] + public void VerifyHorizontalScrollViewPositionAtRuntime() + { + App.WaitForElement("ToggleOrientationButton"); + App.Tap("ToggleOrientationButton"); + VerifyScreenshot(); + } +} +#endif \ No newline at end of file diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.Android.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.Android.cs index 8e24bc8f0daa..d4250379a386 100644 --- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.Android.cs +++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.Android.cs @@ -147,6 +147,14 @@ public static void MapOrientation(IScrollViewHandler handler, IScrollView scroll handler.PlatformView.SetOrientation(scrollView.Orientation); } + internal static void MapFlowDirection(IScrollViewHandler handler, IScrollView scrollView) + { + if (handler.PlatformView is MauiScrollView mauiScrollView && scrollView is IView view) + { + mauiScrollView.UpdateFlowDirection(view); + } + } + public static void MapRequestScrollTo(IScrollViewHandler handler, IScrollView scrollView, object? args) { if (args is not ScrollToRequest request) diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.cs index bff7dc29a9bd..cc46a0771bec 100644 --- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.cs +++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.cs @@ -22,6 +22,9 @@ public partial class ScrollViewHandler : IScrollViewHandler [nameof(IScrollView.HorizontalScrollBarVisibility)] = MapHorizontalScrollBarVisibility, [nameof(IScrollView.VerticalScrollBarVisibility)] = MapVerticalScrollBarVisibility, [nameof(IScrollView.Orientation)] = MapOrientation, +#if ANDROID + [nameof(IView.FlowDirection)] = MapFlowDirection, +#endif #if __IOS__ [nameof(IScrollView.IsEnabled)] = MapIsEnabled, #endif diff --git a/src/Core/src/Platform/Android/MauiScrollView.cs b/src/Core/src/Platform/Android/MauiScrollView.cs index 49c36ee11de2..e129617c50f0 100644 --- a/src/Core/src/Platform/Android/MauiScrollView.cs +++ b/src/Core/src/Platform/Android/MauiScrollView.cs @@ -10,6 +10,7 @@ using Android.Widget; using AndroidX.Core.View; using AndroidX.Core.Widget; +using ALayoutDirection = Android.Views.LayoutDirection; namespace Microsoft.Maui.Platform { @@ -26,6 +27,8 @@ public class MauiScrollView : NestedScrollView, IScrollBarView, NestedScrollView bool _didSafeAreaEdgeConfigurationChange = true; bool _isInsetListenerSet; Java.Lang.IRunnable? _setAppBarLiftTargetRunnable; + ALayoutDirection _prevLayoutDirection = ALayoutDirection.Ltr; + bool _checkedForRtlScroll; internal float LastX { get; set; } internal float LastY { get; set; } @@ -224,6 +227,12 @@ public void SetOrientation(ScrollOrientation orientation) bool orientationChanged = _scrollOrientation != orientation; _scrollOrientation = orientation; + // Reset RTL tracking when orientation changes + if (orientationChanged) + { + _checkedForRtlScroll = false; + } + if (orientation == ScrollOrientation.Horizontal || orientation == ScrollOrientation.Both) { if (_hScrollView == null) @@ -268,6 +277,28 @@ public void SetOrientation(ScrollOrientation orientation) } } + internal void UpdateFlowDirection(IView view) + { + var layoutDirection = ViewExtensions.GetLayoutDirection(view); + + // Handle FlowDirection specifically for horizontal scroll view + if (_hScrollView != null && _scrollOrientation == ScrollOrientation.Horizontal) + { + if (_prevLayoutDirection != layoutDirection) + { + _prevLayoutDirection = layoutDirection; + _hScrollView.LayoutDirection = layoutDirection; + _checkedForRtlScroll = false; // Reset to allow re-evaluation + } + } + else + { + // Fallback to default mechanism for other cases (vertical scroll or no horizontal scroll) + // Use the common ViewExtensions logic for standard FlowDirection handling + this.LayoutDirection = layoutDirection; + } + } + public override bool OnInterceptTouchEvent(MotionEvent? ev) { // See also MauiHorizontalScrollView notes in OnInterceptTouchEvent @@ -369,6 +400,21 @@ protected override void OnLayout(bool changed, int left, int top, int right, int _hScrollView.Layout(0, 0, hScrollViewWidth, hScrollViewHeight); } + // Handle RTL initial positioning + if (!_checkedForRtlScroll && _hScrollView != null && _scrollOrientation == ScrollOrientation.Horizontal) + { + if (_hScrollView.LayoutDirection == ALayoutDirection.Rtl) + { + Post(() => + { + // Scroll to the right end for RTL + _hScrollView?.ScrollTo(_hScrollView?.GetChildAt(0)?.Width ?? 0, 0); + }); + } + } + + _checkedForRtlScroll = true; + if (_didSafeAreaEdgeConfigurationChange && _isInsetListenerSet) { ViewCompat.RequestApplyInsets(this); @@ -473,6 +519,7 @@ void SmoothScrollTo(int x, int y, Action finished) void IOnScrollChangeListener.OnScrollChange(NestedScrollView? v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) #pragma warning restore CA1822 { + _checkedForRtlScroll = true; OnScrollChanged(scrollX, scrollY, oldScrollX, oldScrollY); } } diff --git a/src/Core/src/Platform/Android/ViewExtensions.cs b/src/Core/src/Platform/Android/ViewExtensions.cs index e845af0335e6..a1c89cf5c0c6 100644 --- a/src/Core/src/Platform/Android/ViewExtensions.cs +++ b/src/Core/src/Platform/Android/ViewExtensions.cs @@ -378,7 +378,7 @@ public static void UpdateFlowDirection(this AView platformView, IView view) platformView.LayoutDirection = GetLayoutDirection(view); } - static ALayoutDirection GetLayoutDirection(IView view) + internal static ALayoutDirection GetLayoutDirection(IView view) { return view.FlowDirection switch {