Skip to content

[Android] Fix for RefreshView triggering pull-to-refresh when scrolling inside a WebView with internal scrollable content#34614

Open
BagavathiPerumal wants to merge 5 commits intodotnet:mainfrom
BagavathiPerumal:fix-33510
Open

[Android] Fix for RefreshView triggering pull-to-refresh when scrolling inside a WebView with internal scrollable content#34614
BagavathiPerumal wants to merge 5 commits intodotnet:mainfrom
BagavathiPerumal:fix-33510

Conversation

@BagavathiPerumal
Copy link
Copy Markdown
Contributor

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:

  • If the internal web content is not yet at the top, the gesture remains with the WebView
  • Once the internal web content reaches the top, RefreshView is allowed to intercept and trigger refresh

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

  • Android
  • Windows
  • iOS
  • Mac

Issues Fixed

Fixes #33510

Output ScreenShot

Before After
BeforeFix-33510.mov
AfterFix-33510.mov

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 24, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://github.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 34614

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://github.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 34614"

@dotnet-policy-service dotnet-policy-service Bot added the partner/syncfusion Issues / PR's with Syncfusion collaboration label Mar 24, 2026
@sheiksyedm sheiksyedm marked this pull request as ready for review March 26, 2026 10:58
Copilot AI review requested due to automatic review settings March 26, 2026 10:58
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 WebView JavaScript bridge + injected observer script to report whether touched DOM content can still scroll up.
  • Update MauiSwipeRefreshLayout to consult the reported “can scroll up” state for WebView (and add intercept logic for gestures starting inside a WebView).
  • 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.

Comment thread src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs
Comment thread src/Core/src/Platform/Android/MauiWebViewClient.cs
Comment thread src/Core/src/Platform/Android/MauiWebView.cs
@MauiBot MauiBot added s/agent-review-incomplete AI agent could not complete all phases (blocker, timeout, error) s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-approved AI agent recommends approval - PR fix is correct and optimal s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates and removed s/agent-review-incomplete AI agent could not complete all phases (blocker, timeout, error) s/agent-changes-requested AI agent recommends changes - found a better alternative or issues labels Mar 27, 2026
kubaflo
kubaflo previously approved these changes Mar 28, 2026
@sheiksyedm
Copy link
Copy Markdown
Contributor

/azp run maui-pr-uitests , maui-pr-devicetests

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 2 pipeline(s).

@sheiksyedm
Copy link
Copy Markdown
Contributor

/azp run maui-pr-uitests

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@dotnet dotnet deleted a comment from MauiBot Apr 30, 2026
@dotnet dotnet deleted a comment from MauiBot Apr 30, 2026
Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

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

🤖 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

Copy link
Copy Markdown
Contributor

@kubaflo kubaflo left a comment

Choose a reason for hiding this comment

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

Could you please review the latest suggestions?

@dotnet dotnet deleted a comment from MauiBot May 1, 2026
@kubaflo kubaflo dismissed MauiBot’s stale review May 1, 2026 09:39

Resetting for re-review

Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

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

Expert Review — 7 findings

See inline comments for details.

@MauiBot MauiBot added s/agent-review-incomplete AI agent could not complete all phases (blocker, timeout, error) and removed s/agent-changes-requested AI agent recommends changes - found a better alternative or issues labels May 1, 2026
@dotnet dotnet deleted a comment from MauiBot May 3, 2026
@dotnet dotnet deleted a comment from MauiBot May 3, 2026
@dotnet dotnet deleted a comment from MauiBot May 3, 2026
@dotnet dotnet deleted a comment from MauiBot May 3, 2026
@dotnet dotnet deleted a comment from MauiBot May 3, 2026
@dotnet dotnet deleted a comment from MauiBot May 3, 2026
@dotnet dotnet deleted a comment from MauiBot May 3, 2026
@dotnet dotnet deleted a comment from MauiBot May 3, 2026
@MauiBot
Copy link
Copy Markdown
Collaborator

MauiBot commented May 3, 2026

🤖 AI Summary

👋 @BagavathiPerumal — new AI review results are available. Please review the latest session below.

📊 Review Session1369731 · fix-33510-Code changes updated. · 2026-05-03 05:15 UTC
🚦 Gate — Test Before & After Fix

Gate Result: ✅ PASSED

Platform: ANDROID · Base: main · Merge base: 1463c4c5

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.yml
  • src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs
  • src/Core/src/Platform/Android/MauiWebView.cs
  • src/Core/src/Platform/Android/MauiWebViewClient.cs
  • src/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() queries WebView.ScrollY and CanScrollVertically(-1), which only reflect the native Android WebView scroll state. When HTML content uses an internal scrollable container (e.g., overflow-y: auto on a child div), the native WebView body stays at ScrollY=0, causing CanChildScrollUp() to return false even when the HTML content is scrolled down.
  • Fix strategy: A JavaScript bridge (RefreshViewWebViewScrollCapture) injects touchstart/touchmove observers into the WebView to track whether the inner HTML scroll container is at the top. This state is queried in OnInterceptTouchEvent and CanChildScrollUp to block pull-to-refresh when inner content can still scroll up.
  • Approach is correctly scoped: Attach() is only called when the WebView is inside a MauiSwipeRefreshLayout — no JS overhead for standalone WebViews.
  • Prior review fully addressed: MOVE re-evaluation, gated injection, MarkDetached() guard, late-attach handling, ScrollX/ScrollY in FindWebView — all implemented.
  • Remaining gaps: (1) MauiHybridWebView also extends AWebView and will be found by FindWebView but never gets Attach() called — HybridWebView inside RefreshView still reproduces the bug. (2) window.__mauiRefreshViewObserverInstalled guard 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 (also AWebView subclass) found by FindWebView but Attach() never called — HybridWebView + RefreshView still broken (MauiWebView.cs:39)
  • ⚠️ window.__mauiRefreshViewObserverInstalled guard 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)
  • ⚠️ FindWebView coordinate 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)
  • 💡 _activeTouchWebView holds 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() before RemoveJavascriptInterface to prevent use-after-free on JNI thread ✅
  • Late-attach scenario handled by calling InjectObserver in OnAttachedToWindow when URL is already loaded ✅
  • ScrollX/ScrollY offsets added to FindWebView coordinate 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):

  1. OnDetachedFromWindowDetach()MarkDetached(), RemoveJavascriptInterface, SetTag(null).
  2. OnAttachedToWindowAttach() → new ScrollCaptureState + AddJavascriptInterface.
  3. InjectObserver runs the script. window.__mauiRefreshViewObserverInstalled is still true in the DOM → script returns early without re-registering listeners with the new host.
  4. The old closure-captured host (previous ScrollCaptureState) has _detached = true → all callbacks are no-ops.
  5. New ScrollCaptureState.HasReportedState is never set → TryGetCanScrollUp falls 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 ⚠️ code review warnings; most comprehensive
3 try-fix-3 (gpt-5.3-codex) Native gesture ownership — block ALL gestures when touch starts in WebView (no JS) ✅ PASS 1 file ⚠️ DISABLES pull-to-refresh for WebView entirely; passes only because test lacks positive assertion
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 ⚠️ Warnings (re-attach guard bug, HybridWebView gap). More complete than try-fix-1 (on-demand JS). try-fix-3 is disqualified (disables pull-to-refresh entirely). try-fix-4 is more complex with similar correctness.


📋 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


Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

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

🤖 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

@MauiBot MauiBot added s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-fix-win AI found a better alternative fix than the PR and removed s/agent-review-incomplete AI agent could not complete all phases (blocker, timeout, error) s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates labels May 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community ✨ Community Contribution partner/syncfusion Issues / PR's with Syncfusion collaboration platform/android s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-fix-win AI found a better alternative fix than the PR s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android] RefreshView triggers pull-to-refresh immediately when scrolling up inside a WebView

7 participants