diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs index b14be1c5fd18..d8aa17492025 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs @@ -77,8 +77,20 @@ public void SetCustomContent(View content) { sv.Scrolled += ScrollViewScrolled; removeScrolledEvent = () => sv.Scrolled -= ScrollViewScrolled; - void ScrollViewScrolled(object sender, ScrolledEventArgs e) => - OnScrolled((nfloat)sv.ScrollY); + void ScrollViewScrolled(object sender, ScrolledEventArgs e) + { + // Use the event only for timing and read the offset from the native view. + // OnScrolled works in native offsets (which rest at -headerHeight, since + // SetHeaderContentInset carries the header in ContentInset), while the + // value ScrollY reports depends on which renderer is in play — the default + // handler publishes content coordinates, the compatibility renderer raw + // ContentOffset — so converting from it would be right for one and wrong + // for the other. With no native view there is no usable offset: in this + // convention 0 is not neutral but "scrolled past the header", so skip + // rather than collapse the header. + if (ScrollView is { } nativeScrollView) + OnScrolled(nativeScrollView.ContentOffset.Y); + } } #pragma warning disable CS0618 // Type or member is obsolete else if (Content is CollectionView cv) diff --git a/src/Controls/src/Core/ScrollView/ScrollView.cs b/src/Controls/src/Core/ScrollView/ScrollView.cs index ac7d843dfe76..2dd1ed0159ee 100644 --- a/src/Controls/src/Core/ScrollView/ScrollView.cs +++ b/src/Controls/src/Core/ScrollView/ScrollView.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Threading.Tasks; using Microsoft.Maui.Graphics; +using Microsoft.Maui.Handlers; using Microsoft.Maui.Layouts; namespace Microsoft.Maui.Controls @@ -15,7 +16,7 @@ namespace Microsoft.Maui.Controls [ContentProperty(nameof(Content))] [DebuggerDisplay("{GetDebuggerDisplay(), nq}")] #pragma warning disable CS0618 // Type or member is obsolete - public partial class ScrollView : Compatibility.Layout, ILayout, ILayoutController, IPaddingElement, IView, IVisualTreeElement, IInputTransparentContainerElement, IScrollViewController, IElementConfiguration, IFlowDirectionController, IScrollView, IContentView, ISafeAreaElement, ISafeAreaView2 + public partial class ScrollView : Compatibility.Layout, ILayout, ILayoutController, IPaddingElement, IView, IVisualTreeElement, IInputTransparentContainerElement, IScrollViewController, IElementConfiguration, IFlowDirectionController, IScrollView, IScrollOffsetReceiver, IContentView, ISafeAreaElement, ISafeAreaView2 #pragma warning restore CS0618 // Type or member is obsolete { #region IScrollViewController @@ -40,18 +41,92 @@ public Rect LayoutAreaOverride public event EventHandler ScrollToRequested; ScrollToRequestedEventArgs _pendingScrollToRequested; + bool _replayPendingScrollToRequestedEvent; private protected override void OnHandlerChangedCore() { base.OnHandlerChangedCore(); - if (Handler is not null && _pendingScrollToRequested is not null) + if (Handler is null) { - OnScrollToRequested(_pendingScrollToRequested); - _pendingScrollToRequested = null; + // The handler went away with a request still queued, so nothing will ever + // dispatch it. Release the caller rather than leaving its task pending + // forever; Core does the same for its own pending request on disconnect. + if (_pendingScrollToRequested is not null) + { + _pendingScrollToRequested = null; + SendScrollFinished(); + } + + return; + } + + DispatchPendingScrollToRequest(); + } + + void DispatchPendingScrollToRequest() + { + if (Handler is null || _pendingScrollToRequested is not { } pending) + { + return; + } + + if (pending.Mode == ScrollToMode.Element) + { + if (!IsElementTargetGeometryReady()) + { + // The request has to wait; OnSizeAllocated and ContentSizeChanged retry it. + return; + } + + // Those callbacks run while the pass that produced the sizes is still arranging + // children, so resolve on the next tick, once positions are final. Posting on + // every retry is deliberate: SendPendingScrollToRequest is a no-op once the + // request has been sent or superseded, so a dropped callback cannot wedge the + // request the way an "already queued" flag would. + Dispatcher.Dispatch(SendPendingScrollToRequest); + return; + } + + SendPendingScrollToRequest(); + } + + // An element target is resolved against this ScrollView's geometry and the element's + // position inside the arranged content. Before the first layout pass Width/Height are + // still -1 (the never-arranged sentinel, for the content too), so a target computed + // then is garbage. The content check must be "not yet arranged" rather than "arranged + // to nothing": content can legitimately arrange to a zero size (a collapsed container), + // and that raises no further callbacks — gating on the size would hang the caller's + // task forever, while dispatching just clamps the target to the origin. + bool IsElementTargetGeometryReady() => + Width >= 0 && Height >= 0 && Content is not ({ Width: < 0 } or { Height: < 0 }); + + void SendPendingScrollToRequest() + { + if (Handler is null || _pendingScrollToRequested is not { } pending) + { + return; + } + + _pendingScrollToRequested = null; + + // Replay without going through OnScrollToRequested: that would reset the + // completion source and orphan the task the original caller is still awaiting. + // The event is re-raised only for requests parked before the handler attached: + // compatibility renderers subscribe to ScrollToRequested at attach and perform the + // scroll from it, so they would otherwise never see the request. A request parked + // with the handler present (waiting for element geometry) already notified its + // subscribers at request time, and re-raising would double-notify them. + if (_replayPendingScrollToRequestedEvent) + { + _replayPendingScrollToRequestedEvent = false; + ScrollToRequested?.Invoke(this, pending); } + + Handler.Invoke(nameof(IScrollView.RequestScrollTo), ConvertRequestMode(pending).ToRequest()); } + /// /// Gets the scroll position for the specified element. /// @@ -62,39 +137,62 @@ public Point GetScrollPositionForElement(VisualElement item, ScrollToPosition po double y = GetCoordinate(item, "Y", 0); double x = GetCoordinate(item, "X", 0); + // The scrollable viewport can be smaller than this ScrollView's frame: on iOS, content + // insets (safe area, ContentInset) obscure part of the frame and (0,0) in scroll + // coordinates is the inset rest position. Compute element targets against the effective + // viewport so End/Center/MakeVisible land the element fully inside the visible region. + // Part of those insets can be baked into the content itself (the platform arranged the + // content inside safe-area-inset bounds): element coordinates already include that + // padding, so targets shift back by it — the platform-inset part is instead + // compensated when the request is translated to a native offset. + var viewportInsets = GetVisibleViewportInsets(); + var contentInsets = GetContentCoordinateInsets(); + double viewportWidth = Math.Max(0, Width - viewportInsets.HorizontalThickness); + double viewportHeight = Math.Max(0, Height - viewportInsets.VerticalThickness); + if (position == ScrollToPosition.MakeVisible) { - var scrollBounds = new Rect(ScrollX, ScrollY, Width, Height); + // In content coordinates the visible window starts past the baked padding + var scrollBounds = new Rect(ScrollX + contentInsets.Left, ScrollY + contentInsets.Top, viewportWidth, viewportHeight); var itemBounds = new Rect(x, y, item.Width, item.Height); if (scrollBounds.Contains(itemBounds)) return new Point(ScrollX, ScrollY); switch (Orientation) { case ScrollOrientation.Vertical: - position = y > ScrollY ? ScrollToPosition.End : ScrollToPosition.Start; + position = y > scrollBounds.Y ? ScrollToPosition.End : ScrollToPosition.Start; break; case ScrollOrientation.Horizontal: - position = x > ScrollX ? ScrollToPosition.End : ScrollToPosition.Start; + position = x > scrollBounds.X ? ScrollToPosition.End : ScrollToPosition.Start; break; case ScrollOrientation.Both: - position = x > ScrollX || y > ScrollY ? ScrollToPosition.End : ScrollToPosition.Start; + position = x > scrollBounds.X || y > scrollBounds.Y ? ScrollToPosition.End : ScrollToPosition.Start; break; } } switch (position) { case ScrollToPosition.Center: - y = y - Height / 2 + item.Height / 2; - x = x - Width / 2 + item.Width / 2; + y = y - viewportHeight / 2 + item.Height / 2; + x = x - viewportWidth / 2 + item.Width / 2; break; case ScrollToPosition.End: - y = y - Height + item.Height; - x = x - Width + item.Width; + y = y - viewportHeight + item.Height; + x = x - viewportWidth + item.Width; break; } - return new Point(x, y); + return new Point(x - contentInsets.Left, y - contentInsets.Top); } + // The scrollable viewport can be smaller than the frame: on iOS the adjusted content + // insets obscure part of it. The handler owns that coordinate convention and reports + // it here; handlers whose viewport always equals the frame don't implement the contract. + Thickness GetVisibleViewportInsets() => + (Handler as IScrollViewportProvider)?.ViewportInsets ?? default; + + Thickness GetContentCoordinateInsets() => + (Handler as IScrollViewportProvider)?.ContentCoordinateInsets ?? default; + /// /// Sends the scroll finished notification. /// @@ -231,6 +329,10 @@ void ContentSizeChanged(object sender, EventArgs e) // The ContentSize includes the margins for the content ContentSize = new Size(frameSize.Width + margin.HorizontalThickness, frameSize.Height + margin.VerticalThickness); + + // The content has been arranged, so an element target can now be resolved: its + // position is read from the content tree, which is only meaningful once laid out + DispatchPendingScrollToRequest(); } /// @@ -423,9 +525,24 @@ void OnScrollToRequested(ScrollToRequestedEventArgs e) if (Handler is null) { _pendingScrollToRequested = e; + _replayPendingScrollToRequestedEvent = true; + } + else if (e.Mode == ScrollToMode.Element && !IsElementTargetGeometryReady()) + { + // The handler exists but layout has not run yet (e.g. ScrollToAsync from + // OnAppearing): resolving the element target now would compute against the -1 + // never-arranged sentinels. Park it for the layout callbacks instead — the + // subscribers were already notified above, so the replay must not re-raise. + _pendingScrollToRequested = e; + _replayPendingScrollToRequestedEvent = false; } else { + // This request supersedes anything still queued: a deferred element request + // whose dispatch is already scheduled must not run afterwards and restore the + // older target (latest request wins). + _pendingScrollToRequested = null; + Handler.Invoke(nameof(IScrollView.RequestScrollTo), ConvertRequestMode(e).ToRequest()); } } @@ -470,6 +587,14 @@ double IScrollView.VerticalOffset } } + void IScrollOffsetReceiver.UpdateScrollOffsets(double horizontalOffset, double verticalOffset) + { + // The reported offsets moved because the platform insets did, not because anything + // scrolled: keep ScrollX/ScrollY (and their bindings) current without raising Scrolled + ScrollX = horizontalOffset; + ScrollY = verticalOffset; + } + void IScrollView.RequestScrollTo(double horizontalOffset, double verticalOffset, bool instant) { var request = new ScrollToRequest(horizontalOffset, verticalOffset, instant); @@ -533,6 +658,10 @@ protected override Size ArrangeOverride(Rect bounds) protected override void OnSizeAllocated(double width, double height) { base.OnSizeAllocated(width, height); + + // Geometry is now known, so an element-mode request held back at handler-attach + // can be resolved + DispatchPendingScrollToRequest(); } Size ICrossPlatformLayout.CrossPlatformArrange(Rect bounds) diff --git a/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs b/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs index 5d0b58727ca8..9c1e8e23f72f 100644 --- a/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs @@ -314,6 +314,255 @@ public void TestBackToBackBiDirectionalScroll() Assert.Equal(2, y100Count); } + [Fact] + public async Task DeferredElementScrollCompletesWhenTheHandlerGoesAway() + { + var item = new View(); + var scrollView = new ScrollView { Content = new StackLayout { Children = { item } } }; + + // No handler yet, so the request is held. An element target also cannot be resolved + // until layout has run, so it stays held even once a handler attaches. + var task = scrollView.ScrollToAsync(item, ScrollToPosition.Center, false); + Assert.False(task.IsCompleted); + + scrollView.Handler = Substitute.For(); + Assert.False(task.IsCompleted); + + // The handler goes away before layout ever happens, so nothing will dispatch the + // request; the awaiting caller must still be released rather than hanging forever. + scrollView.Handler = null; + + await task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(task.IsCompleted); + } + + [Fact] + public void DeferredElementScrollDispatchesOnceContentIsArranged() + { + var item = new View(); + var layout = new StackLayout { Children = { item } }; + var scrollView = new ScrollView { Content = layout }; + + var task = scrollView.ScrollToAsync(item, ScrollToPosition.Start, false); + + var handler = new ViewportProviderHandlerStub(); + scrollView.Handler = handler; + + // No geometry yet, so the request must still be held + Assert.Empty(handler.ScrollToRequests); + + item.Layout(new Graphics.Rect(0, 450, 100, 50)); + layout.Layout(new Graphics.Rect(0, 0, 100, 1000)); + scrollView.Layout(new Graphics.Rect(0, 0, 100, 100)); + + // The element target resolves against the arranged position, not the zeros it had + // when the request was queued + var request = Assert.Single(handler.ScrollToRequests); + Assert.Equal(450, request.VerticalOffset); + + // The replay must complete the task the original caller is still awaiting, not a + // fresh completion source + Assert.False(task.IsCompleted); + scrollView.SendScrollFinished(); + Assert.True(task.IsCompleted); + } + + [Fact] + public void DirectRequestSupersedesDeferredElementRequest() + { + var item = new View(); + var layout = new StackLayout { Children = { item } }; + var scrollView = new ScrollView { Content = layout }; + + _ = scrollView.ScrollToAsync(item, ScrollToPosition.Start, false); + + var handler = new ViewportProviderHandlerStub(); + scrollView.Handler = handler; + + // A direct request while the element request is still held must win + var task = scrollView.ScrollToAsync(0, 100, false); + var request = Assert.Single(handler.ScrollToRequests); + Assert.Equal(100, request.VerticalOffset); + + // Layout completing later must not dispatch the stale element target on top of it + item.Layout(new Graphics.Rect(0, 450, 100, 50)); + layout.Layout(new Graphics.Rect(0, 0, 100, 1000)); + scrollView.Layout(new Graphics.Rect(0, 0, 100, 100)); + + Assert.Single(handler.ScrollToRequests); + scrollView.SendScrollFinished(); + Assert.True(task.IsCompleted); + } + + [Fact] + public void DeferredElementScrollCompletesWhenContentArrangesToZero() + { + var item = new View(); + var layout = new StackLayout { Children = { item } }; + var scrollView = new ScrollView { Content = layout }; + + var task = scrollView.ScrollToAsync(item, ScrollToPosition.Start, false); + + var handler = new ViewportProviderHandlerStub(); + scrollView.Handler = handler; + + // Everything arranges to zero (a collapsed container). "Arranged to nothing" is a + // settled state: the request must dispatch and clamp instead of waiting for a + // content size that will never become non-zero. + item.Layout(new Graphics.Rect(0, 0, 0, 0)); + layout.Layout(new Graphics.Rect(0, 0, 0, 0)); + scrollView.Layout(new Graphics.Rect(0, 0, 0, 0)); + + Assert.Single(handler.ScrollToRequests); + scrollView.SendScrollFinished(); + Assert.True(task.IsCompleted); + } + + [Fact] + public void ElementTargetsAccountForViewportAndContentCoordinateInsets() + { + var item = new View(); + var layout = new StackLayout { Children = { item } }; + var scrollView = new ScrollView { Content = layout }; + + // Total obscured insets shrink the viewport; the content-coordinate part is padding + // the platform baked into the content, which element positions already include + var handler = new ViewportProviderHandlerStub + { + ViewportInsets = new Thickness(0, 70, 0, 50), + ContentCoordinateInsets = new Thickness(0, 10, 0, 10), + }; + scrollView.Handler = handler; + + item.Layout(new Graphics.Rect(0, 450, 100, 50)); + layout.Layout(new Graphics.Rect(0, 0, 100, 1000)); + scrollView.Layout(new Graphics.Rect(0, 0, 100, 300)); + + // Start aligns the element with the visible viewport top: the 10 of baked padding in + // its coordinates must be shifted back out + Assert.Equal(440, scrollView.GetScrollPositionForElement(item, ScrollToPosition.Start).Y); + + // End: 450 - (300 - 70 - 50) + 50 = 320, shifted by the baked 10 + Assert.Equal(310, scrollView.GetScrollPositionForElement(item, ScrollToPosition.End).Y); + + // Center: 450 - 180 / 2 + 25 = 385, shifted by the baked 10 + Assert.Equal(375, scrollView.GetScrollPositionForElement(item, ScrollToPosition.Center).Y); + + // The element is below the visible window, so MakeVisible resolves to End + Assert.Equal(310, scrollView.GetScrollPositionForElement(item, ScrollToPosition.MakeVisible).Y); + } + + [Fact] + public void ElementRequestWithHandlerAttachedWaitsForArrange() + { + var item = new View(); + var layout = new StackLayout { Children = { item } }; + var scrollView = new ScrollView { Content = layout }; + + var handler = new ViewportProviderHandlerStub(); + scrollView.Handler = handler; + + var eventCount = 0; + ((IScrollViewController)scrollView).ScrollToRequested += (_, _) => eventCount++; + + // The handler exists but nothing is arranged yet (Width/Height are the -1 + // never-arranged sentinels): resolving the element target now would compute + // against garbage geometry, so the request must wait for layout + var task = scrollView.ScrollToAsync(item, ScrollToPosition.Center, false); + Assert.Empty(handler.ScrollToRequests); + Assert.Equal(1, eventCount); + + item.Layout(new Graphics.Rect(0, 450, 100, 50)); + layout.Layout(new Graphics.Rect(0, 0, 100, 1000)); + scrollView.Layout(new Graphics.Rect(0, 0, 100, 100)); + + // Dispatched once with a target computed from real geometry: 450 - 100/2 + 50/2. + // The public event must not be raised a second time on the replay — subscribers + // were already notified when the request was made. + var request = Assert.Single(handler.ScrollToRequests); + Assert.Equal(425, request.VerticalOffset); + Assert.Equal(1, eventCount); + + scrollView.SendScrollFinished(); + Assert.True(task.IsCompleted); + } + + [Fact] + public void DeferredRequestReplaysEventForSubscribersAttachedWithTheHandler() + { + var scrollView = new ScrollView(); + var task = scrollView.ScrollToAsync(10, 20, false); + + // Compatibility renderers subscribe to ScrollToRequested when they attach — + // after the request above was parked — and perform the scroll from the event, + // so the replay must re-raise it for them + var eventCount = 0; + ((IScrollViewController)scrollView).ScrollToRequested += (_, _) => eventCount++; + + var handler = new ViewportProviderHandlerStub(); + scrollView.Handler = handler; + + Assert.Equal(1, eventCount); + var request = Assert.Single(handler.ScrollToRequests); + Assert.Equal(20, request.VerticalOffset); + + scrollView.SendScrollFinished(); + Assert.True(task.IsCompleted); + } + + [Fact] + public void InsetRefreshUpdatesOffsetsWithoutRaisingScrolled() + { + var scrollView = new ScrollView(); + var scrolledCount = 0; + scrollView.Scrolled += (_, _) => scrolledCount++; + + // An inset-only change moves the derived offsets without any scroll: the values + // (and their bindings) must refresh, but no Scrolled event may be manufactured + ((IScrollOffsetReceiver)scrollView).UpdateScrollOffsets(5, 40); + + Assert.Equal(5, scrollView.ScrollX); + Assert.Equal(40, scrollView.ScrollY); + Assert.Equal(0, scrolledCount); + + // An actual scroll notification still raises Scrolled + ((IScrollView)scrollView).VerticalOffset = 60; + + Assert.Equal(60, scrollView.ScrollY); + Assert.Equal(1, scrolledCount); + } + + // IScrollViewportProvider is internal to Core, which NSubstitute cannot proxy, so the + // viewport contract is stubbed by hand here. + class ViewportProviderHandlerStub : IViewHandler, Microsoft.Maui.Handlers.IScrollViewportProvider + { + public System.Collections.Generic.List ScrollToRequests { get; } = new(); + + public Thickness ViewportInsets { get; set; } + public Thickness ContentCoordinateInsets { get; set; } + public void NotifyInsetsChanged() { } + + public bool HasContainer { get; set; } + public object ContainerView => null; + public IView VirtualView { get; private set; } + IElement IElementHandler.VirtualView => VirtualView; + public object PlatformView => null; + public IMauiContext MauiContext => null; + + public Graphics.Size GetDesiredSize(double widthConstraint, double heightConstraint) => Graphics.Size.Zero; + public void PlatformArrange(Graphics.Rect frame) { } + public void SetMauiContext(IMauiContext mauiContext) { } + public void SetVirtualView(IElement view) => VirtualView = (IView)view; + public void UpdateValue(string property) { } + public void DisconnectHandler() { } + + public void Invoke(string command, object args = null) + { + if (command == nameof(IScrollView.RequestScrollTo) && args is ScrollToRequest request) + ScrollToRequests.Add(request); + } + } + void AssertInvalidated(IViewHandler handler) { handler.Received().Invoke(Arg.Is(nameof(IView.InvalidateMeasure)), Arg.Any()); diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs new file mode 100644 index 000000000000..0a3ee58aa362 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs @@ -0,0 +1,282 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 36801, "iOS ScrollToAsync clamps without AdjustedContentInset", PlatformAffected.iOS)] +public class Issue36801 : ContentPage +{ + readonly ScrollView _scrollView; + readonly VerticalStackLayout _content; + readonly Label _probeLabel; + readonly Label _endResultLabel; + readonly Label _topResultLabel; + readonly Label _elementResultLabel; + readonly Label _deferredResultLabel; + + public Issue36801() + { + NavigationPage.SetHasNavigationBar(this, false); + SafeAreaEdges = SafeAreaEdges.None; + + _endResultLabel = new Label { Text = "EndPending", AutomationId = "EndResultLabel" }; + _topResultLabel = new Label { Text = "TopPending", AutomationId = "TopResultLabel" }; + _elementResultLabel = new Label { Text = "ElementPending", AutomationId = "ElementResultLabel" }; + _deferredResultLabel = new Label { Text = "DeferredPending", AutomationId = "DeferredResultLabel" }; + + var scrollToEndButton = new Button { Text = "Scroll to end", AutomationId = "ScrollToEndButton" }; + scrollToEndButton.Clicked += async (sender, e) => + { + _endResultLabel.Text = "EndPending"; + await _scrollView.ScrollToAsync(0, _scrollView.ContentSize.Height, animated: false); + await EvaluateUntilSuccess(_endResultLabel, () => EvaluateOffset(expectEnd: true, kind: "end")); + }; + + var scrollToTopButton = new Button { Text = "Scroll to top", AutomationId = "ScrollToTopButton" }; + scrollToTopButton.Clicked += async (sender, e) => + { + _topResultLabel.Text = "TopPending"; + await _scrollView.ScrollToAsync(0, 0, animated: false); + await EvaluateUntilSuccess(_topResultLabel, () => EvaluateOffset(expectEnd: false, kind: "top")); + }; + + var scrollToProbeButton = new Button { Text = "Scroll to probe (End)", AutomationId = "ScrollToProbeButton" }; + scrollToProbeButton.Clicked += async (sender, e) => + { + _elementResultLabel.Text = "ElementPending"; + await _scrollView.ScrollToAsync(_probeLabel, ScrollToPosition.End, animated: false); + await EvaluateUntilSuccess(_elementResultLabel, EvaluateElementEnd); + }; + + _content = new VerticalStackLayout { Padding = 16, Spacing = 6 }; + + // Spacer so the scrollable content starts below the floating header + _content.Add(new BoxView { HeightRequest = 220, Color = Colors.Transparent }); + for (int i = 0; i < 60; i++) + { + _content.Add(new Label { Text = $"Filler {i}", HeightRequest = 30 }); + } + + _probeLabel = new Label + { + Text = "BOTTOM PROBE", + AutomationId = "ProbeLabel", + FontAttributes = FontAttributes.Bold + }; + _content.Add(_probeLabel); + + // Set the mode on the ScrollView itself: SafeAreaEdges does not propagate from the + // page, and the ScrollView's own value is what selects ContentInsetAdjustmentBehavior. + _scrollView = new ScrollView { Content = _content, SafeAreaEdges = SafeAreaEdges.Default }; + + var modeDefaultButton = new Button { Text = "Mode Default", AutomationId = "ModeDefaultButton" }; + modeDefaultButton.Clicked += (sender, e) => SetMode(SafeAreaEdges.Default); + + var modeNoneButton = new Button { Text = "Mode None", AutomationId = "ModeNoneButton" }; + modeNoneButton.Clicked += (sender, e) => SetMode(SafeAreaEdges.None); + + // SafeAreaEdges.All also resolves to Never, but unlike None it makes MauiScrollView + // bake the safe area into ContentSize — the case ScrollableContentSize reasons about + var modeAllButton = new Button { Text = "Mode All", AutomationId = "ModeAllButton" }; + modeAllButton.Clicked += (sender, e) => SetMode(SafeAreaEdges.All); + + var modeContainerButton = new Button { Text = "Mode Container", AutomationId = "ModeContainerButton" }; + // SafeAreaEdges.Container is internal, so build the same value from the public enum. + // SafeAreaEdges.All would not do: it is mapped to Never, not Always. + modeContainerButton.Clicked += (sender, e) => SetMode(new SafeAreaEdges(SafeAreaRegions.Container)); + + // Floating header keeps the buttons and result labels visible and tappable + // regardless of the scroll position. + var header = new VerticalStackLayout + { + Padding = new Thickness(16, 60, 16, 8), + Spacing = 6, + BackgroundColor = Colors.LightGray, + VerticalOptions = LayoutOptions.Start, + Children = + { + modeDefaultButton, modeNoneButton, modeAllButton, modeContainerButton, + scrollToEndButton, scrollToTopButton, scrollToProbeButton, + _endResultLabel, _topResultLabel, _elementResultLabel, _deferredResultLabel + } + }; + + Content = new Grid { AutomationId = "PageRoot", Children = { _scrollView, header } }; + +#if IOS || MACCATALYST + // Give the native scroll view explicit content insets so it has a non-zero + // AdjustedContentInset regardless of how the host positions the page relative + // to the system chrome. This mirrors the issue's "custom chrome" scenario + // (AdditionalSafeAreaInsets) and deterministically exposes the clamping math: + // the valid native offset range becomes [-60, ContentSize + 40 - Bounds.Height]. + // Applied from HandlerChanged rather than Loaded so the inset is in place before the + // first layout pass, which is what drains the deferred scroll request — otherwise the + // scroll could be clamped against a different inset than the one asserted against. + _scrollView.HandlerChanged += (sender, e) => + { + if (_scrollView.Handler?.PlatformView is UIKit.UIScrollView nativeScrollView) + { + nativeScrollView.ContentInset = new UIKit.UIEdgeInsets(60, 0, 40, 0); + } + }; +#endif + + // Issue a request before any handler or layout exists so it travels through the + // deferred PendingScrollToRequest drain in the first layout pass, where the adjusted + // insets may still be stale (the #35395 OnAppearing scenario). + RunDeferredScroll(); + } + + void SetMode(SafeAreaEdges edges) + { + _scrollView.SafeAreaEdges = edges; + + // Reset the results so a stale Success from the previous mode can't be read as this + // mode's outcome + _endResultLabel.Text = "EndPending"; + _topResultLabel.Text = "TopPending"; + _elementResultLabel.Text = "ElementPending"; + } + + async void RunDeferredScroll() + { + await _scrollView.ScrollToAsync(0, 100000, animated: false); + await EvaluateUntilSuccess(_deferredResultLabel, () => EvaluateOffset(expectEnd: true, kind: "deferred")); + } + + // Re-evaluates until the offset settles on the expected value so the UI test's polling + // wait can converge instead of freezing a single too-early sample. + static async Task EvaluateUntilSuccess(Label label, Func evaluate) + { + for (int attempt = 0; attempt < 20; attempt++) + { + var result = evaluate(); + label.Text = result; + if (result.StartsWith("Success", StringComparison.Ordinal)) + { + return; + } + + await Task.Delay(250); + } + } + +#if IOS || MACCATALYST + // The whole clamp has a mode-specific branch, so a fixture that silently resolved to a + // different ContentInsetAdjustmentBehavior than intended would test the wrong one. + string CheckResolvedMode(UIKit.UIScrollView nativeScrollView, string kind) + { + var edges = _scrollView.SafeAreaEdges; + var expected = + edges.Equals(new SafeAreaEdges(SafeAreaRegions.Container)) ? UIKit.UIScrollViewContentInsetAdjustmentBehavior.Always : + edges.Equals(SafeAreaEdges.None) || edges.Equals(SafeAreaEdges.All) ? UIKit.UIScrollViewContentInsetAdjustmentBehavior.Never : + // Default on a vertical scroll view resolves to Never since the landscape-notch + // fix (#35533): MAUI owns all edges there, and Automatic remains in use only for + // horizontal scroll views + UIKit.UIScrollViewContentInsetAdjustmentBehavior.Never; + + return nativeScrollView.ContentInsetAdjustmentBehavior == expected + ? null + : $"Fail ({kind}): SafeAreaEdges={edges} resolved to " + + $"{nativeScrollView.ContentInsetAdjustmentBehavior}, expected {expected}"; + } +#endif + + string EvaluateOffset(bool expectEnd, string kind) + { +#if IOS || MACCATALYST + if (_scrollView.Handler?.PlatformView is not UIKit.UIScrollView nativeScrollView) + { + return $"Fail ({kind}): native scroll view unavailable"; + } + + if (_content.Handler?.PlatformView is not UIKit.UIView contentView) + { + return $"Fail ({kind}): content platform view unavailable"; + } + + if (CheckResolvedMode(nativeScrollView, kind) is string modeFailure) + { + return modeFailure; + } + + var adjustedInset = nativeScrollView.AdjustedContentInset; + if (adjustedInset.Top + adjustedInset.Bottom <= 0) + { + // Without insets the buggy and correct math coincide and the test proves nothing + return $"Fail ({kind}): scenario invalid, no adjusted content insets " + + $"(behavior={nativeScrollView.ContentInsetAdjustmentBehavior}, " + + $"contentInset=({nativeScrollView.ContentInset.Top:F0},{nativeScrollView.ContentInset.Bottom:F0}))"; + } + + // Independent oracle: measure where the content platform view actually sits inside the + // scroll view instead of re-deriving the implementation's ContentSize arithmetic. + // At the end, the content's last pixel must rest exactly at the bottom of the + // unobscured viewport; at the top, the offset must be the natural rest position. + double expected = expectEnd + ? (double)(contentView.Frame.Bottom + adjustedInset.Bottom - nativeScrollView.Bounds.Height) + : -(double)adjustedInset.Top; + double actual = (double)nativeScrollView.ContentOffset.Y; + + if (Math.Abs(actual - expected) > 1.5) + { + return $"Fail ({kind}): actual={actual:F1} expected={expected:F1}"; + } + + // Also pin the public contract: ScrollY is published in cross-platform content + // coordinates, i.e. the native offset shifted by the adjusted inset (0 at rest) + double expectedScrollY = expected + (double)adjustedInset.Top; + if (Math.Abs(_scrollView.ScrollY - expectedScrollY) > 1.5) + { + return $"Fail ({kind}): ScrollY={_scrollView.ScrollY:F1} expected={expectedScrollY:F1}"; + } + + return $"Success ({kind}): mode={nativeScrollView.ContentInsetAdjustmentBehavior} offset={actual:F1} scrollY={_scrollView.ScrollY:F1}"; +#else + return $"Skipped ({kind}): not applicable on this platform"; +#endif + } + + string EvaluateElementEnd() + { +#if IOS || MACCATALYST + if (_scrollView.Handler?.PlatformView is not UIKit.UIScrollView nativeScrollView) + { + return "Fail (element): native scroll view unavailable"; + } + + if (_probeLabel.Handler?.PlatformView is not UIKit.UIView probeView) + { + return "Fail (element): probe platform view unavailable"; + } + + if (CheckResolvedMode(nativeScrollView, "element") is string modeFailure) + { + return modeFailure; + } + + var adjustedInset = nativeScrollView.AdjustedContentInset; + if (adjustedInset.Top + adjustedInset.Bottom <= 0) + { + return "Fail (element): scenario invalid, no adjusted content insets"; + } + + // Independent geometric oracle: after ScrollToAsync(probe, End) the probe's bottom edge + // must sit exactly at the bottom of the unobscured viewport, in window coordinates. + // In the mode where MauiScrollView applies the safe area itself (SafeAreaEdges.All maps + // to Never with the safe area baked into the content) the obscured bottom never appears + // in AdjustedContentInset; the view-level SafeAreaInsets is what the platform view baked + // in, so it obscures the viewport all the same. + double bakedBottom = _scrollView.SafeAreaEdges.Equals(SafeAreaEdges.All) + ? (double)nativeScrollView.SafeAreaInsets.Bottom + : 0; + var probeInWindow = probeView.ConvertRectToView(probeView.Bounds, null); + var scrollInWindow = nativeScrollView.ConvertRectToView(nativeScrollView.Bounds, null); + double visibleBottom = (double)(scrollInWindow.Bottom - adjustedInset.Bottom) - bakedBottom; + double actual = (double)probeInWindow.Bottom; + + return Math.Abs(actual - visibleBottom) <= 1.5 + ? $"Success (element): mode={nativeScrollView.ContentInsetAdjustmentBehavior} bottom={actual:F1}" + : $"Fail (element): actual={actual:F1} expected={visibleBottom:F1}"; +#else + return "Skipped (element): not applicable on this platform"; +#endif + } +} diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801DeferredElement.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801DeferredElement.cs new file mode 100644 index 000000000000..7557fa8a9a92 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801DeferredElement.cs @@ -0,0 +1,107 @@ +namespace Maui.Controls.Sample.Issues; + +// Covers the deferred *element-mode* request: ScrollToAsync(element, End) issued before the +// handler exists. The target is resolved against the ScrollView's own geometry, so converting +// it at handler-attach — before the first layout, when Width/Height are still -1 — produces a +// target computed from invalid geometry. It has to wait for layout. +[Issue(IssueTracker.None, 0, "Deferred element-mode ScrollToAsync", PlatformAffected.iOS)] +public class Issue36801DeferredElement : ContentPage +{ + readonly ScrollView _scrollView; + readonly Label _probeLabel; + readonly Label _resultLabel; + + public Issue36801DeferredElement() + { + NavigationPage.SetHasNavigationBar(this, false); + + _resultLabel = new Label { Text = "Pending", AutomationId = "ResultLabel" }; + + var content = new VerticalStackLayout { Padding = 16, Spacing = 6 }; + content.Add(new BoxView { HeightRequest = 220, Color = Colors.Transparent }); + for (int i = 0; i < 60; i++) + { + content.Add(new Label { Text = $"Filler {i}", HeightRequest = 30 }); + } + + _probeLabel = new Label + { + Text = "BOTTOM PROBE", + AutomationId = "ProbeLabel", + FontAttributes = FontAttributes.Bold + }; + content.Add(_probeLabel); + + _scrollView = new ScrollView { Content = content, SafeAreaEdges = SafeAreaEdges.Default }; + + var header = new VerticalStackLayout + { + Padding = new Thickness(16, 60, 16, 8), + BackgroundColor = Colors.LightGray, + VerticalOptions = LayoutOptions.Start, + Children = { _resultLabel } + }; + + Content = new Grid { Children = { _scrollView, header } }; + +#if IOS || MACCATALYST + _scrollView.HandlerChanged += (sender, e) => + { + if (_scrollView.Handler?.PlatformView is UIKit.UIScrollView nativeScrollView) + { + nativeScrollView.ContentInset = new UIKit.UIEdgeInsets(60, 0, 40, 0); + } + }; +#endif + + // Issued before any handler or layout exists + RunDeferredElementScroll(); + } + + async void RunDeferredElementScroll() + { + await _scrollView.ScrollToAsync(_probeLabel, ScrollToPosition.End, animated: false); + + for (int attempt = 0; attempt < 20; attempt++) + { + var result = Evaluate(); + _resultLabel.Text = result; + if (result.StartsWith("Success", StringComparison.Ordinal)) + { + return; + } + + await Task.Delay(250); + } + } + + string Evaluate() + { +#if IOS || MACCATALYST + if (_scrollView.Handler?.PlatformView is not UIKit.UIScrollView nativeScrollView || + _probeLabel.Handler?.PlatformView is not UIKit.UIView probeView) + { + return "Fail: platform views unavailable"; + } + + var adjustedInset = nativeScrollView.AdjustedContentInset; + if (adjustedInset.Top + adjustedInset.Bottom <= 0) + { + return "Fail: scenario invalid, no adjusted content insets"; + } + + // Same geometric oracle as the non-deferred element test: the probe's bottom edge must + // rest exactly at the bottom of the unobscured viewport + var probeInWindow = probeView.ConvertRectToView(probeView.Bounds, null); + var scrollInWindow = nativeScrollView.ConvertRectToView(nativeScrollView.Bounds, null); + double visibleBottom = (double)(scrollInWindow.Bottom - adjustedInset.Bottom); + double actual = (double)probeInWindow.Bottom; + + return Math.Abs(actual - visibleBottom) <= 1.5 + ? $"Success: bottom={actual:F1}" + : $"Fail: actual={actual:F1} expected={visibleBottom:F1}"; +#else + return "Skipped: not applicable on this platform"; +#endif + } +} diff --git a/src/Controls/tests/TestCases.HostApp/Issues/ShellFlyoutHeaderScrollViewContent.cs b/src/Controls/tests/TestCases.HostApp/Issues/ShellFlyoutHeaderScrollViewContent.cs new file mode 100644 index 000000000000..23ffff4e1549 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/ShellFlyoutHeaderScrollViewContent.cs @@ -0,0 +1,139 @@ +namespace Maui.Controls.Sample.Issues; + +// A Shell whose FlyoutContent is a ScrollView exercises ShellFlyoutLayoutManager's +// ScrollView branch, which feeds ScrollView.ScrollY into the header layout math. That math +// works in native offsets (which rest at -headerHeight because the header is carried in +// ContentInset), so it has to convert back from the content coordinates ScrollY reports. +// The default flyout uses a UITableView and a different code path, so it does not cover this. +[Issue(IssueTracker.None, 0, "Shell flyout header with ScrollView flyout content", PlatformAffected.iOS)] +public class ShellFlyoutHeaderScrollViewContent : TestShell +{ + const double HeaderHeight = 150; + + readonly Label _resultLabel = new() { Text = "Pending", AutomationId = "ResultLabel" }; + ScrollView _flyoutScroll; + Grid _headerGrid; + + protected override void Init() + { + Shell.SetFlyoutBehavior(this, FlyoutBehavior.Locked); + FlyoutHeaderBehavior = FlyoutHeaderBehavior.Scroll; + + _headerGrid = new Grid + { + HeightRequest = HeaderHeight, + BackgroundColor = Colors.MediumPurple, + AutomationId = "FlyoutHeaderId", + Children = + { + new Label + { + Text = "FLYOUT HEADER", + TextColor = Colors.White, + HorizontalTextAlignment = TextAlignment.Center, + VerticalTextAlignment = TextAlignment.Center + } + } + }; + + FlyoutHeader = _headerGrid; + + var rows = new VerticalStackLayout(); + for (int i = 0; i < 40; i++) + { + rows.Add(new Label { Text = $"Flyout row {i}", HeightRequest = 40 }); + } + + _flyoutScroll = new ScrollView { Content = rows, AutomationId = "FlyoutScroll" }; + FlyoutContent = _flyoutScroll; + + var runButton = new Button { Text = "Scroll flyout and return", AutomationId = "RunButton" }; + runButton.Clicked += async (sender, e) => await RunAsync(); + + AddFlyoutItem(new ContentPage + { + Content = new VerticalStackLayout + { + Padding = 24, + Spacing = 12, + Children = { runButton, _resultLabel } + } + }, "Item"); + } + + async Task RunAsync() + { + _resultLabel.Text = "Pending"; + + // Scroll the flyout content and come back to the top. The round trip guarantees the + // header layout is driven by a Scrolled event (the ScrollView branch) rather than the + // direct native call that also runs during initial layout. + await _flyoutScroll.ScrollToAsync(0, 200, animated: false); + await Task.Delay(150); + + // Assert the scrolled-away state too. The returned-to-top state alone is also what a + // header that never moved would report (_headerOffset starts at 0), so without this + // the test would pass even if the Scrolled wiring went dead entirely. + var scrolledAway = EvaluateHeaderScrolledAway(); + if (!scrolledAway.StartsWith("Success", StringComparison.Ordinal)) + { + _resultLabel.Text = scrolledAway; + return; + } + + await _flyoutScroll.ScrollToAsync(0, 0, animated: false); + await Task.Delay(150); + + _resultLabel.Text = EvaluateHeaderPosition(); + } + + // With FlyoutHeaderBehavior.Scroll the header must move up as the content scrolls down, + // which only happens if the ScrollView -> OnScrolled path actually ran + string EvaluateHeaderScrolledAway() + { +#if IOS || MACCATALYST + if (_headerGrid.Handler?.PlatformView is not UIKit.UIView headerView || + headerView.Superview is not UIKit.UIView headerContainer) + { + return "Fail: header views unavailable"; + } + + double headerY = headerContainer.Frame.Y; + + return headerY < 0 + ? $"Success: scrolled headerY={headerY:F1}" + : $"Fail: header did not move up while scrolled (headerY={headerY:F1})"; +#else + return "Skipped: not applicable on this platform"; +#endif + } + + string EvaluateHeaderPosition() + { +#if IOS || MACCATALYST + if (_headerGrid.Handler?.PlatformView is not UIKit.UIView headerView) + { + return "Fail: header platform view unavailable"; + } + + // The Shell renderer positions the container that wraps the header content, not the + // header content itself, so read the container's frame + if (headerView.Superview is not UIKit.UIView headerContainer) + { + return "Fail: header container unavailable"; + } + + // FlyoutHeaderBehavior.Scroll positions the header by its frame Y (the scroll offset, + // plus the safe area when honoured), so back at the top of the content the header must + // be fully visible again — never pushed above the flyout's top edge. Computing the + // header offset from the wrong coordinate space pushes it up by a full header height. + double headerY = headerContainer.Frame.Y; + + return headerY >= -1 + ? $"Success: headerY={headerY:F1} height={headerContainer.Frame.Height:F1}" + : $"Fail: headerY={headerY:F1}, expected the header to stay at or below the top"; +#else + return "Skipped: not applicable on this platform"; +#endif + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs new file mode 100644 index 000000000000..0c072373ceca --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs @@ -0,0 +1,124 @@ +#if IOS || MACCATALYST // Validates UIScrollView AdjustedContentInset math with platform instrumentation in the HostApp page +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue36801 : _IssuesUITest +{ + public Issue36801(TestDevice device) : base(device) { } + + public override string Issue => "iOS ScrollToAsync clamps without AdjustedContentInset"; + + [Test] + [Category(UITestCategories.ScrollView)] + public void ScrollToAsyncReachesInsetAwareExtremes() + { + App.WaitForElement("ScrollToEndButton"); + + // A ScrollToAsync issued before the first layout must also land on the inset-aware + // maximum once the deferred request drains and the adjusted insets settle + var deferredSuccess = App.WaitForTextToBePresentInElement("DeferredResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var deferredText = App.FindElement("DeferredResultLabel").GetText(); + Assert.That(deferredSuccess, Is.True, $"Deferred (pre-layout) scroll to end did not settle on the inset-aware maximum: {deferredText}"); + + App.Tap("ScrollToEndButton"); + + // The page measures where the content platform view actually rests: its last pixel + // must sit exactly at the bottom of the unobscured viewport + var endSuccess = App.WaitForTextToBePresentInElement("EndResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var endText = App.FindElement("EndResultLabel").GetText(); + Assert.That(endSuccess, Is.True, $"Scroll to end did not reach the inset-aware maximum: {endText}"); + + // Independent user-visible oracle: the probe label at the very end of the content + // must now be fully inside the page, above the bottom edge + var probeRect = App.WaitForElement("ProbeLabel").GetRect(); + var pageRect = App.WaitForElement("PageRoot").GetRect(); + Assert.That(probeRect.Y, Is.GreaterThanOrEqualTo(pageRect.Y), "Probe label should be inside the page after scrolling to end"); + Assert.That(probeRect.Y + probeRect.Height, Is.LessThanOrEqualTo(pageRect.Y + pageRect.Height + 1), + "Probe label should be fully visible above the bottom edge after scrolling to end"); + + App.Tap("ScrollToTopButton"); + + // Scrolling back to 0 must land on the natural rest position (-AdjustedContentInset.Top) + var topSuccess = App.WaitForTextToBePresentInElement("TopResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var topText = App.FindElement("TopResultLabel").GetText(); + Assert.That(topSuccess, Is.True, $"Scroll to top did not land on the rest position: {topText}"); + } + + [Test] + [Category(UITestCategories.ScrollView)] + public void ScrollToElementEndLandsInsideVisibleViewport() + { + App.WaitForElement("ScrollToProbeButton"); + App.Tap("ScrollToProbeButton"); + + // The page asserts, in window coordinates, that the probe's bottom edge rests exactly + // at the bottom of the unobscured viewport (frame bottom minus AdjustedContentInset.Bottom) + var elementSuccess = App.WaitForTextToBePresentInElement("ElementResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var elementText = App.FindElement("ElementResultLabel").GetText(); + Assert.That(elementSuccess, Is.True, $"ScrollToAsync(element, End) did not align the element with the visible viewport bottom: {elementText}"); + + var probeRect = App.WaitForElement("ProbeLabel").GetRect(); + var pageRect = App.WaitForElement("PageRoot").GetRect(); + Assert.That(probeRect.Y + probeRect.Height, Is.LessThanOrEqualTo(pageRect.Y + pageRect.Height + 1), + "Probe label should be fully visible after ScrollToAsync(element, End)"); + } + + // The reachable ContentInsetAdjustmentBehavior modes bake the safe area into the content + // differently, so each is exercised. The page asserts the resolved native behavior, so a + // mode that silently drifted fails instead of quietly testing a different branch. + // Default on a vertical scroll view resolves to Never since the landscape-notch fix + // (#35533); Automatic remains in use only for horizontal scroll views. + [Test] + [Category(UITestCategories.ScrollView)] + [TestCase("ModeDefaultButton", "Never")] + [TestCase("ModeNoneButton", "Never")] + // Also resolves to Never, but bakes the safe area into the arranged content, which None + // does not — so it is the case that actually exercises the measured extent's baked padding + [TestCase("ModeAllButton", "Never")] + [TestCase("ModeContainerButton", "Always")] + public void ScrollToExtremesInEachInsetMode(string modeButton, string expectedMode) + { + App.WaitForElement(modeButton); + App.Tap(modeButton); + + App.Tap("ScrollToEndButton"); + var endSuccess = App.WaitForTextToBePresentInElement("EndResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var endText = App.FindElement("EndResultLabel").GetText(); + Assert.That(endSuccess, Is.True, $"[{expectedMode}] scroll to end did not reach the inset-aware maximum: {endText}"); + Assert.That(endText, Does.Contain($"mode={expectedMode}"), $"[{expectedMode}] resolved to a different inset mode: {endText}"); + + App.Tap("ScrollToTopButton"); + var topSuccess = App.WaitForTextToBePresentInElement("TopResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var topText = App.FindElement("TopResultLabel").GetText(); + Assert.That(topSuccess, Is.True, $"[{expectedMode}] scroll to top did not land on the rest position: {topText}"); + } + + // Element targets resolve against the effective viewport, and each inset mode obscures it + // differently: Always through AdjustedContentInset, Default/SafeAreaEdges.All by baking + // the safe area into the content where AdjustedContentInset never reports it. The page's + // oracle measures the probe's bottom edge against the unobscured viewport bottom in window + // coordinates, so the mode where MAUI itself obscures the viewport is proven too. + [Test] + [Category(UITestCategories.ScrollView)] + [TestCase("ModeDefaultButton", "Never")] + [TestCase("ModeNoneButton", "Never")] + // Also resolves to Never, but bakes the safe area into the content — the case where the + // viewport shrink comes from MAUI's own arrange instead of a UIKit inset + [TestCase("ModeAllButton", "Never")] + [TestCase("ModeContainerButton", "Always")] + public void ScrollToElementEndInEachInsetMode(string modeButton, string expectedMode) + { + App.WaitForElement(modeButton); + App.Tap(modeButton); + + App.Tap("ScrollToProbeButton"); + var elementSuccess = App.WaitForTextToBePresentInElement("ElementResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var elementText = App.FindElement("ElementResultLabel").GetText(); + Assert.That(elementSuccess, Is.True, $"[{expectedMode}] ScrollToAsync(element, End) did not align the element with the visible viewport bottom: {elementText}"); + Assert.That(elementText, Does.Contain($"mode={expectedMode}"), $"[{expectedMode}] resolved to a different inset mode: {elementText}"); + } +} +#endif diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801DeferredElement.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801DeferredElement.cs new file mode 100644 index 000000000000..fb21afd70eb7 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801DeferredElement.cs @@ -0,0 +1,27 @@ +#if IOS || MACCATALYST // Validates UIScrollView AdjustedContentInset math with platform instrumentation in the HostApp page +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue36801DeferredElement : _IssuesUITest +{ + public Issue36801DeferredElement(TestDevice device) : base(device) { } + + public override string Issue => "Deferred element-mode ScrollToAsync"; + + [Test] + [Category(UITestCategories.ScrollView)] + public void DeferredElementScrollLandsInsideVisibleViewport() + { + App.WaitForElement("ResultLabel"); + + // The page issues ScrollToAsync(probe, End) before the handler exists; the target can + // only be resolved once layout has given the ScrollView its geometry. + var success = App.WaitForTextToBePresentInElement("ResultLabel", "Success", timeout: TimeSpan.FromSeconds(15)); + var resultText = App.FindElement("ResultLabel").GetText(); + Assert.That(success, Is.True, $"Deferred element scroll did not align with the viewport bottom: {resultText}"); + } +} +#endif diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/ShellFlyoutHeaderScrollViewContent.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/ShellFlyoutHeaderScrollViewContent.cs new file mode 100644 index 000000000000..e764b3b670b7 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/ShellFlyoutHeaderScrollViewContent.cs @@ -0,0 +1,31 @@ +#if IOS || MACCATALYST // The header offset is read from the iOS platform view in the HostApp page +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class ShellFlyoutHeaderScrollViewContent : _IssuesUITest +{ + public ShellFlyoutHeaderScrollViewContent(TestDevice testDevice) : base(testDevice) + { + } + + public override string Issue => "Shell flyout header with ScrollView flyout content"; + + [Test] + [Category(UITestCategories.Shell)] + public void FlyoutHeaderReturnsToTopWhenScrollViewContentScrollsBack() + { + App.WaitForElement("RunButton"); + App.Tap("RunButton"); + + // The page scrolls the flyout ScrollView down and back to the top, then reports the + // header's platform frame offset — which must be 0 again once the content is back + // at the top under FlyoutHeaderBehavior.Scroll. + var success = App.WaitForTextToBePresentInElement("ResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var resultText = App.FindElement("ResultLabel").GetText(); + Assert.That(success, Is.True, $"Flyout header did not return to the top: {resultText}"); + } +} +#endif diff --git a/src/Core/src/Core/IScrollOffsetReceiver.cs b/src/Core/src/Core/IScrollOffsetReceiver.cs new file mode 100644 index 000000000000..5738596c8170 --- /dev/null +++ b/src/Core/src/Core/IScrollOffsetReceiver.cs @@ -0,0 +1,21 @@ +namespace Microsoft.Maui +{ + /// + /// Implemented by scroll views whose reported offsets are derived from platform insets: + /// when the insets change without any scrolling, the derived offsets move too, and the + /// handler refreshes them through this path so the update is not mistaken for a scroll. + /// + /// + /// The / + /// setters signal "the view scrolled" and views typically raise their scrolled notification + /// from them; an inset-only change must keep the offset values current without + /// manufacturing that notification (issue #36801). + /// + internal interface IScrollOffsetReceiver + { + /// + /// Updates the reported scroll offsets without treating the change as a scroll. + /// + void UpdateScrollOffsets(double horizontalOffset, double verticalOffset); + } +} diff --git a/src/Core/src/Handlers/ScrollView/IScrollViewportProvider.cs b/src/Core/src/Handlers/ScrollView/IScrollViewportProvider.cs new file mode 100644 index 000000000000..616c211709d5 --- /dev/null +++ b/src/Core/src/Handlers/ScrollView/IScrollViewportProvider.cs @@ -0,0 +1,39 @@ +namespace Microsoft.Maui.Handlers +{ + /// + /// Implemented by scroll view handlers whose platform reduces the scrollable viewport + /// below the view's frame — on iOS/MacCatalyst the adjusted content insets obscure part + /// of the frame, so the visible viewport is smaller and (0,0) in cross-platform scroll + /// coordinates is the inset rest position. + /// + /// + /// The handler owns that coordinate convention; this is how the cross-platform layer and + /// the platform view consume it instead of re-deriving inset knowledge of their own. + /// Handlers on platforms where the viewport always equals the frame do not implement it. + /// + internal interface IScrollViewportProvider + { + /// + /// The total insets obscuring the scrollable viewport, in cross-platform units: + /// the platform-applied content insets plus any safe area the platform view baked + /// into the content itself (see ). + /// + Thickness ViewportInsets { get; } + + /// + /// The portion of the platform view baked into the + /// content as padding instead of applying it as a platform inset. Element positions + /// read from the content tree already include it, so element-relative targets must + /// shift by it; platform insets instead sit outside the content coordinate space and + /// are compensated when the request is translated to a native offset. + /// + Thickness ContentCoordinateInsets { get; } + + /// + /// Notifies the handler that the platform view's insets changed. The reported scroll + /// offsets are derived from them, and insets can change without the offset moving — + /// which produces no scroll notification of its own. + /// + void NotifyInsetsChanged(); + } +} diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs index ef2013cf4a28..89da7aef634d 100644 --- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs +++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs @@ -7,12 +7,50 @@ namespace Microsoft.Maui.Handlers { - public partial class ScrollViewHandler : ViewHandler, ICrossPlatformLayout + public partial class ScrollViewHandler : ViewHandler, ICrossPlatformLayout, IScrollViewportProvider { readonly ScrollEventProxy _eventProxy = new(); internal ScrollToRequest? PendingScrollToRequest { get; private set; } + Thickness IScrollViewportProvider.ViewportInsets + { + get + { + if (PlatformView is not { } platformView) + { + return default; + } + + // The safe area MauiScrollView bakes into the content obscures the viewport just + // like a UIKit inset but never appears in AdjustedContentInset (issue #36801) + var inset = platformView.AdjustedContentInset; + var baked = GetSafeAreaBakedIntoContent(platformView); + return new Thickness( + inset.Left + baked.Left, + inset.Top + baked.Top, + inset.Right + baked.Right, + inset.Bottom + baked.Bottom); + } + } + + Thickness IScrollViewportProvider.ContentCoordinateInsets + { + get + { + if (PlatformView is not { } platformView) + { + return default; + } + + var baked = GetSafeAreaBakedIntoContent(platformView); + return new Thickness(baked.Left, baked.Top, baked.Right, baked.Bottom); + } + } + + static SafeAreaPadding GetSafeAreaBakedIntoContent(UIScrollView platformView) => + (platformView as MauiScrollView)?.SafeAreaBakedIntoContent ?? SafeAreaPadding.Empty; + protected override UIScrollView CreatePlatformView() { return new MauiScrollView(); @@ -127,16 +165,19 @@ public static void MapRequestScrollTo(IScrollViewHandler handler, IScrollView sc return; } - var availableScrollHeight = Math.Max(uiScrollView.ContentSize.Height - uiScrollView.Frame.Height, 0); - var availableScrollWidth = Math.Max(uiScrollView.ContentSize.Width - uiScrollView.Frame.Width, 0); - var minScrollHorizontal = Math.Clamp(request.HorizontalOffset, 0, availableScrollWidth); - var minScrollVertical = Math.Clamp(request.VerticalOffset, 0, availableScrollHeight); - - bool alreadyAtTarget = uiScrollView.ContentOffset.Y == minScrollVertical && uiScrollView.ContentOffset.X == minScrollHorizontal; + var target = GetTargetContentOffset(uiScrollView, request); + + // Compare at device-pixel resolution: UIKit rounds resting offsets to physical + // pixels while the inset-derived target is fractional, so an exact comparison can + // miss by a sub-pixel amount — and an animated SetContentOffset for a sub-pixel + // delta may never raise ScrollAnimationEnded, leaving the caller's task pending + // forever (there is no timeout anywhere in that chain). + var pixelTolerance = 1 / (uiScrollView.Window?.Screen ?? UIScreen.MainScreen).Scale; + bool alreadyAtTarget = uiScrollView.ContentOffset.IsCloseTo(target, pixelTolerance); if (!alreadyAtTarget) { - uiScrollView.SetContentOffset(new CGPoint(minScrollHorizontal, minScrollVertical), !request.Instant); + uiScrollView.SetContentOffset(target, !request.Instant); } if (request.Instant || alreadyAtTarget) @@ -146,6 +187,33 @@ public static void MapRequestScrollTo(IScrollViewHandler handler, IScrollView sc } } + // Cross-platform scroll coordinates treat (0,0) as the top of the content, but with content + // insets (e.g. a scroll view consuming the safe area) the native rest offset is + // (-adjustedInset.Left, -adjustedInset.Top) and the native maximum extends past + // ContentSize - Bounds by the trailing insets. Translate the request into native offset + // space and clamp against the inset-aware range (issue #36801). + static CGPoint GetTargetContentOffset(UIScrollView uiScrollView, ScrollToRequest request) + { + var adjustedInset = uiScrollView.AdjustedContentInset; + var bounds = uiScrollView.Bounds; + + // MauiScrollView reports the extent to clamp against, since only it knows when its + // arrange baked safe-area padding into ContentSize that UIKit is also applying + // through AdjustedContentInset + var contentSize = (uiScrollView as MauiScrollView)?.ScrollableContentSize ?? uiScrollView.ContentSize; + var contentWidth = (double)contentSize.Width; + var contentHeight = (double)contentSize.Height; + + var minScrollHorizontal = -(double)adjustedInset.Left; + var minScrollVertical = -(double)adjustedInset.Top; + var maxScrollHorizontal = Math.Max(minScrollHorizontal, contentWidth + adjustedInset.Right - bounds.Width); + var maxScrollVertical = Math.Max(minScrollVertical, contentHeight + adjustedInset.Bottom - bounds.Height); + + return new CGPoint( + Math.Clamp(request.HorizontalOffset - (double)adjustedInset.Left, minScrollHorizontal, maxScrollHorizontal), + Math.Clamp(request.VerticalOffset - (double)adjustedInset.Top, minScrollVertical, maxScrollVertical)); + } + static void UpdateContentView(IScrollView scrollView, IScrollViewHandler handler) { bool changed = false; @@ -253,19 +321,52 @@ void ScrollAnimationEnded(object? sender, EventArgs e) void Scrolled(object? sender, EventArgs e) { - if (VirtualView == null) + if (sender is UIScrollView platformView) { - return; + PublishScrollOffsets(VirtualView, platformView); } + } + } - if (sender is not UIScrollView platformView) - { - return; - } + void IScrollViewportProvider.NotifyInsetsChanged() + { + if (PlatformView is not { } platformView || VirtualView is not { } virtualView) + { + return; + } - VirtualView.HorizontalOffset = platformView.ContentOffset.X; - VirtualView.VerticalOffset = platformView.ContentOffset.Y; + var (horizontalOffset, verticalOffset) = GetContentCoordinateOffsets(platformView); + + // Nothing scrolled — the derived offsets moved because the inset did — so refresh + // the values without letting the view raise a scrolled notification for it + if (virtualView is IScrollOffsetReceiver receiver) + { + receiver.UpdateScrollOffsets(horizontalOffset, verticalOffset); } + else + { + virtualView.HorizontalOffset = horizontalOffset; + virtualView.VerticalOffset = verticalOffset; + } + } + + // Report offsets in cross-platform content coordinates: with content insets the native + // rest offset is (-adjustedInset.Left, -adjustedInset.Top), which maps to (0,0) + // cross-platform so ScrollToAsync(ScrollX, ScrollY, ...) round-trips (issue #36801). + static void PublishScrollOffsets(IScrollView? virtualView, UIScrollView platformView) + { + if (virtualView is null) + { + return; + } + + (virtualView.HorizontalOffset, virtualView.VerticalOffset) = GetContentCoordinateOffsets(platformView); + } + + static (double HorizontalOffset, double VerticalOffset) GetContentCoordinateOffsets(UIScrollView platformView) + { + var adjustedInset = platformView.AdjustedContentInset; + return (platformView.ContentOffset.X + adjustedInset.Left, platformView.ContentOffset.Y + adjustedInset.Top); } } } diff --git a/src/Core/src/Platform/iOS/CoreGraphicsExtensions.cs b/src/Core/src/Platform/iOS/CoreGraphicsExtensions.cs index dcd48d60b43d..c6026d5f3fe2 100644 --- a/src/Core/src/Platform/iOS/CoreGraphicsExtensions.cs +++ b/src/Core/src/Platform/iOS/CoreGraphicsExtensions.cs @@ -38,5 +38,10 @@ public static bool IsCloseTo(this CGSize size0, CGSize size1, nfloat tolerance) var diff = size0 - size1; return Math.Abs(diff.Width) < tolerance && Math.Abs(diff.Height) < tolerance; } + + internal static bool IsCloseTo(this CGPoint point0, CGPoint point1, nfloat tolerance) + { + return Math.Abs(point0.X - point1.X) < tolerance && Math.Abs(point0.Y - point1.Y) < tolerance; + } } } diff --git a/src/Core/src/Platform/iOS/MauiScrollView.cs b/src/Core/src/Platform/iOS/MauiScrollView.cs index 2c0e65193d0a..a1559e3df002 100644 --- a/src/Core/src/Platform/iOS/MauiScrollView.cs +++ b/src/Core/src/Platform/iOS/MauiScrollView.cs @@ -155,6 +155,11 @@ public override void AdjustedContentInsetDidChange() ((IPlatformMeasureInvalidationController)this).InvalidateMeasure(); this.InvalidateAncestorsMeasures(); } + + // UIKit does not raise Scrolled when the inset changes while the offset stays put + // (keyboard, rotation, an auto-hiding bar), and the reported scroll offsets are + // derived from the inset, so they would go stale by the delta + (CrossPlatformLayout as IScrollViewportProvider)?.NotifyInsetsChanged(); } /// @@ -444,6 +449,89 @@ bool ValidateSafeArea() (oldSafeArea.EqualsAtPixelLevel(_safeArea) || !_appliesSafeAreaAdjustments); } + /// + /// The content extent to clamp scrolling against: the trailing edge of the rect + /// actually arranged the content into, plus any + /// trailing safe-area padding it baked into the content coordinate space + /// (see ). + /// + /// + /// Measured from the recorded arrange rather than reconstructed from + /// , because that value is not authoritative: + /// depending on the inset mode the arrange pass pads it with a safe area UIKit is also + /// compensating through , inflates it to + /// keep UIKit in scrollable mode, or omits a non-zero safe-area origin the content was + /// arranged at. The arranged rect is correct in every mode (issue #36801). + /// + internal CGSize ScrollableContentSize + { + get + { + // Content has not been arranged through CrossPlatformArrange (e.g. ContentSize + // was mapped directly), so the content size is the only extent available. The + // nullable keeps this distinct from content legitimately arranged to a zero + // size at the origin, whose extent really is empty. + if (_arrangedContentRect is not { } arranged) + { + return ContentSize; + } + + var baked = SafeAreaBakedIntoContent; + var width = (double)arranged.Right + baked.Right; + var height = (double)arranged.Bottom + baked.Bottom; + + // Mirror the orientation clamp LayoutSubviews applies to ContentSize: an axis the + // ScrollView doesn't scroll must not become reachable just because the arranged + // content overflows it + if (View is IScrollView scrollView) + { + var frameSize = Bounds.Size; + var orientation = scrollView.Orientation; + + if (orientation is ScrollOrientation.Vertical && width > frameSize.Width) + { + width = frameSize.Width; + } + + if (orientation is ScrollOrientation.Horizontal && height > frameSize.Height) + { + height = frameSize.Height; + } + } + + return new CGSize(width, height); + } + } + + /// + /// The rect the last placed the content into: + /// origin is the (possibly safe-area-inset) position the content was arranged at and + /// size is the arranged content size before any + /// padding or scrollable-mode inflation is applied. Null until the first arrange runs. + /// + CGRect? _arrangedContentRect; + + /// + /// The safe area baked into the content's coordinate + /// space: when it applies the safe area while UIKit is not compensating through + /// , the content is arranged inside + /// safe-area-inset bounds, so element positions carry the padding and the trailing + /// padding still obscures the viewport without ever appearing in the adjusted inset. + /// + /// + /// Mirrors the arrange-side branch exactly: bounds are inset only while + /// _appliesSafeAreaAdjustments, and the inset origin is kept only when UIKit + /// contributes nothing (, + /// or a zero system inset). In the remaining case () + /// the content is re-based at the origin and UIKit's inset owns the compensation, so the + /// arranged rect measures naturally excludes it (issue #36801). + /// + internal SafeAreaPadding SafeAreaBakedIntoContent => + _appliesSafeAreaAdjustments && + (SystemAdjustedContentInset == UIEdgeInsets.Zero || ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Never) + ? _safeArea + : SafeAreaPadding.Empty; + UIEdgeInsets SystemAdjustedContentInset { get @@ -539,7 +627,7 @@ Size CrossPlatformArrange(CGRect bounds) Size contentSize; - + CGPoint contentOrigin; double width; double height; if (SystemAdjustedContentInset != UIEdgeInsets.Zero @@ -555,6 +643,7 @@ Size CrossPlatformArrange(CGRect bounds) ? 0 : bounds.X; contentSize = CrossPlatformLayout?.CrossPlatformArrange(new Rect(arrangeX, 0, bounds.Width, bounds.Height)) ?? Size.Zero; + contentOrigin = new CGPoint(arrangeX, 0); width = contentSize.Width; height = contentSize.Height; @@ -563,11 +652,16 @@ Size CrossPlatformArrange(CGRect bounds) { // Never CIAB (or zero ACI): MAUI fully controls safe area — apply full inset bounds. contentSize = CrossPlatformLayout?.CrossPlatformArrange(bounds.ToRectangle()) ?? Size.Zero; + contentOrigin = bounds.Location; width = contentSize.Width; height = contentSize.Height; } + // Record where the content was actually arranged, before the ContentSize adjustments + // below: ScrollableContentSize measures the scrollable extent from this rect + _arrangedContentRect = new CGRect(contentOrigin, contentSize.ToCGSize()); + // When using ContentInsetAdjustmentBehavior.Automatic, UIKit dynamically decides whether to apply // safe area insets to the scroll view (via AdjustedContentInset) or to push them into the child view's SafeAreaInsets.