diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue33510.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue33510.cs
new file mode 100644
index 000000000000..305937bb2313
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue33510.cs
@@ -0,0 +1,128 @@
+namespace Maui.Controls.Sample.Issues;
+
+[Issue(IssueTracker.Github, 33510, "[Android] RefreshView triggers pull-to-refresh immediately when scrolling up inside a WebView", PlatformAffected.Android)]
+public class Issue33510 : TestContentPage
+{
+ RefreshView _refreshView;
+ Label _statusLabel;
+
+ protected override void Init()
+ {
+ _statusLabel = new Label
+ {
+ AutomationId = "StatusLabel",
+ Text = "Loading..."
+ };
+
+ var webView = new WebView
+ {
+ AutomationId = "TestWebView",
+ Source = new HtmlWebViewSource { Html = ScrollableHtml }
+ };
+
+ webView.Navigated += (_, _) => _statusLabel.Text = "WebView ready";
+
+ _refreshView = new RefreshView
+ {
+ AutomationId = "TestRefreshView",
+ Content = webView
+ };
+
+ _refreshView.Command = new Command(async () =>
+ {
+ _statusLabel.Text = "Refresh triggered";
+ await Task.Delay(150);
+ _refreshView.IsRefreshing = false;
+ });
+
+ var grid = new Grid
+ {
+ RowDefinitions =
+ {
+ new RowDefinition { Height = GridLength.Auto },
+ new RowDefinition { Height = GridLength.Star }
+ }
+ };
+
+ grid.Add(_statusLabel, 0, 0);
+ grid.Add(_refreshView, 0, 1);
+
+ Content = grid;
+ }
+
+ const string ScrollableHtml = """
+
+
+
+
+
+
+
+
+
+
+
+ """;
+}
diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33510.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33510.cs
new file mode 100644
index 000000000000..f33deebba16d
--- /dev/null
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33510.cs
@@ -0,0 +1,77 @@
+#if ANDROID // This test is Android-only because Issue #33510 (RefreshView triggering pull-to-refresh when scrolling inside a WebView) only reproduces on Android, and the test uses Android-specific Appium touch gesture APIs.
+using NUnit.Framework;
+using OpenQA.Selenium.Appium.Interactions;
+using OpenQA.Selenium.Interactions;
+using UITest.Appium;
+using UITest.Core;
+
+namespace Microsoft.Maui.TestCases.Tests.Issues;
+
+[Category(UITestCategories.RefreshView)]
+public class Issue33510 : _IssuesUITest
+{
+ const string StatusLabel = "StatusLabel";
+ const string TestRefreshView = "TestRefreshView";
+
+ public Issue33510(TestDevice device) : base(device)
+ {
+ }
+
+ public override string Issue => "[Android] RefreshView triggers pull-to-refresh immediately when scrolling up inside a WebView";
+
+ [Test]
+ public void PullToRefreshShouldNotTriggerWhenWebViewIsScrolledDown()
+ {
+ var androidApp = WaitForAndroidApp();
+ var refreshViewRect = App.WaitForElement(TestRefreshView).GetRect();
+ var x = refreshViewRect.CenterX();
+
+ // Scroll content down by swiping up inside the WebView
+ for (var i = 0; i < 3; i++)
+ {
+ DragInsideWebView(androidApp, x,
+ refreshViewRect.Y + (refreshViewRect.Height * 70 / 100),
+ x,
+ refreshViewRect.Y + (refreshViewRect.Height * 25 / 100));
+ }
+
+ // Attempt pull-to-refresh (drag down) while content is still scrolled down
+ DragInsideWebView(androidApp, x,
+ refreshViewRect.Y + (refreshViewRect.Height * 30 / 100),
+ x,
+ refreshViewRect.Y + (refreshViewRect.Height * 80 / 100));
+
+ Assert.That(App.FindElement(StatusLabel).GetText(), Does.Not.Contain("Refresh triggered"),
+ "RefreshView should not trigger while WebView content is not at the top.");
+ }
+
+ AppiumAndroidApp WaitForAndroidApp()
+ {
+ if (App is not AppiumAndroidApp androidApp)
+ {
+ Assert.Ignore("Issue #33510 is Android-specific.");
+ return null!;
+ }
+
+ Assert.That(
+ App.WaitForTextToBePresentInElement(StatusLabel, "WebView ready", timeout: TimeSpan.FromSeconds(30)),
+ Is.True,
+ "WebView never finished loading.");
+
+ return androidApp;
+ }
+
+ static void DragInsideWebView(AppiumAndroidApp androidApp, int fromX, int fromY, int toX, int toY)
+ {
+ var touchDevice = new OpenQA.Selenium.Appium.Interactions.PointerInputDevice(PointerKind.Touch);
+ var dragSequence = new ActionSequence(touchDevice, 0);
+
+ dragSequence.AddAction(touchDevice.CreatePointerMove(CoordinateOrigin.Viewport, fromX, fromY, TimeSpan.Zero));
+ dragSequence.AddAction(touchDevice.CreatePointerDown(PointerButton.TouchContact));
+ dragSequence.AddAction(touchDevice.CreatePointerMove(CoordinateOrigin.Viewport, toX, toY, TimeSpan.FromMilliseconds(450)));
+ dragSequence.AddAction(touchDevice.CreatePointerUp(PointerButton.TouchContact));
+
+ androidApp.Driver.PerformActions([dragSequence]);
+ }
+}
+#endif
diff --git a/src/Core/src/Platform/Android/MauiHybridWebView.cs b/src/Core/src/Platform/Android/MauiHybridWebView.cs
index 6354bdf7abea..74518c09cd1e 100644
--- a/src/Core/src/Platform/Android/MauiHybridWebView.cs
+++ b/src/Core/src/Platform/Android/MauiHybridWebView.cs
@@ -36,12 +36,31 @@ protected override void OnSizeChanged(int width, int height, int oldWidth, int o
UpdateClipBounds(width, height);
}
+ // OnAttachedToWindow — calls Attach(this) when inside a SwipeRefreshLayout.
protected override void OnAttachedToWindow()
{
base.OnAttachedToWindow();
// Re-evaluate ClipBounds when re-parented (e.g., wrapped in WrapperView for shadow)
UpdateClipBounds(Width, Height);
+
+ if (RefreshViewWebViewScrollCapture.IsInsideMauiSwipeRefreshLayout(this))
+ {
+ 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);
+ }
+ }
+ }
+
+ // OnDetachedFromWindow — calls Detach().
+ protected override void OnDetachedFromWindow()
+ {
+ RefreshViewWebViewScrollCapture.Detach(this);
+ base.OnDetachedFromWindow();
}
void UpdateClipBounds(int width, int height)
@@ -77,5 +96,16 @@ public void SendRawMessage(string rawMessage)
PostWebMessage(new WebMessage(rawMessage), AndroidAppOriginUri);
#pragma warning restore CA1416 // Validate platform compatibility
}
+
+ // Dispose(bool) — calls Detach() on cleanup.
+ 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 1c8b1bd3ce1b..c202d14e2366 100644
--- a/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs
+++ b/src/Core/src/Platform/Android/MauiHybridWebViewClient.cs
@@ -7,6 +7,7 @@
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,32 @@ public MauiHybridWebViewClient(HybridWebViewHandler handler)
private HybridWebViewHandler? Handler => _handler is not null && _handler.TryGetTarget(out var h) ? h : null;
+ // OnPageStarted — calls Reset() to clear stale scroll state.
+ public override void OnPageStarted(AWebView? view, string? url, Bitmap? favicon)
+ {
+ RefreshViewWebViewScrollCapture.Reset(view);
+ base.OnPageStarted(view, url, favicon);
+ }
+
+ // OnPageFinished — calls InjectObserver() to inject JS bridge when page loads.
+ public override void OnPageFinished(AWebView? view, string? url)
+ {
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ base.OnPageFinished(view, url);
+ return;
+ }
+
+ // Only inject the scroll-capture observer when the WebView is hosted inside
+ // a RefreshView – avoids unnecessary JS overhead for standalone HybridWebViews.
+ 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 1d2553dde335..23cc46bd77d5 100644
--- a/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs
+++ b/src/Core/src/Platform/Android/MauiSwipeRefreshLayout.cs
@@ -19,6 +19,10 @@ public class MauiSwipeRefreshLayout : SwipeRefreshLayout
readonly Context _context;
AView? _contentView;
bool _refreshEnabled = true;
+ AWebView? _activeTouchWebView;
+ RefreshViewWebViewScrollCapture.ScrollCaptureState? _activeTouchScrollState;
+ bool _webViewOwnsGesture;
+ bool _touchStartedInWebView;
public MauiSwipeRefreshLayout(Context context) : base(context)
{
@@ -135,6 +139,66 @@ public override bool CanChildScrollUp()
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;
+ // ACTION_DOWN — caches ScrollCaptureState via GetAttachedState().
+ _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:
+ // ACTION_MOVE — reads CanScrollUp (volatile bool, zero JNI) from cached state
+ // instead of calling TryGetCanScrollUp every frame.
+ if (_touchStartedInWebView && _webViewOwnsGesture && _activeTouchScrollState is not null)
+ {
+ if (!_activeTouchScrollState.CanScrollUp)
+ {
+ _webViewOwnsGesture = false;
+ }
+ }
+ if (_touchStartedInWebView && _webViewOwnsGesture)
+ {
+ return false;
+ }
+ break;
+ case MotionEventActions.Cancel:
+ case MotionEventActions.Up:
+ // ACTION_UP/CANCEL — clears cached state.
+ _activeTouchWebView = null;
+ _activeTouchScrollState = null;
+ _touchStartedInWebView = false;
+ _webViewOwnsGesture = false;
+ break;
+ }
+
+ return base.OnInterceptTouchEvent(ev);
+ }
+
bool CanScrollUp(AView? view)
{
if (!(view is ViewGroup viewGroup))
@@ -182,9 +246,44 @@ static bool CanScrollUpViewByType(AView? view)
#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 2d571d50030a..9b439e7c66d7 100644
--- a/src/Core/src/Platform/Android/MauiWebView.cs
+++ b/src/Core/src/Platform/Android/MauiWebView.cs
@@ -36,6 +36,25 @@ protected override void OnAttachedToWindow()
// Re-evaluate ClipBounds when re-parented (e.g., wrapped in WrapperView for shadow)
UpdateClipBounds(Width, Height);
+
+ if (RefreshViewWebViewScrollCapture.IsInsideMauiSwipeRefreshLayout(this))
+ {
+ 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();
}
void UpdateClipBounds(int width, int height)
@@ -108,5 +127,15 @@ void IWebViewDelegate.LoadUrl(string? url)
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 b24f3ab6aa13..89e53e36c47b 100644
--- a/src/Core/src/Platform/Android/MauiWebViewClient.cs
+++ b/src/Core/src/Platform/Android/MauiWebViewClient.cs
@@ -22,8 +22,12 @@ public override bool ShouldOverrideUrlLoading(WebView? view, IWebResourceRequest
public override void OnPageStarted(WebView? view, string? url, Bitmap? favicon)
{
+ RefreshViewWebViewScrollCapture.Reset(view);
+
if (!_handler.TryGetTarget(out var handler) || handler.VirtualView == null)
+ {
return;
+ }
if (!string.IsNullOrWhiteSpace(url))
{
@@ -59,12 +63,21 @@ public override void OnPageFinished(WebView? view, string? url)
// Skip Navigated event for about:blank to prevent unwanted events when Source is null
if (navigate && !IsBlankNavigation(url))
+ {
handler.VirtualView.Navigated(handler.CurrentNavigationEvent, GetValidUrl(url), _navigationResult);
+ }
handler.SyncPlatformCookiesToVirtualView(url);
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);
}
@@ -76,7 +89,9 @@ public override void OnReceivedError(WebView? view, IWebResourceRequest? request
_navigationResult = WebNavigationResult.Failure;
if (error?.ErrorCode == ClientError.Timeout)
+ {
_navigationResult = WebNavigationResult.Timeout;
+ }
}
base.OnReceivedError(view, request, error);
@@ -102,7 +117,9 @@ static bool IsBlankNavigation(string? url)
// Null/empty URLs are handled by the early return in OnPageFinished,
// so we only need to check for the explicit "about:blank" URL
if (string.IsNullOrWhiteSpace(url))
+ {
return false;
+ }
// Check if URL is about:blank (case insensitive)
return string.Equals(url.Trim(), "about:blank", StringComparison.OrdinalIgnoreCase);
@@ -111,7 +128,9 @@ static bool IsBlankNavigation(string? url)
static string GetValidUrl(string? url)
{
if (string.IsNullOrEmpty(url))
+ {
return string.Empty;
+ }
return url;
}
@@ -119,7 +138,9 @@ static string GetValidUrl(string? url)
protected override void Dispose(bool disposing)
{
if (disposing)
+ {
Disconnect();
+ }
base.Dispose(disposing);
}
diff --git a/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs b/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs
new file mode 100644
index 000000000000..ac2ab4b54da7
--- /dev/null
+++ b/src/Core/src/Platform/Android/RefreshViewWebViewScrollCapture.cs
@@ -0,0 +1,228 @@
+using Android.Webkit;
+using Java.Interop;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Microsoft.Maui.Platform;
+
+internal static class RefreshViewWebViewScrollCapture
+{
+ const string JavaScriptInterfaceName = "mauiRefreshViewHost";
+ const int ScrollCaptureStateKey = 0x4D415549;
+
+ // Observer JS script — replaces static boolean guard (__mauiRefreshViewObserverInstalled) with
+ // dynamic window.mauiRefreshViewHost lookup inside report(). Fixes scroll tracking after Shell tab-switch.
+ //
+ // 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). A static guard would
+ // prevent re-injection, so the new bridge object would never receive callbacks, silently breaking
+ // pull-to-refresh protection until the next full page reload.
+ //
+ // Named global JS listener vars (window.__mauiTouchStartHandler/MoveHandler) so removeEventListener
+ // works on re-inject, preventing listener stacking across Detach/re-Attach cycles.
+ const string ObserverScript =
+ """
+ (function () {
+ function isScrollableElement(node) {
+ if (!node || node.nodeType !== Node.ELEMENT_NODE) {
+ return false;
+ }
+
+ var style = window.getComputedStyle(node);
+ var overflowY = style ? style.overflowY : '';
+ return (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') &&
+ node.scrollHeight > node.clientHeight + 1;
+ }
+
+ function getScrollableElement(startNode) {
+ for (var node = startNode; node && node.nodeType === Node.ELEMENT_NODE; node = node.parentElement) {
+ if (isScrollableElement(node)) {
+ return node;
+ }
+ }
+
+ return document.scrollingElement || document.documentElement || document.body;
+ }
+
+ function getScrollTopForElement(element) {
+ if (!element) {
+ return 0;
+ }
+
+ if (element === document.body || element === document.documentElement || element === document.scrollingElement) {
+ return window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;
+ }
+
+ return element.scrollTop || 0;
+ }
+
+ 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) {
+ }
+ }
+
+ // 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);
+ }
+
+ 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);
+ })();
+ """;
+
+ internal static void Attach(WebView webView)
+ {
+ if (GetState(webView) is not null)
+ {
+ return;
+ }
+
+ var state = new ScrollCaptureState();
+ webView.SetTag(ScrollCaptureStateKey, state);
+ webView.AddJavascriptInterface(state, JavaScriptInterfaceName);
+ }
+
+ internal static void Detach(WebView? webView)
+ {
+ if (webView is null)
+ {
+ return;
+ }
+
+ if (GetState(webView) is not ScrollCaptureState state)
+ {
+ return;
+ }
+
+ // Mark detached BEFORE removing the interface so any in-flight JNI
+ // callbacks to SetCanScrollUp become no-ops instead of accessing a
+ // disposed object. RemoveJavascriptInterface is async from V8's
+ // perspective — the JS bridge can still fire after this call returns.
+ state.MarkDetached();
+ webView.RemoveJavascriptInterface(JavaScriptInterfaceName);
+ webView.SetTag(ScrollCaptureStateKey, null);
+ // Do NOT call state.Dispose() here — V8 may still hold a reference to
+ // the state object via the JS bridge. The GC will collect it once V8
+ // releases its last reference.
+ }
+
+ internal static void Reset(WebView? webView)
+ {
+ if (GetState(webView) is ScrollCaptureState state)
+ {
+ state.Reset();
+ }
+ }
+
+ internal static void InjectObserver(WebView? webView)
+ {
+ if (webView is null)
+ {
+ return;
+ }
+
+ webView.EvaluateJavascript(ObserverScript, null);
+ }
+
+ internal static bool IsAttached(WebView? webView) => GetState(webView) is not null;
+
+ internal static bool IsInsideMauiSwipeRefreshLayout(WebView webView)
+ {
+ var parent = webView.Parent;
+ while (parent is not null)
+ {
+ if (parent is MauiSwipeRefreshLayout)
+ {
+ return true;
+ }
+ parent = parent.Parent;
+ }
+ return false;
+ }
+
+ internal static bool TryGetCanScrollUp(WebView? webView, out bool canScrollUp)
+ {
+ if (webView is null)
+ {
+ canScrollUp = false;
+ return false;
+ }
+
+ var nativeCanScrollUp = webView.CanScrollVertically(-1) || webView.ScrollY > 0;
+
+ if (GetState(webView) is ScrollCaptureState state && state.HasReportedState)
+ {
+ canScrollUp = state.CanScrollUp || nativeCanScrollUp;
+ return true;
+ }
+
+ if (nativeCanScrollUp)
+ {
+ canScrollUp = true;
+ return true;
+ }
+
+ canScrollUp = false;
+ return false;
+ }
+
+ static ScrollCaptureState? GetState(WebView? webView) =>
+ webView?.GetTag(ScrollCaptureStateKey) as ScrollCaptureState;
+
+ // Returns the cached ScrollCaptureState for the given WebView so callers on the
+ // UI thread can read CanScrollUp (a volatile bool) directly without any JNI overhead.
+ // Returns null when the WebView is not inside a RefreshView.
+ internal static ScrollCaptureState? GetAttachedState(WebView? webView) => GetState(webView);
+
+ 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.
+ volatile bool _canScrollUp;
+ volatile bool _hasReportedState;
+ // Set before RemoveJavascriptInterface so any in-flight JNI callbacks become
+ // no-ops rather than accessing a disposed object.
+ volatile bool _detached;
+
+ internal bool CanScrollUp => _canScrollUp;
+
+ internal bool HasReportedState => _hasReportedState;
+
+ [JavascriptInterface]
+ [RequiresUnreferencedCode("Java.Interop.Export uses dynamic features.")]
+ [Export("setCanScrollUp")]
+ public void SetCanScrollUp(bool canScrollUp)
+ {
+ if (_detached)
+ {
+ return;
+ }
+ _canScrollUp = canScrollUp;
+ _hasReportedState = true;
+ }
+
+ internal void MarkDetached() => _detached = true;
+
+ internal void Reset()
+ {
+ _canScrollUp = false;
+ _hasReportedState = false;
+ }
+ }
+}
diff --git a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
index eebc770d253a..b3c4372f7b7d 100644
--- a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
@@ -13,7 +13,14 @@ 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
override Microsoft.Maui.Platform.MauiWebView.OnTouchEvent(Android.Views.MotionEvent? e) -> bool