diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue30070.xaml b/src/Controls/tests/TestCases.HostApp/Issues/Issue30070.xaml
new file mode 100644
index 000000000000..82fba80f9860
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue30070.xaml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue30070.xaml.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue30070.xaml.cs
new file mode 100644
index 000000000000..f7a827895bec
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue30070.xaml.cs
@@ -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));
+ }
+}
\ No newline at end of file
diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/ScrollViewOrientationTest.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/ScrollViewOrientationTest.png
new file mode 100644
index 000000000000..4edc790ce976
Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/ScrollViewOrientationTest.png differ
diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue30070.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue30070.cs
new file mode 100644
index 000000000000..3e89f1db4ec1
--- /dev/null
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue30070.cs
@@ -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
\ No newline at end of file
diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/ScrollViewOrientationTest.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/ScrollViewOrientationTest.png
new file mode 100644
index 000000000000..98a77d43b167
Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/ScrollViewOrientationTest.png differ
diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.cs
index bff7dc29a9bd..d6f1407d70ca 100644
--- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.cs
+++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.cs
@@ -19,9 +19,9 @@ public partial class ScrollViewHandler : IScrollViewHandler
public static IPropertyMapper Mapper = new PropertyMapper(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
diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs
index 36debcf218b8..3e6805212ef5 100644
--- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs
+++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs
@@ -17,7 +17,11 @@ public partial class ScrollViewHandler : ViewHandler,
protected override UIScrollView CreatePlatformView()
{
- return new MauiScrollView();
+ return new MauiScrollView
+ {
+ // Set the VirtualView reference
+ VirtualView = VirtualView
+ };
}
protected override void ConnectHandler(UIScrollView platformView)
@@ -82,7 +86,8 @@ public static void MapOrientation(IScrollViewHandler handler, IScrollView scroll
{
return;
}
-
+
+ platformView.UpdateOrientation(scrollView);
platformView.UpdateIsEnabled(scrollView);
platformView.InvalidateMeasure(scrollView);
}
diff --git a/src/Core/src/Platform/iOS/MauiScrollView.cs b/src/Core/src/Platform/iOS/MauiScrollView.cs
index 410871c7bae9..5a788d28111e 100644
--- a/src/Core/src/Platform/iOS/MauiScrollView.cs
+++ b/src/Core/src/Platform/iOS/MauiScrollView.cs
@@ -18,6 +18,15 @@ public class MauiScrollView : UIScrollView, IUIViewLifeCycleEvents, ICrossPlatfo
WeakReference? _crossPlatformLayoutReference;
+ // Add reference to the ScrollView element to access orientation
+ WeakReference? _virtualViewReference;
+
+ internal IScrollView? VirtualView
+ {
+ get => _virtualViewReference != null && _virtualViewReference.TryGetTarget(out var v) ? v : null;
+ set => _virtualViewReference = value == null ? null : new WeakReference(value);
+ }
+
ICrossPlatformLayout? ICrossPlatformLayoutBacking.CrossPlatformLayout
{
get => _crossPlatformLayoutReference != null && _crossPlatformLayoutReference.TryGetTarget(out var v) ? v : null;
@@ -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.
@@ -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()
{
diff --git a/src/Core/src/Platform/iOS/ScrollViewExtensions.cs b/src/Core/src/Platform/iOS/ScrollViewExtensions.cs
index 02c65a3d10e6..9cfa5641f213 100644
--- a/src/Core/src/Platform/iOS/ScrollViewExtensions.cs
+++ b/src/Core/src/Platform/iOS/ScrollViewExtensions.cs
@@ -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)
+ {
+ 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;
+ }
+ }
}
}
diff --git a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
index 7869f46fee56..fcf2e3805b9d 100644
--- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
@@ -1,4 +1,5 @@
#nullable enable
+static Microsoft.Maui.Platform.ScrollViewExtensions.UpdateOrientation(this UIKit.UIScrollView! nativeScrollView, Microsoft.Maui.IScrollView! scrollView) -> void
virtual Microsoft.Maui.Animations.Lerp.LerpDelegate.Invoke(object! start, object! end, double progress) -> object!
Microsoft.Maui.Handlers.ContextFlyoutItemHandlerUpdate.ContextFlyoutItemHandlerUpdate(Microsoft.Maui.Handlers.ContextFlyoutItemHandlerUpdate! original) -> void
Microsoft.Maui.Handlers.ContextFlyoutItemHandlerUpdate.Deconstruct(out int Index, out Microsoft.Maui.IMenuElement! MenuElement) -> void
diff --git a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
index 7869f46fee56..fcf2e3805b9d 100644
--- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
@@ -1,4 +1,5 @@
#nullable enable
+static Microsoft.Maui.Platform.ScrollViewExtensions.UpdateOrientation(this UIKit.UIScrollView! nativeScrollView, Microsoft.Maui.IScrollView! scrollView) -> void
virtual Microsoft.Maui.Animations.Lerp.LerpDelegate.Invoke(object! start, object! end, double progress) -> object!
Microsoft.Maui.Handlers.ContextFlyoutItemHandlerUpdate.ContextFlyoutItemHandlerUpdate(Microsoft.Maui.Handlers.ContextFlyoutItemHandlerUpdate! original) -> void
Microsoft.Maui.Handlers.ContextFlyoutItemHandlerUpdate.Deconstruct(out int Index, out Microsoft.Maui.IMenuElement! MenuElement) -> void
diff --git a/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.iOS.cs
index a472a01082cd..3615f817ac78 100644
--- a/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.iOS.cs
+++ b/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.iOS.cs
@@ -15,6 +15,44 @@ namespace Microsoft.Maui.DeviceTests
{
public partial class ScrollViewHandlerTests : CoreHandlerTestBase
{
+ [Theory]
+ [InlineData(ScrollOrientation.Horizontal, true, false)]
+ [InlineData(ScrollOrientation.Vertical, false, true)]
+ [InlineData(ScrollOrientation.Both, true, true)]
+ [InlineData(ScrollOrientation.Neither, false, false)]
+ public async Task ScrollViewOrientationSetsBounceBehaviorCorrectly(ScrollOrientation orientation, bool expectedHorizontalBounce, bool expectedVerticalBounce)
+ {
+ EnsureHandlerCreated(builder => { builder.ConfigureMauiHandlers(handlers => { handlers.AddHandler(); }); });
+
+ var scrollView = new ScrollViewStub()
+ {
+ Orientation = orientation,
+ Content = new EntryStub
+ {
+ Text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
+ } // Add some content
+ };
+
+ var scrollViewHandler = await InvokeOnMainThreadAsync(() =>
+ {
+ var handler = CreateHandler(scrollView);
+
+ return handler;
+ });
+
+ await InvokeOnMainThreadAsync(async () =>
+ {
+ await scrollViewHandler.PlatformView.AttachAndRun(() =>
+ {
+ Assert.Equal(expectedHorizontalBounce, scrollViewHandler.PlatformView.AlwaysBounceHorizontal);
+ Assert.Equal(expectedVerticalBounce, scrollViewHandler.PlatformView.AlwaysBounceVertical);
+
+ // ScrollEnabled should be false only for Neither orientation
+ Assert.Equal(orientation != ScrollOrientation.Neither, scrollViewHandler.PlatformView.ScrollEnabled);
+ });
+ });
+ }
+
[Fact]
public async Task ContentInitializesCorrectly()
{