[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://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 34614Or
iex "& { $(irm https://raw.githubusercontent.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). |
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 7 findings
See inline comments for details.
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
… WebView drag gestures when internal DOM content can still scroll up.
…e code changes based on AI summary.
1369731 to
94256ab
Compare
…WebView, optimized the implementation, and resolved the related issues as well.
I've updated the changes based on the Copilot review suggestions to extend the same fixes to MauiHybridWebView. Specifically: MauiHybridWebView.cs
MauiHybridWebViewClient.cs
PublicAPI.Unshipped.txt
These changes mirror the existing RefreshView/WebView fix pattern and resolve the same scroll-capture issue for HybridWebView inside SwipeRefreshLayout. |
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