diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue34671.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue34671.cs new file mode 100644 index 000000000000..4b790bd14ed8 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue34671.cs @@ -0,0 +1,287 @@ +using System.Globalization; + +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 34671, "[Windows] ScrollView offsets do not preserve when Orientation changes to Neither", PlatformAffected.UWP)] +public class Issue34671 : ContentPage +{ + const double ReproScrollX = 700; + const double ReproScrollY = 480; + readonly Label _orientationLabel; + readonly Label _offsetLabel; + readonly Label _lastActionLabel; + readonly ScrollView _bugScrollView; + string _lastAction = string.Empty; + + public Issue34671() + { + Title = "ScrollView Orientation Repro"; + + _orientationLabel = new Label + { + AutomationId = "OrientationLabel", + Text = "Orientation: Both" + }; + + _offsetLabel = new Label + { + AutomationId = "OffsetLabel", + Text = "ScrollX: 0 | ScrollY: 0" + }; + + _lastActionLabel = new Label + { + AutomationId = "LastActionLabel", + Text = "Action: launch the app and tap 'Scroll To Repro Offset'." + }; + + _bugScrollView = new ScrollView + { + AutomationId = "BugScrollView", + Orientation = ScrollOrientation.Both, + Content = CreateScrollContent() + }; + _bugScrollView.Scrolled += OnScrollViewScrolled; + + Content = new Grid + { + Padding = 16, + RowSpacing = 12, + RowDefinitions = + { + new RowDefinition(GridLength.Auto), + new RowDefinition(GridLength.Auto), + new RowDefinition(GridLength.Auto), + new RowDefinition(GridLength.Star) + }, + Children = + { + new Label + { + Text = "Repro for dotnet/maui#34671: scroll away from the origin, then set Orientation to Neither. On iOS, ScrollX and ScrollY reset to 0 instead of being preserved.", + LineBreakMode = LineBreakMode.WordWrap + }.Row(0), + new VerticalStackLayout + { + Spacing = 6, + Children = + { + new HorizontalStackLayout + { + Spacing = 8, + Children = + { + new Button + { + Text = "Scroll To Repro Offset", + AutomationId = "ScrollToReproOffsetButton" + }.Assign(out var scrollToReproOffsetButton), + new Button + { + Text = "Set Both", + AutomationId = "SetBothButton" + }.Assign(out var setBothButton), + new Button + { + Text = "Set Neither", + AutomationId = "SetNeitherButton" + }.Assign(out var setNeitherButton), + new Button + { + Text = "Reset", + AutomationId = "ResetButton" + }.Assign(out var resetButton), + } + }, + _orientationLabel, + _offsetLabel, + _lastActionLabel + } + }.Row(1), + new Border + { + StrokeThickness = 1, + Stroke = Color.FromArgb("#A0A0A0"), + Padding = 10, + Background = Color.FromArgb("#F7F7F7"), + Content = new Label + { + Text = "Expected: changing to Neither should preserve the current scroll position. Actual bug: the offset snaps back to the origin." + } + }.Row(2), + new Border + { + StrokeThickness = 1, + Stroke = Color.FromArgb("#202020"), + Background = Color.FromArgb("#FFFFFF"), + HeightRequest = 360, + Content = _bugScrollView + }.Row(3) + } + }; + + scrollToReproOffsetButton.Clicked += OnScrollToSampleClicked; + setBothButton.Clicked += OnSetBothClicked; + setNeitherButton.Clicked += OnSetNeitherClicked; + resetButton.Clicked += OnResetClicked; + + RefreshState("Launch the app, tap 'Scroll To Repro Offset', then tap 'Set Neither'."); + } + + Grid CreateScrollContent() + { + return new Grid + { + WidthRequest = 1400, + HeightRequest = 1100, + Background = Color.FromArgb("#FFF8E7"), + RowDefinitions = + { + new RowDefinition(220), + new RowDefinition(220), + new RowDefinition(220), + new RowDefinition(220), + new RowDefinition(220) + }, + ColumnDefinitions = + { + new ColumnDefinition(280), + new ColumnDefinition(280), + new ColumnDefinition(280), + new ColumnDefinition(280), + new ColumnDefinition(280) + }, + Children = + { + new BoxView { Margin = 16, Color = Color.FromArgb("#F94144"), Opacity = 0.35 }.Row(0).Column(0), + new BoxView { Margin = 16, Color = Color.FromArgb("#F3722C"), Opacity = 0.35 }.Row(0).Column(4), + new BoxView { Margin = 16, Color = Color.FromArgb("#90BE6D"), Opacity = 0.45 }.Row(2).Column(2), + new BoxView { Margin = 16, Color = Color.FromArgb("#577590"), Opacity = 0.35 }.Row(4).Column(0), + new BoxView { Margin = 16, Color = Color.FromArgb("#277DA1"), Opacity = 0.35 }.Row(4).Column(4), + new Border + { + Margin = 20, + Padding = 12, + Background = Color.FromArgb("#FFFFFF"), + Stroke = Color.FromArgb("#222222"), + Content = new Label + { + Text = "Origin (0,0)", + FontAttributes = FontAttributes.Bold + } + }.Row(0).Column(0), + new Border + { + Margin = 20, + Padding = 12, + Background = Color.FromArgb("#FFFFFF"), + Stroke = Color.FromArgb("#222222"), + Content = new VerticalStackLayout + { + Spacing = 6, + Children = + { + new Label + { + Text = "Target zone", + FontAttributes = FontAttributes.Bold + }, + new Label + { + Text = "Tap 'Scroll To Repro Offset', then 'Set Neither'." + } + } + } + }.Row(2).Column(2), + new Border + { + Margin = 20, + Padding = 12, + Background = Color.FromArgb("#FFFFFF"), + Stroke = Color.FromArgb("#222222"), + Content = new Label + { + Text = "Bottom-right marker", + FontAttributes = FontAttributes.Bold + } + }.Row(4).Column(4) + } + }; + } + + async void OnScrollToSampleClicked(object sender, EventArgs e) + { + var orientationChanged = _bugScrollView.Orientation != ScrollOrientation.Both; + _bugScrollView.Orientation = ScrollOrientation.Both; + + if (orientationChanged) + { + await Task.Delay(100); + } + + await _bugScrollView.ScrollToAsync(ReproScrollX, ReproScrollY, false); + await Task.Delay(50); + RefreshState($"Scrolled to approx. X={ReproScrollX:0}, Y={ReproScrollY:0}."); + } + + void OnSetBothClicked(object sender, EventArgs e) + { + _bugScrollView.Orientation = ScrollOrientation.Both; + RefreshState("Orientation set to Both."); + } + + async void OnSetNeitherClicked(object sender, EventArgs e) + { + var beforeX = _bugScrollView.ScrollX; + var beforeY = _bugScrollView.ScrollY; + + _bugScrollView.Orientation = ScrollOrientation.Neither; + + await Task.Delay(100); + RefreshState($"Set Orientation to Neither. Before: X={beforeX:0.##}, Y={beforeY:0.##}. After: X={_bugScrollView.ScrollX:0.##}, Y={_bugScrollView.ScrollY:0.##}."); + } + + async void OnResetClicked(object sender, EventArgs e) + { + _bugScrollView.Orientation = ScrollOrientation.Both; + await _bugScrollView.ScrollToAsync(0, 0, false); + RefreshState("Reset orientation to Both and scrolled back to the origin."); + } + + void OnScrollViewScrolled(object sender, ScrolledEventArgs e) + { + RefreshState(_lastAction); + } + + void RefreshState(string action) + { + _lastAction = action ?? string.Empty; + _orientationLabel.Text = $"Orientation: {_bugScrollView.Orientation}"; + _offsetLabel.Text = $"ScrollX: {_bugScrollView.ScrollX.ToString("0.##", CultureInfo.InvariantCulture)} | ScrollY: {_bugScrollView.ScrollY.ToString("0.##", CultureInfo.InvariantCulture)}"; + _lastActionLabel.Text = $"Action: {_lastAction}"; + } +} + +static class Issue34671ViewExtensions +{ + public static TView Row(this TView view, int row) + where TView : View + { + Grid.SetRow(view, row); + return view; + } + + public static TView Column(this TView view, int column) + where TView : View + { + Grid.SetColumn(view, column); + return view; + } + + public static TView Assign(this TView view, out TView assigned) + where TView : View + { + assigned = view; + return view; + } +} \ No newline at end of file diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34671.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34671.cs new file mode 100644 index 000000000000..c7e6fcdc0bbf --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34671.cs @@ -0,0 +1,41 @@ +#if WINDOWS || ANDROID // Existing PR for iOS : https://github.com/dotnet/maui/pull/34672 +using System.Globalization; +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue34671 : _IssuesUITest +{ + public override string Issue => "[Windows] ScrollView offsets do not preserve when Orientation changes to Neither"; + + public Issue34671(TestDevice device) : base(device) { } + + [Test] + [Category(UITestCategories.ScrollView)] + public void Issue34671ScrollPositionIsPreservedWhenOrientationChangesToNeither() + { + App.WaitForElement("ScrollToReproOffsetButton"); + App.Tap("ScrollToReproOffsetButton"); + App.WaitForTextToBePresentInElement("LastActionLabel", "Scrolled to approx"); + App.WaitForElement("SetNeitherButton"); + App.Tap("SetNeitherButton"); + + var offsetText = App.WaitForElement("OffsetLabel").GetText() + ?? throw new InvalidOperationException("OffsetLabel text was null."); + var offsetParts = offsetText.Split('|', StringSplitOptions.TrimEntries); + var scrollXText = offsetParts[0] + .Replace("ScrollX:", string.Empty, StringComparison.Ordinal) + .Trim(); + var scrollYText = offsetParts[1] + .Replace("ScrollY:", string.Empty, StringComparison.Ordinal) + .Trim(); + var scrollX = double.Parse(scrollXText, CultureInfo.InvariantCulture); + var scrollY = double.Parse(scrollYText, CultureInfo.InvariantCulture); + + Assert.That(scrollX, Is.GreaterThan(0d), "ScrollX should remain non-zero after setting orientation to Neither."); + Assert.That(scrollY, Is.GreaterThan(0d), "ScrollY should remain non-zero after setting orientation to Neither."); + } +} +#endif diff --git a/src/Core/src/Platform/Windows/ScrollViewerExtensions.cs b/src/Core/src/Platform/Windows/ScrollViewerExtensions.cs index 46487d6c911f..5b716146f673 100644 --- a/src/Core/src/Platform/Windows/ScrollViewerExtensions.cs +++ b/src/Core/src/Platform/Windows/ScrollViewerExtensions.cs @@ -20,10 +20,21 @@ public static void UpdateScrollBarVisibility(this ScrollViewer scrollViewer, Scr { if (orientation == ScrollOrientation.Neither) { - scrollViewer.HorizontalScrollBarVisibility = scrollViewer.VerticalScrollBarVisibility = WScrollBarVisibility.Disabled; + // Use Hidden (not Disabled) so WinUI keeps the current ContentOffset instead of + // resetting it to (0,0). ScrollMode.Disabled prevents further user scrolling while + // the orientation is Neither. + scrollViewer.HorizontalScrollBarVisibility = WScrollBarVisibility.Hidden; + scrollViewer.VerticalScrollBarVisibility = WScrollBarVisibility.Hidden; + scrollViewer.HorizontalScrollMode = ScrollMode.Disabled; + scrollViewer.VerticalScrollMode = ScrollMode.Disabled; return; } + // When leaving Neither, restore scroll modes to Auto so that they follow + // the ScrollBarVisibility settings applied below. + scrollViewer.HorizontalScrollMode = ScrollMode.Auto; + scrollViewer.VerticalScrollMode = ScrollMode.Auto; + if (visibility == ScrollBarVisibility.Default) { // If the user has not explicitly set a horizontal scroll bar visibility, then the orientation will