Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue33510.cs
Original file line number Diff line number Diff line change
@@ -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 = """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: sans-serif;
}

#wrapper {
height: 100%;
display: flex;
flex-direction: column;
}

#header {
position: sticky;
top: 0;
padding: 12px;
background: #f2f2f2;
border-bottom: 1px solid #d9d9d9;
font-weight: 600;
}

#scrollHost {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 12px;
box-sizing: border-box;
}

.card {
margin-bottom: 12px;
padding: 12px;
border-radius: 8px;
background: #e8f0fe;
}
</style>
</head>
<body>
<div id="wrapper">
<div id="header">Issue33510 Internal Scroll Host</div>
<div id="scrollHost">
<div class="card">Content 01</div>
<div class="card">Content 02</div>
<div class="card">Content 03</div>
<div class="card">Content 04</div>
<div class="card">Content 05</div>
<div class="card">Content 06</div>
<div class="card">Content 07</div>
<div class="card">Content 08</div>
<div class="card">Content 09</div>
<div class="card">Content 10</div>
<div class="card">Content 11</div>
<div class="card">Content 12</div>
<div class="card">Content 13</div>
<div class="card">Content 14</div>
<div class="card">Content 15</div>
<div class="card">Content 16</div>
<div class="card">Content 17</div>
<div class="card">Content 18</div>
<div class="card">Content 19</div>
<div class="card">Content 20</div>
</div>
</div>
</body>
</html>
""";
}
Original file line number Diff line number Diff line change
@@ -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()
Comment thread
BagavathiPerumal marked this conversation as resolved.
{
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
30 changes: 30 additions & 0 deletions src/Core/src/Platform/Android/MauiHybridWebView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
}
}
}
27 changes: 27 additions & 0 deletions src/Core/src/Platform/Android/MauiHybridWebViewClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down
Loading
Loading