Skip to content

[iOS] Fix wrong scrolling options using the ScrollView Orientation property #30131

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
46 changes: 46 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue30070.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8" ?>
<local:TestContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:d="http://schemas.microsoft.com/dotnet/2021/maui/design"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
xmlns:local="using:Maui.Controls.Sample.Issues"
xmlns:issues="using:Maui.Controls.Sample.Issues"
x:Class="Maui.Controls.Sample.Issues.Issue30070">
<Grid RowDefinitions="*,Auto,Auto,Auto"
Padding="0,10">
<ScrollView
Grid.Row="0"
x:Name="MyScrollView"
Orientation="Horizontal"
AutomationId="TestScrollView">
<ContentView x:Name="ScrollViewContent"
Content="{Binding Content}"
AutomationId="ScrollViewContent"/>
</ScrollView>
<HorizontalStackLayout Grid.Row="1">
<Button Text="Label"
Clicked="OnContentTypeButtonClicked"
WidthRequest="100"
AutomationId="LabelContentBtn"/>
<Button Text="Image"
Clicked="OnContentTypeButtonClicked"
WidthRequest="100"
AutomationId="ImageContentBtn"/>
<Button Text="Editor"
Clicked="OnContentTypeButtonClicked"
WidthRequest="100"
AutomationId="GridContentBtn"/>
Copy link
Preview

Copilot AI Jul 3, 2025

Choose a reason for hiding this comment

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

The Editor button reuses the "GridContentBtn" AutomationId, causing a duplicate; assign a unique AutomationId (e.g. "EditorContentBtn") to ensure UI tests can target each control correctly.

Copilot uses AI. Check for mistakes.

</HorizontalStackLayout>
<HorizontalStackLayout Grid.Row="2">
<Button Text="Grid"
Clicked="OnContentTypeButtonClicked"
WidthRequest="100"
AutomationId="GridContentBtn"/>
<Button Text="AbsoluteLayout"
Clicked="OnContentTypeButtonClicked"
WidthRequest="150"
AutomationId="AbsoluteLayoutContentBtn"/>
</HorizontalStackLayout>
</Grid>
</local:TestContentPage>
189 changes: 189 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue30070.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace Maui.Controls.Sample.Issues
{
[Issue(IssueTracker.Github, 30070,
"ScrollView Orientation set to Horizontal allows both horizontal and vertical scrolling",
PlatformAffected.iOS)]
[XamlCompilation(XamlCompilationOptions.Skip)]
public partial class Issue30070 : TestContentPage
{
Issue30070ViewModel _viewModel;

public Issue30070()
{
InitializeComponent();
}

protected override void Init()
{
_viewModel = new Issue30070ViewModel();
BindingContext = _viewModel;
}

void OnContentTypeButtonClicked(object sender, EventArgs e)
{
if (sender is not Button btn)
{
return;
}

if (BindingContext is not Issue30070ViewModel vm)
{
return;
}

switch (btn.Text)
{
case "Label":
vm.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 = 16,
Padding = 10
};
break;
case "Image":
vm.Content = new Image
{
Source = "dotnet_bot.png",
HeightRequest = 2000,
WidthRequest = 2000,
Aspect = Aspect.AspectFit
};
break;
case "Editor":
vm.Content = new Editor
{
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}")),
AutoSize = EditorAutoSizeOption.TextChanges
};
break;
case "Grid":
var grid = new Grid();
for (int row = 0; row < 30; row++)
{
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
}

for (int col = 0; col < 20; col++)
{
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Star });
}

string[] names =
[
"Apple", "Banana", "Tomato", "Potato", "Orange", "Cucumber", "Broccoli", "Pineapple",
"Strawberry", "Onion", "Lettuce", "Pear", "Kiwi", "Radish", "Cabbage", "Melon", "Plum",
"Garlic", "Corn", "Blueberry", "Zucchini", "Bell Pepper", "Beetroot", "Avocado",
"Asparagus", "Pomegranate", "Cauliflower", "Fennel", "Chili Pepper", "Mushroom", "Turnip",
"Tangerine", "Radicchio", "Passion Fruit", "Endive", "Starfruit", "Kale", "Guava", "Chard",
"Persimmon", "Arugula", "Coconut", "Celery", "Lychee", "Okra", "Dragon Fruit", "Squash",
"Mulberry", "Artichoke", "Cranberry", "Parsnip", "Raspberry", "Pumpkin", "Blackberry",
"Sweet Potato", "Grapefruit", "Eggplant", "Tamarind", "Nectarine"
];

for (int row = 0; row < 30; row++)
for (int col = 0; col < 20; col++)
{
int index = (row * 20 + col) % names.Length;
var cell = new Label
{
Text = names[index],
FontSize = 16,
HorizontalOptions = LayoutOptions.Center,
Padding = new Thickness(8)
};
grid.Add(cell, col, row);
}

vm.Content = grid;
break;

case "AbsoluteLayout":
var absolute = new AbsoluteLayout { HeightRequest = 800, WidthRequest = 800 };

for (int i = 0; i < 10; i++)
{
var box = new BoxView
{
Color = i % 2 == 0 ? Colors.CornflowerBlue : Colors.Orange,
WidthRequest = 80,
HeightRequest = 80
};
AbsoluteLayout.SetLayoutBounds(box, new Rect(30 + i * 70, 30 + i * 70, 80, 80));
absolute.Children.Add(box);
}

for (int i = 0; i < 10; i++)
{
var box = new BoxView
{
Color = i % 2 == 0 ? Colors.MediumPurple : Colors.ForestGreen,
WidthRequest = 80,
HeightRequest = 80
};
AbsoluteLayout.SetLayoutBounds(box, new Rect(30 + (9 - i) * 70, 30 + i * 70, 80, 80));
absolute.Children.Add(box);
}

vm.Content = absolute;
break;
}
}
}

public class Issue30070ViewModel : INotifyPropertyChanged
{
string _contentText;
View _content;

public Issue30070ViewModel()
{
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));
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#if IOS || MACCATALYST
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

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

public override string Issue => "ScrollView Orientation set to Horizontal allows both horizontal and vertical scrolling";

[Test]
[Category(UITestCategories.ScrollView)]
public void ScrollViewOrientationTest()
{
App.WaitForElement("TestScrollView");
App.ScrollDown("TestScrollView");

// Wait to allow any temporary scroll indicators to disappear,
// ensuring a clean screenshot for visual validation.
Thread.Sleep(1000);

VerifyScreenshot();
}
}
#endif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/Core/src/Handlers/ScrollView/ScrollViewHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ public partial class ScrollViewHandler : IScrollViewHandler
public static IPropertyMapper<IScrollView, IScrollViewHandler> Mapper = new PropertyMapper<IScrollView, IScrollViewHandler>(ViewMapper)
{
[nameof(IScrollView.Content)] = MapContent,
[nameof(IScrollView.Orientation)] = MapOrientation,
[nameof(IScrollView.HorizontalScrollBarVisibility)] = MapHorizontalScrollBarVisibility,
[nameof(IScrollView.VerticalScrollBarVisibility)] = MapVerticalScrollBarVisibility,
[nameof(IScrollView.Orientation)] = MapOrientation,
#if __IOS__
[nameof(IScrollView.IsEnabled)] = MapIsEnabled,
#endif
Expand Down
9 changes: 7 additions & 2 deletions src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ public partial class ScrollViewHandler : ViewHandler<IScrollView, UIScrollView>,

protected override UIScrollView CreatePlatformView()
{
return new MauiScrollView();
return new MauiScrollView
{
// Set the VirtualView reference
VirtualView = VirtualView
};
}

protected override void ConnectHandler(UIScrollView platformView)
Expand Down Expand Up @@ -82,7 +86,8 @@ public static void MapOrientation(IScrollViewHandler handler, IScrollView scroll
{
return;
}


platformView.UpdateOrientation(scrollView);
platformView.UpdateIsEnabled(scrollView);
platformView.InvalidateMeasure(scrollView);
}
Expand Down
33 changes: 33 additions & 0 deletions src/Core/src/Platform/iOS/MauiScrollView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ public class MauiScrollView : UIScrollView, IUIViewLifeCycleEvents, ICrossPlatfo

WeakReference<ICrossPlatformLayout>? _crossPlatformLayoutReference;

// Add reference to the ScrollView element to access orientation
WeakReference<IScrollView>? _virtualViewReference;

internal IScrollView? VirtualView
{
get => _virtualViewReference != null && _virtualViewReference.TryGetTarget(out var v) ? v : null;
set => _virtualViewReference = value == null ? null : new WeakReference<IScrollView>(value);
}

ICrossPlatformLayout? ICrossPlatformLayoutBacking.CrossPlatformLayout
{
get => _crossPlatformLayoutReference != null && _crossPlatformLayoutReference.TryGetTarget(out var v) ? v : null;
Expand Down Expand Up @@ -53,6 +62,12 @@ public override void LayoutSubviews()
var crossPlatformContentSize = crossPlatformLayout.CrossPlatformArrange(new Rect(new Point(), crossPlatformBounds));
var contentSize = crossPlatformContentSize.ToCGSize();

// Apply orientation constraints to content size
if (VirtualView != null)
{
contentSize = ApplyOrientationConstraints(contentSize, bounds.Size, VirtualView.Orientation);
}

// For Right-To-Left (RTL) layouts, we need to adjust the content arrangement and offset
// to ensure the content is correctly aligned and scrolled. This involves a second layout
// arrangement with an adjusted starting point and recalculating the content offset.
Expand Down Expand Up @@ -104,6 +119,24 @@ public override CGSize SizeThatFits(CGSize size)

return contentSize;
}

static CGSize ApplyOrientationConstraints(CGSize contentSize, CGSize frameSize, ScrollOrientation orientation)
{
return orientation switch
{
ScrollOrientation.Horizontal => new CGSize(
Math.Max(contentSize.Width, frameSize.Width), // Ensure content can scroll horizontally
frameSize.Height // Prevent vertical scrolling by matching frame height
),
ScrollOrientation.Vertical => new CGSize(
frameSize.Width, // Prevent horizontal scrolling by matching frame width
Math.Max(contentSize.Height, frameSize.Height) // Ensure content can scroll vertically
),
ScrollOrientation.Neither => frameSize, // Prevent all scrolling
ScrollOrientation.Both => contentSize, // Allow scrolling in both directions
_ => contentSize
};
}

void IPlatformMeasureInvalidationController.InvalidateAncestorsMeasuresWhenMovedToWindow()
{
Expand Down
31 changes: 31 additions & 0 deletions src/Core/src/Platform/iOS/ScrollViewExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,36 @@ public static void UpdateIsEnabled(this UIScrollView nativeScrollView, IScrollVi
nativeScrollView.ScrollEnabled = scrollView.IsEnabled;
}
}

public static void UpdateOrientation(this UIScrollView nativeScrollView, IScrollView scrollView)
Copy link
Preview

Copilot AI Jul 3, 2025

Choose a reason for hiding this comment

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

A new public extension method UpdateOrientation was added; please update the corresponding XML documentation in /docs/ to describe this method and its behavior so public API docs stay in sync.

Copilot uses AI. Check for mistakes.

{
switch (scrollView.Orientation)
{
case ScrollOrientation.Horizontal:
nativeScrollView.AlwaysBounceHorizontal = true;
nativeScrollView.AlwaysBounceVertical = false;
nativeScrollView.ShowsHorizontalScrollIndicator = true;
nativeScrollView.ShowsVerticalScrollIndicator = false;
break;
case ScrollOrientation.Vertical:
nativeScrollView.AlwaysBounceHorizontal = false;
nativeScrollView.AlwaysBounceVertical = true;
nativeScrollView.ShowsHorizontalScrollIndicator = false;
nativeScrollView.ShowsVerticalScrollIndicator = true;
break;
case ScrollOrientation.Both:
nativeScrollView.AlwaysBounceHorizontal = true;
nativeScrollView.AlwaysBounceVertical = true;
nativeScrollView.ShowsHorizontalScrollIndicator = true;
nativeScrollView.ShowsVerticalScrollIndicator = true;
break;
case ScrollOrientation.Neither:
nativeScrollView.AlwaysBounceHorizontal = false;
nativeScrollView.AlwaysBounceVertical = false;
nativeScrollView.ShowsHorizontalScrollIndicator = false;
nativeScrollView.ShowsVerticalScrollIndicator = false;
break;
}
}
}
}
Loading
Loading