[Android] Fix for RefreshView triggering pull-to-refresh when scrolling inside a WebView with internal scrollable content#34614
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://github.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 34614Or
iex "& { $(irm https://github.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 34614" |
There was a problem hiding this comment.
Pull request overview
This PR addresses Android-specific RefreshView behavior when wrapping a WebView whose visible scrolling happens inside an internal HTML overflow container (so native WebView scroll state can incorrectly indicate “at top”), causing pull-to-refresh to trigger too early.
Changes:
- Add an Android
WebViewJavaScript bridge + injected observer script to report whether touched DOM content can still scroll up. - Update
MauiSwipeRefreshLayoutto consult the reported “can scroll up” state forWebView(and add intercept logic for gestures starting inside aWebView). - Add a HostApp repro page and an Android UI test for issue #33510.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt | Records the new/changed Android public surface from overrides added in this PR. |
| src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs | Introduces the JS bridge + observer script and exposes TryGetCanScrollUp for RefreshView decisions. |
| src/Core/src/Platform/Android/MauiWebViewClient.cs | Resets scroll capture state on navigation start and injects the observer script on navigation finish. |
| src/Core/src/Platform/Android/MauiWebView.cs | Attaches/detaches the JS interface lifecycle to the native MauiWebView. |
| src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs | Uses the bridge-reported scrollability for WebView and adds intercept logic for gestures that start in a WebView. |
| src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33510.cs | Adds an Android UI test that validates pull-to-refresh doesn’t trigger until internal web scrolling reaches top. |
| src/Controls/tests/TestCases.HostApp/Issues/Issue33510.cs | Adds a HostApp issue page with a RefreshView + WebView using internal overflow scrolling to reproduce the bug. |
4c83f0d to
b45679f
Compare
|
/azp run maui-pr-uitests , maui-pr-devicetests |
|
Azure Pipelines successfully started running 2 pipeline(s). |
|
/azp run maui-pr-uitests |
|
Azure Pipelines successfully started running 1 pipeline(s). |
…e code changes based on AI summary.
ca790dc to
3aae140
Compare
MauiBot
left a comment
There was a problem hiding this comment.
🤖 Automated review — alternative fix proposed
The expert-reviewer evaluation compared the PR fix against #2 automatically generated candidates and selected try-fix-2 as the strongest fix.
Why: try-fix-2 fixes the root cause (JS re-injection guard not cleared on page navigation) at the C# lifecycle level in Reset(), while adding optimistic gesture ownership on ACTION_DOWN as belt-and-suspenders. This makes the fix robust against JS state unavailability for any reason, passes the gate test, and preserves the guard's deduplication purpose.
Please consider applying the candidate diff below (or use it as guidance). Once you push an update, this workflow will re-trigger and re-evaluate.
Candidate diff (`try-fix-2`)
diff --git a/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs b/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs
index 1d2553dde3..2e3a281c07 100644
--- a/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs
+++ b/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs
@@ -19,6 +19,9 @@ namespace Microsoft.Maui.Platform
readonly Context _context;
AView? _contentView;
bool _refreshEnabled = true;
+ AWebView? _activeTouchWebView;
+ bool _webViewOwnsGesture;
+ bool _touchStartedInWebView;
public MauiSwipeRefreshLayout(Context context) : base(context)
{
@@ -135,6 +138,66 @@ namespace Microsoft.Maui.Platform
return CanScrollUp(_contentView);
}
+ public override bool OnInterceptTouchEvent(MotionEvent? ev)
+ {
+ if (ev is null)
+ return false;
+
+ switch (ev.ActionMasked)
+ {
+ case MotionEventActions.Down:
+ _activeTouchWebView = FindWebView(_contentView, ev.GetX(), ev.GetY());
+ _touchStartedInWebView = _activeTouchWebView is not null;
+ if (_touchStartedInWebView && RefreshViewWebViewScrollCapture.IsAttached(_activeTouchWebView))
+ {
+ // When the WebView is under scroll-capture, optimistically give it gesture
+ // ownership. The JS touchstart listener fires before the first ACTION_MOVE,
+ // reporting accurate DOM scroll state via setCanScrollUp. Deferring the
+ // scroll-state check to ACTION_MOVE avoids the false negative that occurs when
+ // _hasReportedState=false (e.g., after page navigation where the JS guard
+ // blocked re-injection of report(document.body)), which would otherwise let
+ // pull-to-refresh fire while the WebView's DOM scroll position is > 0.
+ _webViewOwnsGesture = true;
+ }
+ else
+ {
+ _webViewOwnsGesture = _touchStartedInWebView &&
+ RefreshViewWebViewScrollCapture.TryGetCanScrollUp(_activeTouchWebView, out var canScrollUpAtStart) &&
+ canScrollUpAtStart;
+ }
+ if (_webViewOwnsGesture)
+ {
+ // Forward to base so SwipeRefreshLayout records the initial pointer ID
+ // and Y position – required for correct mid-gesture intercept if the
+ // web content scrolls to the top during the same drag.
+ base.OnInterceptTouchEvent(ev);
+ return false;
+ }
+ break;
+ case MotionEventActions.Move:
+ // Re-evaluate scrollability so that once the WebView reaches the top,
+ // RefreshLayout can start intercepting mid-gesture.
+ if (_touchStartedInWebView && _webViewOwnsGesture && _activeTouchWebView is not null)
+ {
+ if (!RefreshViewWebViewScrollCapture.TryGetCanScrollUp(_activeTouchWebView, out var canStillScrollUp) || !canStillScrollUp)
+ {
+ _webViewOwnsGesture = false;
+ }
+ }
+ if (_touchStartedInWebView && _webViewOwnsGesture)
+ return false;
+ break;
+ case MotionEventActions.Cancel:
+ case MotionEventActions.Up:
+ _activeTouchWebView = null;
+ _touchStartedInWebView = false;
+ _webViewOwnsGesture = false;
+ break;
+ }
+
+ return base.OnInterceptTouchEvent(ev);
+ }
+
bool CanScrollUp(AView? view)
{
if (!(view is ViewGroup viewGroup))
@@ -182,9 +245,44 @@ namespace Microsoft.Maui.Platform
#pragma warning restore XAOBS001 // Obsolete
if (view is AWebView webView)
- return webView.ScrollY > 0;
+ return RefreshViewWebViewScrollCapture.TryGetCanScrollUp(webView, out var canScrollUp) && canScrollUp;
return true;
}
+
+ // Recursively hit-tests the view tree to find a WebView at the given
+ // coordinates (in the parent's coordinate space).
+ // ScrollX/ScrollY are added when converting to a child's local coordinate
+ // space so that scrolled containers (HorizontalScrollView, NestedScrollView,
+ // etc.) are handled correctly. Without this adjustment, any ViewGroup that
+ // has been scrolled would cause the hit-test to miss the WebView or match
+ // the wrong region.
+ static AWebView? FindWebView(AView? view, float x, float y)
+ {
+ if (view is null || view.Visibility != ViewStates.Visible)
+ return null;
+
+ if (x < view.Left || x > view.Right || y < view.Top || y > view.Bottom)
+ return null;
+
+ if (view is AWebView)
+ return (AWebView)view;
+
+ if (view is not ViewGroup viewGroup)
+ return null;
+
+ var localX = x - view.Left + view.ScrollX;
+ var localY = y - view.Top + view.ScrollY;
+
+ for (int i = viewGroup.ChildCount - 1; i >= 0; i--)
+ {
+ var webView = FindWebView(viewGroup.GetChildAt(i), localX, localY);
+ if (webView is not null)
+ return webView;
+ }
+
+ return null;
+ }
+
}
}
diff --git a/src/Core/src/Platform/Android/MauiWebView.cs b/src/Core/src/Platform/Android/MauiWebView.cs
index a2ca3839b0..3f21af4e6e 100644
--- a/src/Core/src/Platform/Android/MauiWebView.cs
+++ b/src/Core/src/Platform/Android/MauiWebView.cs
@@ -1,5 +1,6 @@
using System;
using Android.Content;
+using Android.Graphics;
using Android.Webkit;
namespace Microsoft.Maui.Platform
@@ -9,10 +10,87 @@ namespace Microsoft.Maui.Platform
public const string AssetBaseUrl = "file:///android_asset/";
readonly WebViewHandler _handler;
+ readonly Rect _clipRect;
public MauiWebView(WebViewHandler handler, Context context) : base(context)
{
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
+
+ // Initialize with empty clip bounds to prevent the WebView from briefly
+ // rendering at full screen size before layout is complete.
+ // https://github.com/dotnet/maui/issues/31475
+ _clipRect = new Rect(0, 0, 0, 0);
+ ClipBounds = _clipRect;
+ }
+
+ protected override void OnSizeChanged(int width, int height, int oldWidth, int oldHeight)
+ {
+ base.OnSizeChanged(width, height, oldWidth, oldHeight);
+ UpdateClipBounds(width, height);
+ }
+
+ protected override void OnAttachedToWindow()
+ {
+ base.OnAttachedToWindow();
+
+ // Re-evaluate ClipBounds when re-parented (e.g., wrapped in WrapperView for shadow)
+ UpdateClipBounds(Width, Height);
+
+ if (IsInsideSwipeRefreshLayout())
+ {
+ RefreshViewWebViewScrollCapture.Attach(this);
+ // If a page has already loaded before this WebView was placed inside a
+ // RefreshView (late-attach), OnPageFinished already fired with IsAttached=false
+ // and the observer was never injected. Re-inject it now so inner-scroll can
+ // correctly prevent pull-to-refresh.
+ if (!string.IsNullOrEmpty(Url))
+ RefreshViewWebViewScrollCapture.InjectObserver(this);
+ }
+ }
+
+ protected override void OnDetachedFromWindow()
+ {
+ RefreshViewWebViewScrollCapture.Detach(this);
+ base.OnDetachedFromWindow();
+ }
+
+ bool IsInsideSwipeRefreshLayout()
+ {
+ var parent = Parent;
+ while (parent is not null)
+ {
+ if (parent is MauiSwipeRefreshLayout)
+ return true;
+ parent = parent.Parent;
+ }
+ return false;
+ }
+
+ void UpdateClipBounds(int width, int height)
+ {
+ if (width > 0 && height > 0)
+ {
+ if (Parent is WrapperView)
+ {
+ // Parent is WrapperView (shadow/border/clip applied).
+ // Remove ClipBounds to allow visual effects like shadows
+ // to render outside the view area.
+ ClipBounds = null;
+ }
+ else
+ {
+ // No WrapperView — apply exact bounds to prevent the WebView
+ // from briefly rendering at full screen size before layout.
+ _clipRect.Set(0, 0, width, height);
+ ClipBounds = _clipRect;
+ }
+ }
+ else
+ {
+ // Re-apply empty clip bounds when the view becomes zero-sized or hidden.
+ _clipRect.Set(0, 0, 0, 0);
+ ClipBounds = _clipRect;
+ }
}
void IWebViewDelegate.LoadHtml(string? html, string? baseUrl)
@@ -37,5 +115,13 @@ namespace Microsoft.Maui.Platform
LoadUrl(url ?? string.Empty);
}
}
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ RefreshViewWebViewScrollCapture.Detach(this);
+
+ base.Dispose(disposing);
+ }
}
}
\ No newline at end of file
diff --git a/src/Core/src/Platform/Android/MauiWebViewClient.cs b/src/Core/src/Platform/Android/MauiWebViewClient.cs
index b24f3ab6aa..a5b42375e3 100644
--- a/src/Core/src/Platform/Android/MauiWebViewClient.cs
+++ b/src/Core/src/Platform/Android/MauiWebViewClient.cs
@@ -22,6 +22,8 @@ namespace Microsoft.Maui.Platform
public override void OnPageStarted(WebView? view, string? url, Bitmap? favicon)
{
+ RefreshViewWebViewScrollCapture.Reset(view);
+
if (!_handler.TryGetTarget(out var handler) || handler.VirtualView == null)
return;
@@ -65,6 +67,11 @@ namespace Microsoft.Maui.Platform
handler?.PlatformView.UpdateCanGoBackForward(handler.VirtualView);
+ // Only inject the scroll-capture observer when the WebView is hosted inside
+ // a RefreshView – avoids unnecessary JS overhead for standalone WebViews.
+ if (RefreshViewWebViewScrollCapture.IsAttached(view))
+ RefreshViewWebViewScrollCapture.InjectObserver(view);
+
base.OnPageFinished(view, url);
}
diff --git a/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs b/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs
index 872f3312a2..98aeaca26d 100644
--- a/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs
+++ b/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs
@@ -117,6 +117,12 @@ internal static class RefreshViewWebViewScrollCapture
if (GetState(webView) is ScrollCaptureState state)
{
state.Reset();
+ // Clear the JS guard so the next InjectObserver() call (from OnPageFinished)
+ // re-runs report(document.body) and populates state before any user touch.
+ // Without this, the guard prevents re-execution after page navigation
+ // (OnPageStarted → Reset → OnPageFinished → InjectObserver), leaving
+ // _hasReportedState=false until the first touchstart event fires.
+ webView?.EvaluateJavascript("window.__mauiRefreshViewObserverInstalled = undefined;", null);
}
}
diff --git a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
index d4e2d80990..34e4a0d326 100644
--- a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
@@ -9,3 +9,8 @@ override Microsoft.Maui.PlatformDrawable.ThresholdType.get -> System.Type!
*REMOVED*override Microsoft.Maui.Graphics.MauiDrawable.OnBoundsChange(Android.Graphics.Rect! bounds) -> void
*REMOVED*override Microsoft.Maui.Graphics.MauiDrawable.OnDraw(Android.Graphics.Drawables.Shapes.Shape? shape, Android.Graphics.Canvas? canvas, Android.Graphics.Paint? paint) -> void
override Microsoft.Maui.Handlers.LabelHandler.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size
+override Microsoft.Maui.Platform.MauiSwipeRefreshLayout.OnInterceptTouchEvent(Android.Views.MotionEvent? ev) -> bool
+override Microsoft.Maui.Platform.MauiWebView.Dispose(bool disposing) -> void
+override Microsoft.Maui.Platform.MauiWebView.OnAttachedToWindow() -> void
+override Microsoft.Maui.Platform.MauiWebView.OnDetachedFromWindow() -> void
+override Microsoft.Maui.Platform.MauiWebView.OnSizeChanged(int width, int height, int oldWidth, int oldHeight) -> void
kubaflo
left a comment
There was a problem hiding this comment.
Could you please review the latest suggestions?
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 7 findings
See inline comments for details.
🤖 AI Summary
📊 Review Session —
|
| Test | Without Fix (expect FAIL) | With Fix (expect PASS) |
|---|---|---|
🖥️ Issue33510 Issue33510 |
✅ FAIL — 869s | ✅ PASS — 1263s |
🔴 Without fix — 🖥️ Issue33510: FAIL ✅ · 869s
Determining projects to restore...
Restored /home/vsts/work/1/s/src/Graphics/src/Graphics/Graphics.csproj (in 1.15 sec).
Restored /home/vsts/work/1/s/src/Essentials/src/Essentials.csproj (in 5.81 sec).
Restored /home/vsts/work/1/s/src/Core/src/Core.csproj (in 6.73 sec).
Restored /home/vsts/work/1/s/src/Core/maps/src/Maps.csproj (in 1.48 sec).
Restored /home/vsts/work/1/s/src/Controls/src/Xaml/Controls.Xaml.csproj (in 46 ms).
Restored /home/vsts/work/1/s/src/Controls/src/Core/Controls.Core.csproj (in 27 ms).
Restored /home/vsts/work/1/s/src/Controls/Maps/src/Controls.Maps.csproj (in 32 ms).
Restored /home/vsts/work/1/s/src/Controls/Foldable/src/Controls.Foldable.csproj (in 57 ms).
Restored /home/vsts/work/1/s/src/BlazorWebView/src/Maui/Microsoft.AspNetCore.Components.WebView.Maui.csproj (in 1.28 sec).
Restored /home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj (in 2.19 sec).
1 of 11 projects are up-to-date for restore.
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Graphics -> /home/vsts/work/1/s/artifacts/bin/Graphics/Debug/net10.0-android36.0/Microsoft.Maui.Graphics.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Essentials -> /home/vsts/work/1/s/artifacts/bin/Essentials/Debug/net10.0-android36.0/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Core -> /home/vsts/work/1/s/artifacts/bin/Core/Debug/net10.0-android36.0/Microsoft.Maui.dll
Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Maps -> /home/vsts/work/1/s/artifacts/bin/Maps/Debug/net10.0-android36.0/Microsoft.Maui.Maps.dll
Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.Core/Debug/net10.0-android36.0/Microsoft.Maui.Controls.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Controls.Xaml -> /home/vsts/work/1/s/artifacts/bin/Controls.Xaml/Debug/net10.0-android36.0/Microsoft.Maui.Controls.Xaml.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Controls.Foldable -> /home/vsts/work/1/s/artifacts/bin/Controls.Foldable/Debug/net10.0-android36.0/Microsoft.Maui.Controls.Foldable.dll
Microsoft.AspNetCore.Components.WebView.Maui -> /home/vsts/work/1/s/artifacts/bin/Microsoft.AspNetCore.Components.WebView.Maui/Debug/net10.0-android36.0/Microsoft.AspNetCore.Components.WebView.Maui.dll
Controls.Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.Maps/Debug/net10.0-android36.0/Microsoft.Maui.Controls.Maps.dll
Controls.TestCases.HostApp -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Controls.TestCases.HostApp.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Graphics -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Graphics.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Essentials -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Core -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.dll
Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Maps.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Controls.Foldable -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.Foldable.dll
Microsoft.AspNetCore.Components.WebView.Maui -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.AspNetCore.Components.WebView.Maui.dll
Controls.Xaml -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.Xaml.dll
Controls.Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.Maps.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:09:46.68
Broadcasting: Intent { act=android.intent.action.CLOSE_SYSTEM_DIALOGS flg=0x400000 }
Broadcast completed: result=0
Broadcasting: Intent { act=android.intent.action.CLOSE_SYSTEM_DIALOGS flg=0x400000 }
Broadcast completed: result=0
Determining projects to restore...
Restored /home/vsts/work/1/s/src/Controls/tests/CustomAttributes/Controls.CustomAttributes.csproj (in 1.46 sec).
Restored /home/vsts/work/1/s/src/TestUtils/src/VisualTestUtils/VisualTestUtils.csproj (in 6 ms).
Restored /home/vsts/work/1/s/src/TestUtils/src/VisualTestUtils.MagickNet/VisualTestUtils.MagickNet.csproj (in 6.11 sec).
Restored /home/vsts/work/1/s/src/Controls/tests/TestCases.Android.Tests/Controls.TestCases.Android.Tests.csproj (in 7.87 sec).
Restored /home/vsts/work/1/s/src/TestUtils/src/UITest.Core/UITest.Core.csproj (in 16 ms).
Restored /home/vsts/work/1/s/src/TestUtils/src/UITest.Appium/UITest.Appium.csproj (in 3 ms).
Restored /home/vsts/work/1/s/src/TestUtils/src/UITest.NUnit/UITest.NUnit.csproj (in 510 ms).
Restored /home/vsts/work/1/s/src/TestUtils/src/UITest.Analyzers/UITest.Analyzers.csproj (in 2.51 sec).
5 of 13 projects are up-to-date for restore.
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Controls.CustomAttributes -> /home/vsts/work/1/s/artifacts/bin/Controls.CustomAttributes/Debug/net10.0/Controls.CustomAttributes.dll
Graphics -> /home/vsts/work/1/s/artifacts/bin/Graphics/Debug/net10.0/Microsoft.Maui.Graphics.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Essentials -> /home/vsts/work/1/s/artifacts/bin/Essentials/Debug/net10.0/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Core -> /home/vsts/work/1/s/artifacts/bin/Core/Debug/net10.0/Microsoft.Maui.dll
Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.Core/Debug/net10.0/Microsoft.Maui.Controls.dll
VisualTestUtils -> /home/vsts/work/1/s/artifacts/bin/VisualTestUtils/Debug/netstandard2.0/VisualTestUtils.dll
UITest.Core -> /home/vsts/work/1/s/artifacts/bin/UITest.Core/Debug/net10.0/UITest.Core.dll
VisualTestUtils.MagickNet -> /home/vsts/work/1/s/artifacts/bin/VisualTestUtils.MagickNet/Debug/netstandard2.0/VisualTestUtils.MagickNet.dll
UITest.Appium -> /home/vsts/work/1/s/artifacts/bin/UITest.Appium/Debug/net10.0/UITest.Appium.dll
UITest.NUnit -> /home/vsts/work/1/s/artifacts/bin/UITest.NUnit/Debug/net10.0/UITest.NUnit.dll
UITest.Analyzers -> /home/vsts/work/1/s/artifacts/bin/UITest.Analyzers/Debug/netstandard2.0/UITest.Analyzers.dll
Controls.TestCases.Android.Tests -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll
Test run for /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (x64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0)
[xUnit.net 00:00:00.20] Discovering: Controls.TestCases.Android.Tests
[xUnit.net 00:00:00.79] Discovered: Controls.TestCases.Android.Tests
NUnit Adapter 4.5.0.0: Test execution started
Running selected tests in /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll
NUnit3TestExecutor discovered 1 of 1 NUnit test cases using Current Discovery mode, Non-Explicit run
>>>>> 05/03/2026 02:44:15 FixtureSetup for Issue33510(Android)
>>>>> 05/03/2026 02:44:19 PullToRefreshShouldNotTriggerWhenWebViewIsScrolledDown Start
>>>>> 05/03/2026 02:44:31 PullToRefreshShouldNotTriggerWhenWebViewIsScrolledDown Stop
>>>>> 05/03/2026 02:44:32 Log types: logcat, bugreport, server
Failed PullToRefreshShouldNotTriggerWhenWebViewIsScrolledDown [13 s]
Error Message:
RefreshView should not trigger while WebView content is not at the top.
Assert.That(App.FindElement(StatusLabel).GetText(), Does.Not.Contain("Refresh triggered"))
Expected: not String containing "Refresh triggered"
But was: "Refresh triggered"
Stack Trace:
at Microsoft.Maui.TestCases.Tests.Issues.Issue33510.PullToRefreshShouldNotTriggerWhenWebViewIsScrolledDown() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33510.cs:line 44
1) at Microsoft.Maui.TestCases.Tests.Issues.Issue33510.PullToRefreshShouldNotTriggerWhenWebViewIsScrolledDown() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33510.cs:line 44
NUnit Adapter 4.5.0.0: Test execution complete
Total tests: 1
Failed: 1
Test Run Failed.
Total time: 1.2967 Minutes
🟢 With fix — 🖥️ Issue33510: PASS ✅ · 1263s
(truncated to last 15,000 chars)
ostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: --- End of stack trace from previous location --- [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at AndroidDeviceExtensions.PushAndInstallPackageAsync(AndroidDevice device, PushAndInstallCommand command, CancellationToken token) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at AndroidDeviceExtensions.PushAndInstallPackageAsync(AndroidDevice device, PushAndInstallCommand command, CancellationToken token) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at Xamarin.Android.Tasks.FastDeploy.InstallPackage(Boolean installed) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at Xamarin.Android.Tasks.FastDeploy.InstallPackage(Boolean installed) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at Xamarin.Android.Tasks.FastDeploy.RunInstall() [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
Build FAILED.
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: Mono.AndroidTools.InstallFailedException: Unexpected install output: cmd: Can't find service: package [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at Mono.AndroidTools.Internal.AdbOutputParsing.CheckInstallSuccess(String output, String packageName) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at Mono.AndroidTools.AndroidDevice.<>c__DisplayClass105_0.<InstallPackage>b__0(Task`1 t) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: --- End of stack trace from previous location --- [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: --- End of stack trace from previous location --- [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at AndroidDeviceExtensions.PushAndInstallPackageAsync(AndroidDevice device, PushAndInstallCommand command, CancellationToken token) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at AndroidDeviceExtensions.PushAndInstallPackageAsync(AndroidDevice device, PushAndInstallCommand command, CancellationToken token) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at Xamarin.Android.Tasks.FastDeploy.InstallPackage(Boolean installed) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at Xamarin.Android.Tasks.FastDeploy.InstallPackage(Boolean installed) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at Xamarin.Android.Tasks.FastDeploy.RunInstall() [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
0 Warning(s)
1 Error(s)
Time Elapsed 00:10:05.81
* daemon not running; starting now at tcp:5037
* daemon started successfully
Determining projects to restore...
All projects are up-to-date for restore.
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Graphics -> /home/vsts/work/1/s/artifacts/bin/Graphics/Debug/net10.0-android36.0/Microsoft.Maui.Graphics.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Essentials -> /home/vsts/work/1/s/artifacts/bin/Essentials/Debug/net10.0-android36.0/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Core -> /home/vsts/work/1/s/artifacts/bin/Core/Debug/net10.0-android36.0/Microsoft.Maui.dll
Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Maps -> /home/vsts/work/1/s/artifacts/bin/Maps/Debug/net10.0-android36.0/Microsoft.Maui.Maps.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.Core/Debug/net10.0-android36.0/Microsoft.Maui.Controls.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Microsoft.AspNetCore.Components.WebView.Maui -> /home/vsts/work/1/s/artifacts/bin/Microsoft.AspNetCore.Components.WebView.Maui/Debug/net10.0-android36.0/Microsoft.AspNetCore.Components.WebView.Maui.dll
Controls.Foldable -> /home/vsts/work/1/s/artifacts/bin/Controls.Foldable/Debug/net10.0-android36.0/Microsoft.Maui.Controls.Foldable.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Controls.Xaml -> /home/vsts/work/1/s/artifacts/bin/Controls.Xaml/Debug/net10.0-android36.0/Microsoft.Maui.Controls.Xaml.dll
Controls.Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.Maps/Debug/net10.0-android36.0/Microsoft.Maui.Controls.Maps.dll
Controls.TestCases.HostApp -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Controls.TestCases.HostApp.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Graphics -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Graphics.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Essentials -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Core -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.dll
Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Maps.dll
Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Controls.Xaml -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.Xaml.dll
Controls.Foldable -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.Foldable.dll
Controls.Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.Maui.Controls.Maps.dll
Microsoft.AspNetCore.Components.WebView.Maui -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.HostApp/Debug/net10.0-android/Microsoft.AspNetCore.Components.WebView.Maui.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:08:34.26
Broadcasting: Intent { act=android.intent.action.CLOSE_SYSTEM_DIALOGS flg=0x400000 }
Broadcast completed: result=0
Broadcasting: Intent { act=android.intent.action.CLOSE_SYSTEM_DIALOGS flg=0x400000 }
Broadcast completed: result=0
Determining projects to restore...
All projects are up-to-date for restore.
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Graphics -> /home/vsts/work/1/s/artifacts/bin/Graphics/Debug/net10.0/Microsoft.Maui.Graphics.dll
Controls.CustomAttributes -> /home/vsts/work/1/s/artifacts/bin/Controls.CustomAttributes/Debug/net10.0/Controls.CustomAttributes.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Essentials -> /home/vsts/work/1/s/artifacts/bin/Essentials/Debug/net10.0/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Core -> /home/vsts/work/1/s/artifacts/bin/Core/Debug/net10.0/Microsoft.Maui.dll
Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14001862
Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.Core/Debug/net10.0/Microsoft.Maui.Controls.dll
VisualTestUtils -> /home/vsts/work/1/s/artifacts/bin/VisualTestUtils/Debug/netstandard2.0/VisualTestUtils.dll
VisualTestUtils.MagickNet -> /home/vsts/work/1/s/artifacts/bin/VisualTestUtils.MagickNet/Debug/netstandard2.0/VisualTestUtils.MagickNet.dll
UITest.Core -> /home/vsts/work/1/s/artifacts/bin/UITest.Core/Debug/net10.0/UITest.Core.dll
UITest.Appium -> /home/vsts/work/1/s/artifacts/bin/UITest.Appium/Debug/net10.0/UITest.Appium.dll
UITest.NUnit -> /home/vsts/work/1/s/artifacts/bin/UITest.NUnit/Debug/net10.0/UITest.NUnit.dll
UITest.Analyzers -> /home/vsts/work/1/s/artifacts/bin/UITest.Analyzers/Debug/netstandard2.0/UITest.Analyzers.dll
Controls.TestCases.Android.Tests -> /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll
Test run for /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (x64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0)
[xUnit.net 00:00:00.17] Discovering: Controls.TestCases.Android.Tests
[xUnit.net 00:00:00.54] Discovered: Controls.TestCases.Android.Tests
NUnit Adapter 4.5.0.0: Test execution started
Running selected tests in /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll
NUnit3TestExecutor discovered 1 of 1 NUnit test cases using Current Discovery mode, Non-Explicit run
>>>>> 05/03/2026 03:05:25 FixtureSetup for Issue33510(Android)
>>>>> 05/03/2026 03:05:28 PullToRefreshShouldNotTriggerWhenWebViewIsScrolledDown Start
>>>>> 05/03/2026 03:05:38 PullToRefreshShouldNotTriggerWhenWebViewIsScrolledDown Stop
Passed PullToRefreshShouldNotTriggerWhenWebViewIsScrolledDown [10 s]
NUnit Adapter 4.5.0.0: Test execution complete
Test Run Successful.
Total tests: 1
Passed: 1
Total time: 30.2427 Seconds
📁 Fix files reverted (5 files)
eng/pipelines/ci-copilot.ymlsrc/Core/src/Platform/Android/MauiSwipeRefreshLayout.cssrc/Core/src/Platform/Android/MauiWebView.cssrc/Core/src/Platform/Android/MauiWebViewClient.cssrc/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
New files (not reverted):
src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs
🧪 UI Tests — Category Detection
Detected UI test categories: RefreshView,ViewBaseTests,WebView
🔍 Regression Cross-Reference
🔍 Regression Cross-Reference
🟢 No regression risks detected. No labeled bug-fix PRs in the last 6 months touched the modified files.
🔍 Pre-Flight — Context & Validation
Issue: #33510 - [Android] RefreshView triggers pull-to-refresh immediately when scrolling up inside a WebView
PR: #34614 - [Android] Fix for RefreshView triggering pull-to-refresh when scrolling inside a WebView with internal scrollable content
Platforms Affected: Android (primary), preserves behavior on iOS/Windows/Mac
Files Changed: 5 implementation, 2 test
Key Findings
- Root Cause: Android's
SwipeRefreshLayout.CanChildScrollUp()queriesWebView.ScrollYandCanScrollVertically(-1), which only reflect the native Android WebView scroll state. When HTML content uses an internal scrollable container (e.g.,overflow-y: autoon a child div), the native WebView body stays atScrollY=0, causingCanChildScrollUp()to returnfalseeven when the HTML content is scrolled down. - Fix strategy: A JavaScript bridge (
RefreshViewWebViewScrollCapture) injectstouchstart/touchmoveobservers into the WebView to track whether the inner HTML scroll container is at the top. This state is queried inOnInterceptTouchEventandCanChildScrollUpto block pull-to-refresh when inner content can still scroll up. - Approach is correctly scoped:
Attach()is only called when the WebView is inside aMauiSwipeRefreshLayout— no JS overhead for standalone WebViews. - Prior review fully addressed: MOVE re-evaluation, gated injection,
MarkDetached()guard, late-attach handling,ScrollX/ScrollYinFindWebView— all implemented. - Remaining gaps: (1)
MauiHybridWebViewalso extendsAWebViewand will be found byFindWebViewbut never getsAttach()called — HybridWebView inside RefreshView still reproduces the bug. (2)window.__mauiRefreshViewObserverInstalledguard prevents JS observer re-registration after Shell tab-switch (detach+re-attach without page reload) — fix silently broken after tab switching. (3) Test has no positive assertion (refresh fires when at top). - CI: All required CI checks pass.
Code Review Summary
Verdict: NEEDS_DISCUSSION
Confidence: medium
Errors: 0 | Warnings: 3 | Suggestions: 2
Key code review findings:
⚠️ MauiHybridWebView(alsoAWebViewsubclass) found byFindWebViewbutAttach()never called — HybridWebView + RefreshView still broken (MauiWebView.cs:39)⚠️ window.__mauiRefreshViewObserverInstalledguard breaks JS re-registration after Shell tab-switch (detach+re-attach); fix silently stops working until next page navigation (RefreshViewWebViewScrollCapture.cs:15)⚠️ Test only covers negative case (no refresh when scrolled down); no assertion that refresh still fires when at top (Issue33510.cs:23)⚠️ FindWebViewcoordinate transform misses view padding (PaddingLeft/PaddingTop) — can miss WebViews inside padded containers (MauiSwipeRefreshLayout.cs:260)- 💡 No async stabilisation wait before pull gesture in test (
Issue33510.cs:38) - 💡
_activeTouchWebViewholds reference beyond gesture end when base intercepts (MauiSwipeRefreshLayout.cs:178)
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #34614 | JS bridge (mauiRefreshViewHost) + OnInterceptTouchEvent override — defers pull-to-refresh while inner HTML scroll reports canScrollUp=true |
✅ PASSED (Gate) | 5 impl + 2 test | Original PR; NEEDS_DISCUSSION per code review |
🔬 Code Review — Deep Analysis
Code Review — PR #34614
Independent Assessment
What this changes: Introduces a JavaScript bridge (RefreshViewWebViewScrollCapture) that injects a touchstart/touchmove observer script into a WebView hosted inside a RefreshView. The observer calls back into native code (via @JavascriptInterface) with the current DOM scroll state. MauiSwipeRefreshLayout gains an OnInterceptTouchEvent override that defers gesture interception to the WebView if the JS bridge reports that the web content can still scroll up, re-evaluating on every MOVE event so that once the content reaches the top, pull-to-refresh is allowed to re-engage.
Inferred motivation: Android's native WebView.ScrollY / CanScrollVertically(-1) only reports the native WebView viewport's scroll position. When the page layout uses CSS overflow-y: auto/scroll on an inner DOM element (rather than the body/document), the native value is always 0 even while the user is scrolling through content. This causes SwipeRefreshLayout.CanChildScrollUp() to return false, allowing the refresh gesture to fire immediately — the bug.
Reconciliation with PR Narrative
Author claims: The fix adds a lightweight JS bridge so MauiSwipeRefreshLayout can distinguish "native viewport not scrolled" from "inner DOM element is scrolled." The fix preserves existing behaviour for non-WebView controls.
Agreement: Diagnosis and fix approach are correct. The PR correctly handles the primary scenario (single scrollable inner element, WebView directly or shallowly nested inside RefreshView). All existing Copilot review suggestions from prior rounds have been addressed:
- OnInterceptTouchEvent now re-evaluates on MOVE events ✅
- Observer is only injected for WebViews inside a RefreshView ✅
- JS interface is only attached for RefreshView-hosted WebViews ✅
MarkDetached()beforeRemoveJavascriptInterfaceto prevent use-after-free on JNI thread ✅- Late-attach scenario handled by calling
InjectObserverinOnAttachedToWindowwhen URL is already loaded ✅ ScrollX/ScrollYoffsets added toFindWebViewcoordinate transform ✅
Disagreement / new findings: Two new gaps not previously flagged remain open (see Findings below).
Findings
⚠️ Warning — MauiHybridWebView not covered by the fix
File: src/Core/src/Platform/Android/MauiWebView.cs:39 / src/Core/src/Platform/Android/MauiHybridWebView.cs:39
FindWebView in MauiSwipeRefreshLayout recurses into the view tree and returns any Android.Webkit.WebView subclass — including MauiHybridWebView (which also extends AWebView). However, only MauiWebView.OnAttachedToWindow calls RefreshViewWebViewScrollCapture.Attach. MauiHybridWebView has no such logic.
Consequence: a HybridWebView wrapped in a RefreshView with a CSS-scrollable inner element will hit issue #33510 identically to the pre-fix behaviour. TryGetCanScrollUp finds no state, falls back to CanScrollVertically(-1) || ScrollY > 0, which returns false for DOM-only scroll.
Options: (a) Apply the same Attach/Detach/InjectObserver pattern to MauiHybridWebView.OnAttachedToWindow, OnDetachedFromWindow, and Dispose; or (b) add a comment explicitly documenting that HybridWebView is out of scope for this PR.
⚠️ Warning — JS observer silently broken after Shell tab-switch
File: src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs:15 (ObserverScript)
The injected script gates on window.__mauiRefreshViewObserverInstalled to prevent duplicate listener registration. When the user returns to a tab (detach + re-attach without a page reload):
OnDetachedFromWindow→Detach()→MarkDetached(),RemoveJavascriptInterface,SetTag(null).OnAttachedToWindow→Attach()→ newScrollCaptureState+AddJavascriptInterface.InjectObserverruns the script.window.__mauiRefreshViewObserverInstalledis stilltruein the DOM → script returns early without re-registering listeners with the new host.- The old closure-captured
host(previousScrollCaptureState) has_detached = true→ all callbacks are no-ops. - New
ScrollCaptureState.HasReportedStateis never set →TryGetCanScrollUpfalls back to native check only → fix silently stops working.
Fix: Change the JS listeners to dereference window.mauiRefreshViewHost dynamically on each event (instead of capturing var host at install time):
document.addEventListener('touchmove', function (event) {
var h = window.mauiRefreshViewHost;
if (h) report(h, event.target);
}, true);And either remove the __mauiRefreshViewObserverInstalled guard entirely (duplicate listeners are idempotent after this change) or add a __mauiRefreshViewObserverVersion counter that InjectObserver increments on re-attach.
⚠️ Warning — Test has no positive assertion (RefreshView fires when at top)
File: src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33510.cs:23
The test only asserts the negative case: refresh must not fire when content is scrolled down. The author explicitly chose not to add the complementary positive case. However, without it the test would pass even if the fix accidentally disabled pull-to-refresh entirely for WebView-wrapped RefreshViews (a complete regression). This is a meaningful coverage gap — the existing CanScrollUpViewByType now calls TryGetCanScrollUp instead of ScrollY > 0; if TryGetCanScrollUp always returned false, the test would still pass.
A second [Test] method should: scroll content down, scroll back to top (reverse drag direction × 3), perform a pull-to-refresh gesture, and assert StatusLabel text contains "Refresh triggered".
💡 Suggestion — Add a stabilisation wait before the pull-to-refresh gesture in the test
File: src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33510.cs:38
After the three downward-scroll gestures, there is no delay before the pull-to-refresh attempt. The JS touchmove callback fires asynchronously: V8 event → JNI → JavaBridge thread → SetCanScrollUp → _hasReportedState = true. On a loaded emulator the JavaBridge thread may not have committed the state by the time Appium dispatches the next gesture. Consider adding a short stabilisation wait (100–200 ms) after the scroll loop to ensure HasReportedState is visible before evaluating intercept logic.
💡 Suggestion — _activeTouchWebView holds strong reference beyond gesture end (minor)
File: src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs:178
When base.OnInterceptTouchEvent returns true (SwipeRefreshLayout takes ownership), subsequent MOVE/UP/CANCEL events are routed to onTouchEvent rather than onInterceptTouchEvent. The Cancel/Up reset branch in OnInterceptTouchEvent is therefore never reached for that gesture, and _activeTouchWebView holds a strong reference to the WebView until the next DOWN event. This is harmless in practice (every DOWN resets state), but it delays GC of the WebView reference in edge cases. Adding the same reset in an override of OnTouchEvent would be the complete fix.
Devil's Advocate
On the HybridWebView gap: One could argue HybridWebView in a RefreshView is a niche scenario. But since FindWebView already finds it, the fix creates an inconsistency — the gesture capture logic runs on a HybridWebView but without the state it needs. The fallback is false (no scroll up), which means refresh is not wrongly triggered — the opposite regression. Worth documenting even if not fixing now.
On the tab-switch re-attach bug: Tab switching without page reload is common in Shell apps. This is a real reproducible scenario, not an edge case. The __mauiRefreshViewObserverInstalled guard was likely added to avoid redundant listener stacking on each InjectObserver call — which is a legitimate concern — but the current implementation trades one problem for another. The dynamic-lookup approach resolves both.
On CI being green: All PR CI checks pass (build, Helix unit tests, integration tests). No regressions are visible from the test run. The two remaining issues are not testable by the current automated suite.
On approving as-is: The two ⚠️ Warning findings represent real behaviour gaps. The HybridWebView gap is at least a documentation issue. The tab-switch re-attach bug could be filed as a separate issue if the author prefers, but it directly weakens the fix for a common Shell navigation pattern.
Verdict: NEEDS_DISCUSSION
Confidence: medium
Summary: The core fix is sound and well-implemented; previous review feedback has been fully addressed and CI is green. Two remaining issues warrant discussion before merge: (1) MauiHybridWebView placed inside a RefreshView still reproduces the original bug because the JS bridge is never attached — this should either be fixed or explicitly documented; (2) the JS observer silently breaks after a Shell tab-switch (detach + re-attach without page reload) due to the __mauiRefreshViewObserverInstalled guard preventing listener re-registration with the new JS host. The test coverage gap (no positive assertion that refresh still works at the top) is also a concern, though the author has explicitly declined to address it.
🔧 Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| 1 | try-fix-1 (claude-opus-4.6) | On-demand evaluateJavascript() in DispatchTouchEvent — no persistent observer, no JNI bridge, all logic in MauiSwipeRefreshLayout |
✅ PASS | 1 file, deletes RefreshViewWebViewScrollCapture.cs |
Clean minimal change; no re-attach bug |
| 2 | try-fix-2 (claude-sonnet-4.6) | Targeted fix to PR's architecture — dynamic window.mauiRefreshViewHost lookup per event, named JS handles, MauiHybridWebView support |
✅ PASS | 6 files (incl. new MauiHybridWebView.cs hooks, MauiHybridWebViewClient.cs) |
Fixes both |
| 3 | try-fix-3 (gpt-5.3-codex) | Native gesture ownership — block ALL gestures when touch starts in WebView (no JS) | ✅ PASS | 1 file | |
| 4 | try-fix-4 (gpt-5.5) | Hybrid: DOM scan via evaluateJavascript on ACTION_UP + native onScrollChanged in MauiWebView; state stored on MauiWebView instance |
✅ PASS | 3 files | Creative hybrid; more complex than needed |
| PR | PR #34614 | Persistent JS observer (touchstart/touchmove) + JNI @JavascriptInterface bridge + OnInterceptTouchEvent override |
✅ PASSED (Gate) | 5 impl + 2 test | Original PR; code review: NEEDS_DISCUSSION (re-attach guard, HybridWebView gap) |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| claude-opus-4.6 | 2 | No | NO NEW IDEAS — design space well-covered; Attempt 1 or 2 are strongest |
Exhausted: Yes
Selected Fix: try-fix-2 — Targeted fix to PR's architecture. Preserves the well-designed JS observer mechanism while fixing both code review
📋 Report — Final Recommendation
⚠️ Final Recommendation: REQUEST CHANGES
Phase Status
| Phase | Status | Notes |
|---|---|---|
| Pre-Flight | ✅ COMPLETE | Issue #33510, PR #34614, 5 impl + 2 test files classified |
| Code Review | NEEDS_DISCUSSION (medium) | 0 errors, 3 warnings — re-attach guard, HybridWebView gap, missing positive test |
| Gate | ✅ PASSED | android |
| Try-Fix | ✅ COMPLETE | 4 attempts, 4 passing (try-fix-3 disqualified — disables pull-to-refresh entirely) |
| Report | ✅ COMPLETE |
Code Review Impact on Try-Fix
The code review identified two functional gaps: the __mauiRefreshViewObserverInstalled guard silently breaking after Shell tab-switch, and MauiHybridWebView being unprotected despite being found by FindWebView. These directly guided try-fix-2 (claude-sonnet-4.6), which fixed both by removing the guard in favour of named JS handles with removeEventListener/addEventListener rotation, and adding OnAttachedToWindow/OnDetachedFromWindow/Dispose overrides to MauiHybridWebView and MauiHybridWebViewClient. The failure-mode probe for Shell navigation was the most impactful — it revealed a scenario that is realistic (tab switching) and not covered by the existing test suite.
Summary
An alternative fix (try-fix-2) was found that is strictly better than the PR's submitted code. It preserves the PR's well-designed JS observer architecture and all its gate-passing behaviour, while also fixing two code-review-identified functional gaps: the observer silently breaking after Shell tab-switch (detach+re-attach without page reload), and MauiHybridWebView inside a RefreshView reproducing the original bug because RefreshViewWebViewScrollCapture.Attach() was never called for it. All 4 try-fix candidates passed the gate test, but try-fix-3 is disqualified (it blocks pull-to-refresh entirely — only passing because the test has no positive assertion). try-fix-2 is the strongest candidate.
Root Cause
Android's SwipeRefreshLayout.CanChildScrollUp() uses WebView.ScrollY/CanScrollVertically(-1) which only reflects the native WebView viewport scroll. When the HTML page uses a CSS overflow-y: auto/scroll inner element (not the body), the native WebView always reports ScrollY=0, causing the SwipeRefreshLayout to believe the child is at the top and immediately intercept pull-to-refresh gestures.
Fix Quality
The PR's fix is architecturally sound and all prior reviewer feedback has been addressed. The core JS bridge + OnInterceptTouchEvent mechanism is correct for the primary scenario. However, two real functional regressions remain: (1) after Shell tab navigation without a page reload, the JS observer silently stops working — the __mauiRefreshViewObserverInstalled guard blocks re-registration with the new ScrollCaptureState bridge; (2) MauiHybridWebView (which also extends AWebView and is found by FindWebView) never gets Attach() called, so the same bug reproduces for any HybridWebView inside a RefreshView. try-fix-2 addresses both gaps while keeping the same proven architecture. The PR should be updated to incorporate these fixes before merge.
Selected Fix: try-fix-2
MauiBot
left a comment
There was a problem hiding this comment.
🤖 Automated review — alternative fix proposed
The expert-reviewer evaluation compared the PR fix against #2 automatically generated candidates and selected try-fix-2 as the strongest fix.
Why: try-fix-2 preserves the PR's well-designed JS observer architecture while fixing two functional gaps identified in code review: (1) the __mauiRefreshViewObserverInstalled guard that silently breaks JS re-registration after Shell tab-switch (detach+re-attach without page reload), replaced with named JS event handles that are properly removed/re-added on each injection; (2) MauiHybridWebView inside a RefreshView was unprotected since Attach() was never called for it, now fixed via OnAttachedToWindow/OnDetachedFromWindow/Dispose overrides mirroring MauiWebView.
Please consider applying the candidate diff below (or use it as guidance). Once you push an update, this workflow will re-trigger and re-evaluate.
Candidate diff (`try-fix-2`)
diff --git a/src/Core/src/Platform/Android/MauiHybridWebView.cs b/src/Core/src/Platform/Android/MauiHybridWebView.cs
index 6354bdf7ab..2bff13e668 100644
--- a/src/Core/src/Platform/Android/MauiHybridWebView.cs
+++ b/src/Core/src/Platform/Android/MauiHybridWebView.cs
@@ -42,6 +42,33 @@ namespace Microsoft.Maui.Platform
// Re-evaluate ClipBounds when re-parented (e.g., wrapped in WrapperView for shadow)
UpdateClipBounds(Width, Height);
+
+ if (IsInsideSwipeRefreshLayout())
+ {
+ RefreshViewWebViewScrollCapture.Attach(this);
+ // If a page has already loaded before this HybridWebView was placed inside a
+ // RefreshView (late-attach), the observer was never injected. Re-inject now.
+ if (!string.IsNullOrEmpty(Url))
+ RefreshViewWebViewScrollCapture.InjectObserver(this);
+ }
+ }
+
+ protected override void OnDetachedFromWindow()
+ {
+ RefreshViewWebViewScrollCapture.Detach(this);
+ base.OnDetachedFromWindow();
+ }
+
+ bool IsInsideSwipeRefreshLayout()
+ {
+ var parent = Parent;
+ while (parent is not null)
+ {
+ if (parent is MauiSwipeRefreshLayout)
+ return true;
+ parent = parent.Parent;
+ }
+ return false;
}
void UpdateClipBounds(int width, int height)
@@ -77,5 +104,13 @@ namespace Microsoft.Maui.Platform
PostWebMessage(new WebMessage(rawMessage), AndroidAppOriginUri);
#pragma warning restore CA1416 // Validate platform compatibility
}
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ RefreshViewWebViewScrollCapture.Detach(this);
+
+ base.Dispose(disposing);
+ }
}
}
diff --git a/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs b/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs
index 1c8b1bd3ce..37d3942749 100644
--- a/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs
+++ b/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs
@@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text;
using System.Web;
+using Android.Graphics;
using Android.Webkit;
using Java.Net;
using Microsoft.Extensions.Logging;
@@ -30,6 +31,26 @@ namespace Microsoft.Maui.Platform
private HybridWebViewHandler? Handler => _handler is not null && _handler.TryGetTarget(out var h) ? h : null;
+ public override void OnPageStarted(AWebView? view, string? url, Bitmap? favicon)
+ {
+ RefreshViewWebViewScrollCapture.Reset(view);
+ base.OnPageStarted(view, url, favicon);
+ }
+
+ public override void OnPageFinished(AWebView? view, string? url)
+ {
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ base.OnPageFinished(view, url);
+ return;
+ }
+
+ if (RefreshViewWebViewScrollCapture.IsAttached(view))
+ RefreshViewWebViewScrollCapture.InjectObserver(view);
+
+ base.OnPageFinished(view, url);
+ }
+
public override WebResourceResponse? ShouldInterceptRequest(AWebView? view, IWebResourceRequest? request)
{
var url = request?.Url?.ToString();
diff --git a/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs b/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs
index 1d2553dde3..a0b681bc97 100644
--- a/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs
+++ b/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs
@@ -19,6 +19,10 @@ namespace Microsoft.Maui.Platform
readonly Context _context;
AView? _contentView;
bool _refreshEnabled = true;
+ AWebView? _activeTouchWebView;
+ RefreshViewWebViewScrollCapture.ScrollCaptureState? _activeTouchScrollState;
+ bool _webViewOwnsGesture;
+ bool _touchStartedInWebView;
public MauiSwipeRefreshLayout(Context context) : base(context)
{
@@ -182,9 +186,100 @@ namespace Microsoft.Maui.Platform
#pragma warning restore XAOBS001 // Obsolete
if (view is AWebView webView)
- return webView.ScrollY > 0;
+ return RefreshViewWebViewScrollCapture.TryGetCanScrollUp(webView, out var canScrollUp) && canScrollUp;
return true;
}
+
+ public override bool OnInterceptTouchEvent(MotionEvent? ev)
+ {
+ if (ev is null)
+ return false;
+
+ switch (ev.ActionMasked)
+ {
+ case MotionEventActions.Down:
+ _activeTouchWebView = FindWebView(_contentView, ev.GetX(), ev.GetY());
+ _touchStartedInWebView = _activeTouchWebView is not null;
+ // Cache the ScrollCaptureState object at DOWN time so the MOVE hot-path
+ // can check CanScrollUp (a volatile bool read) without any JNI calls.
+ _activeTouchScrollState = RefreshViewWebViewScrollCapture.GetAttachedState(_activeTouchWebView);
+ _webViewOwnsGesture = _touchStartedInWebView &&
+ RefreshViewWebViewScrollCapture.TryGetCanScrollUp(_activeTouchWebView, out var canScrollUpAtStart) &&
+ canScrollUpAtStart;
+ if (_webViewOwnsGesture)
+ {
+ // Forward to base so SwipeRefreshLayout records the initial pointer ID
+ // and Y position – required for correct mid-gesture intercept if the
+ // web content scrolls to the top during the same drag.
+ base.OnInterceptTouchEvent(ev);
+ return false;
+ }
+ break;
+ case MotionEventActions.PointerDown:
+ // Reset WebView gesture ownership when a second finger is placed –
+ // multi-touch cancels the pending single-finger pull-to-refresh guard.
+ _activeTouchWebView = null;
+ _activeTouchScrollState = null;
+ _touchStartedInWebView = false;
+ _webViewOwnsGesture = false;
+ break;
+ case MotionEventActions.Move:
+ // Re-evaluate scrollability so that once the WebView reaches the top,
+ // RefreshLayout can start intercepting mid-gesture. Use the cached
+ // ScrollCaptureState to read a volatile bool with zero JNI overhead.
+ if (_touchStartedInWebView && _webViewOwnsGesture && _activeTouchScrollState is not null)
+ {
+ if (!_activeTouchScrollState.CanScrollUp)
+ _webViewOwnsGesture = false;
+ }
+ if (_touchStartedInWebView && _webViewOwnsGesture)
+ return false;
+ break;
+ case MotionEventActions.Cancel:
+ case MotionEventActions.Up:
+ _activeTouchWebView = null;
+ _activeTouchScrollState = null;
+ _touchStartedInWebView = false;
+ _webViewOwnsGesture = false;
+ break;
+ }
+
+ return base.OnInterceptTouchEvent(ev);
+ }
+
+ // Recursively hit-tests the view tree to find a WebView at the given
+ // coordinates (in the parent's coordinate space).
+ // ScrollX/ScrollY are added when converting to a child's local coordinate
+ // space so that scrolled containers (HorizontalScrollView, NestedScrollView,
+ // etc.) are handled correctly. Without this adjustment, any ViewGroup that
+ // has been scrolled would cause the hit-test to miss the WebView or match
+ // the wrong region.
+ static AWebView? FindWebView(AView? view, float x, float y)
+ {
+ if (view is null || view.Visibility != ViewStates.Visible)
+ return null;
+
+ if (x < view.Left || x >= view.Right || y < view.Top || y >= view.Bottom)
+ return null;
+
+ if (view is AWebView)
+ return (AWebView)view;
+
+ if (view is not ViewGroup viewGroup)
+ return null;
+
+ var localX = x - view.Left + view.ScrollX;
+ var localY = y - view.Top + view.ScrollY;
+
+ for (int i = viewGroup.ChildCount - 1; i >= 0; i--)
+ {
+ var webView = FindWebView(viewGroup.GetChildAt(i), localX, localY);
+ if (webView is not null)
+ return webView;
+ }
+
+ return null;
+ }
}
}
diff --git a/src/Core/src/Platform/Android/MauiWebView.cs b/src/Core/src/Platform/Android/MauiWebView.cs
index 7e9019100c..377705ec64 100644
--- a/src/Core/src/Platform/Android/MauiWebView.cs
+++ b/src/Core/src/Platform/Android/MauiWebView.cs
@@ -35,6 +35,35 @@ namespace Microsoft.Maui.Platform
// Re-evaluate ClipBounds when re-parented (e.g., wrapped in WrapperView for shadow)
UpdateClipBounds(Width, Height);
+
+ if (IsInsideSwipeRefreshLayout())
+ {
+ RefreshViewWebViewScrollCapture.Attach(this);
+ // If a page has already loaded before this WebView was placed inside a
+ // RefreshView (late-attach), OnPageFinished already fired with IsAttached=false
+ // and the observer was never injected. Re-inject it now so inner-scroll can
+ // correctly prevent pull-to-refresh.
+ if (!string.IsNullOrEmpty(Url))
+ RefreshViewWebViewScrollCapture.InjectObserver(this);
+ }
+ }
+
+ protected override void OnDetachedFromWindow()
+ {
+ RefreshViewWebViewScrollCapture.Detach(this);
+ base.OnDetachedFromWindow();
+ }
+
+ bool IsInsideSwipeRefreshLayout()
+ {
+ var parent = Parent;
+ while (parent is not null)
+ {
+ if (parent is MauiSwipeRefreshLayout)
+ return true;
+ parent = parent.Parent;
+ }
+ return false;
}
void UpdateClipBounds(int width, int height)
@@ -86,5 +115,12 @@ namespace Microsoft.Maui.Platform
LoadUrl(url ?? string.Empty);
}
}
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ RefreshViewWebViewScrollCapture.Detach(this);
+
+ base.Dispose(disposing);
+ }
}
}
\ No newline at end of file
diff --git a/src/Core/src/Platform/Android/MauiWebViewClient.cs b/src/Core/src/Platform/Android/MauiWebViewClient.cs
index b24f3ab6aa..a5b42375e3 100644
--- a/src/Core/src/Platform/Android/MauiWebViewClient.cs
+++ b/src/Core/src/Platform/Android/MauiWebViewClient.cs
@@ -22,6 +22,8 @@ namespace Microsoft.Maui.Platform
public override void OnPageStarted(WebView? view, string? url, Bitmap? favicon)
{
+ RefreshViewWebViewScrollCapture.Reset(view);
+
if (!_handler.TryGetTarget(out var handler) || handler.VirtualView == null)
return;
@@ -65,6 +67,11 @@ namespace Microsoft.Maui.Platform
handler?.PlatformView.UpdateCanGoBackForward(handler.VirtualView);
+ // Only inject the scroll-capture observer when the WebView is hosted inside
+ // a RefreshView – avoids unnecessary JS overhead for standalone WebViews.
+ if (RefreshViewWebViewScrollCapture.IsAttached(view))
+ RefreshViewWebViewScrollCapture.InjectObserver(view);
+
base.OnPageFinished(view, url);
}
diff --git a/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs b/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs
index 872f3312a2..527afa1624 100644
--- a/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs
+++ b/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs
@@ -9,20 +9,24 @@ internal static class RefreshViewWebViewScrollCapture
const string JavaScriptInterfaceName = "mauiRefreshViewHost";
const int ScrollCaptureStateKey = 0x4D415549;
+ // The observer script intentionally omits the __mauiRefreshViewObserverInstalled guard
+ // and looks up window.mauiRefreshViewHost dynamically on every event.
+ //
+ // Why no install guard?
+ // After a Shell tab-switch the WebView is detached (Detach removes the old bridge) then
+ // re-attached (Attach adds a fresh ScrollCaptureState, InjectObserver re-runs this script).
+ // If a guard prevented re-injection, the new bridge object would never receive callbacks,
+ // silently breaking pull-to-refresh protection until the next full page reload.
+ //
+ // Why dynamic lookup?
+ // Capturing `var host = window.mauiRefreshViewHost` at install time would hold a reference
+ // to the *old* Java ScrollCaptureState even after Detach removed it. Dynamically resolving
+ // window.mauiRefreshViewHost on each event ensures callbacks always reach the current bridge.
+ // Duplicate listeners from repeated injections are harmless – each call reads the same host
+ // and sets the same value; there is no observable side-effect from the extra invocations.
const string ObserverScript =
"""
(function () {
- if (window.__mauiRefreshViewObserverInstalled) {
- return;
- }
-
- var host = window.mauiRefreshViewHost;
- if (!host || typeof host.setCanScrollUp !== 'function') {
- return;
- }
-
- window.__mauiRefreshViewObserverInstalled = true;
-
function isScrollableElement(node) {
if (!node || node.nodeType !== Node.ELEMENT_NODE) {
return false;
@@ -58,19 +62,30 @@ internal static class RefreshViewWebViewScrollCapture
function report(target) {
try {
+ var host = window.mauiRefreshViewHost;
+ if (!host || typeof host.setCanScrollUp !== 'function') {
+ return;
+ }
var scrollable = getScrollableElement(target);
host.setCanScrollUp(getScrollTopForElement(scrollable) > 0);
} catch (e) {
}
}
- document.addEventListener('touchstart', function (event) {
- report(event.target);
- }, true);
+ // Remove any previously installed listeners to prevent accumulation
+ // after Shell tab-switch (detach + re-attach without page reload).
+ if (window.__mauiTouchStartHandler) {
+ document.removeEventListener('touchstart', window.__mauiTouchStartHandler, true);
+ document.removeEventListener('touchmove', window.__mauiTouchMoveHandler, true);
+ }
- document.addEventListener('touchmove', function (event) {
- report(event.target);
- }, true);
+ var touchStartHandler = function (event) { report(event.target); };
+ var touchMoveHandler = function (event) { report(event.target); };
+ window.__mauiTouchStartHandler = touchStartHandler;
+ window.__mauiTouchMoveHandler = touchMoveHandler;
+
+ document.addEventListener('touchstart', touchStartHandler, true);
+ document.addEventListener('touchmove', touchMoveHandler, true);
report(document.body);
})();
@@ -158,10 +173,13 @@ internal static class RefreshViewWebViewScrollCapture
return false;
}
+ internal static ScrollCaptureState? GetAttachedState(WebView? webView) =>
+GetState(webView);
+
static ScrollCaptureState? GetState(WebView? webView) =>
webView?.GetTag(ScrollCaptureStateKey) as ScrollCaptureState;
- sealed class ScrollCaptureState : Java.Lang.Object
+ internal sealed class ScrollCaptureState : Java.Lang.Object
{
// These fields are written from the JavaBridge thread (via [JavascriptInterface])
// and read from the UI thread, so they must be volatile to ensure visibility on ARM.
diff --git a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
index 59fed41b6d..db676dbda3 100644
--- a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
@@ -13,6 +13,13 @@ override Microsoft.Maui.Platform.ContentViewGroup.HasOverlappingRendering.get ->
override Microsoft.Maui.Platform.LayoutViewGroup.HasOverlappingRendering.get -> bool
override Microsoft.Maui.Platform.WrapperView.HasOverlappingRendering.get -> bool
override Microsoft.Maui.Platform.MauiHybridWebView.OnAttachedToWindow() -> void
+override Microsoft.Maui.Platform.MauiHybridWebView.OnDetachedFromWindow() -> void
+override Microsoft.Maui.Platform.MauiHybridWebView.Dispose(bool disposing) -> void
override Microsoft.Maui.Platform.MauiHybridWebView.OnSizeChanged(int width, int height, int oldWidth, int oldHeight) -> void
+override Microsoft.Maui.Platform.MauiHybridWebViewClient.OnPageFinished(Android.Webkit.WebView? view, string? url) -> void
+override Microsoft.Maui.Platform.MauiHybridWebViewClient.OnPageStarted(Android.Webkit.WebView? view, string? url, Android.Graphics.Bitmap? favicon) -> void
override Microsoft.Maui.Platform.MauiWebView.OnAttachedToWindow() -> void
override Microsoft.Maui.Platform.MauiWebView.OnSizeChanged(int width, int height, int oldWidth, int oldHeight) -> void
+override Microsoft.Maui.Platform.MauiSwipeRefreshLayout.OnInterceptTouchEvent(Android.Views.MotionEvent? ev) -> bool
+override Microsoft.Maui.Platform.MauiWebView.Dispose(bool disposing) -> void
+override Microsoft.Maui.Platform.MauiWebView.OnDetachedFromWindow() -> void
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Issue Details
When using a RefreshView that wraps a WebView in a .NET MAUI app on Android, the pull-to-refresh gesture is triggered as soon as the user scrolls up, even if the WebView content has not reached the top. This prevents normal upward scrolling through web content without accidentally refreshing the page.
Root Cause
The issue occurs because of how Android RefreshView determines whether to intercept a downward drag gesture. For a WebView, this decision is based on the native scroll state (ScrollY / CanScrollVertically(-1)).
In this scenario, the visible content is not scrolled by the native WebView itself, but by an internal HTML container (overflow-y: auto). While this internal DOM element is still mid-scroll, the native WebView may incorrectly report that it is already at the top.
As a result, RefreshView intercepts the gesture too early, triggering pull-to-refresh instead of allowing the web content to continue scrolling.
Description of Change
The fix involves adding Android-specific handling for the WebView + RefreshView interaction.
A lightweight WebView bridge is introduced to determine whether the touched DOM content can still scroll upward. MauiSwipeRefreshLayout uses this information when a gesture starts inside a WebView:
This approach preserves existing RefreshView behavior for other controls, while correctly handling the WebView scenario that native Android scroll checks cannot accurately detect.
Validated the behavior in the following platforms
Issues Fixed
Fixes #33510
Output ScreenShot
BeforeFix-33510.mov
AfterFix-33510.mov