[Android] Fix increasing bottom gap in CollectionView while scrolling#35457
[Android] Fix increasing bottom gap in CollectionView while scrolling#35457praveenkumarkarunanithi wants to merge 4 commits into
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35457Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35457" |
|
/azp run maui-pr-uitests , maui-pr-devicetests |
|
Azure Pipelines successfully started running 2 pipeline(s). |
Root cause: SKILL.md line 23 (now removed) explicitly listed s/needs-repro, s/needs-info, s/needs-attention, and the p/* priority labels as 'useful label families' the agent may apply. The PR-specific caveat only excluded these on PRs, not on issues. The labeler dutifully followed the spec and applied a noisy set of triage labels to issues (observed on #35448: s/needs-repro, untriaged, s/needs-verification, ⌚ Not Triaged, s/needs-info). These labels are all managed by repo triage automation (dotnet-policy-service[bot]) and human triagers — they are NOT content- derivable. The labeler's job is to assign content-derived labels only. SKILL.md changes: - Remove triage/priority labels from the 'useful label families' list. - Keep i/regression with a tightened scope ('only when reporter explicitly states regression'). - Add an explicit 'Triage / workflow labels' section enumerating the full off-limits list (s/needs-*, s/triaged, s/verified, s/no-repro, s/not-a-bug, s/duplicate, s/pr-needs-author-input, untriaged, ⌚ Not Triaged, p/0..p/3). Rule applies to both issues AND PRs. - Add corresponding bullet in 'What NOT to do' section. eval.yaml changes (#35448 scenario): - Rename: 'Cross-platform only issue - no platform labels' → 'Issue with explicit platforms gets platform labels but no triage workflow labels'. Old framing was wrong — issue body's 'Affected platforms' field explicitly lists iOS+Android, so per SKILL.md the labeler MUST apply those platform labels. - Flip platform/ios + platform/android from negative to positive assertions (matches SKILL.md issue-platform rule). - Add negative assertions for s/needs-info, s/needs-repro, s/needs-verification, s/needs-attention, untriaged, ⌚ Not Triaged, p/0, p/1. eval.yaml changes (#35457 PR scenario): - Rename: 'PR should not get s/needs-info or s/needs-repro' → 'PR does not get triage workflow labels' (broader scope per SKILL). - Add positive assertion (platform/android) so a noop response can't vacuously pass the test. - Add negative assertions for s/needs-verification, s/needs-attention, s/pr-needs-author-input, untriaged, ⌚ Not Triaged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per user directive: the agentic-labeler must apply ONLY area-* and platform/* labels. Everything else (t/*, i/*, s/*, p/*, partner/*, perf/*, backport/*, regressed-in-*, version/*, untriaged, :watch: Not Triaged) is forbidden. SKILL.md changes: - Add prominent '🚨 Scope' section at top making the restriction the first rule the labeler reads, with explicit enumeration of forbidden label families. - Simplify 'Label discovery' section (no longer enumerates extra label families beyond area-*/platform/*). - Tighten 'What NOT to do' with a single rule that prohibits all non- area-*/platform/* labels. - Update noop guidance: if the only candidates fall outside area-*/ platform/*, noop instead of applying them. eval.yaml changes: - Add negative assertions for t/bug, i/regression, partner/syncfusion, and perf/memory-leak in the issue #35448, PR #35457, and prompt- injection scenarios so the eval catches over-application of forbidden label families. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
/review -b feature/regression-check -p android |
|
/review -b feature/regression-check -p android |
|
/review -b feature/refactor-copilot-yml |
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 2 findings
See inline comments for details.
|
|
||
| // IMauiRecyclerView (Core) — true while the EmptyView adapter is active so that | ||
| // SafeArea inset logic can allow listeners on EmptyView items (#34634 gate exception). | ||
| bool IMauiRecyclerView.IsShowingEmptyView => _emptyViewAdapter != null && GetAdapter() == _emptyViewAdapter; |
There was a problem hiding this comment.
[critical] Compile correctness — This explicit implementation targets Microsoft.Maui.IMauiRecyclerView.IsShowingEmptyView, but the Core IMauiRecyclerView interface is still empty. This will not compile (IMauiRecyclerView does not contain a definition for IsShowingEmptyView), and the read in MauiWindowInsetListener has the same problem. Add the member to the Core interface or avoid reading it through that interface.
| if (MauiWindowInsetListener.FindListenerForView(view) is MauiWindowInsetListener localListener) | ||
| // applyRecyclerViewGate=true: skip RecyclerView data-item views with default SafeAreaEdges | ||
| // to prevent recycled views from accumulating stale inset-derived padding (#34634/#34635). | ||
| if (MauiWindowInsetListener.FindListenerForView(view, applyRecyclerViewGate: true) is MauiWindowInsetListener localListener) |
There was a problem hiding this comment.
[major] SafeArea/Android listener lifecycle — Because default RecyclerView item roots are skipped only during TrySetMauiWindowInsetListener, a later change from default SafeAreaEdges to an explicit value is ignored. MapSafeAreaEdges can find/reset the parent listener, but it does not attach this listener and LayoutViewGroup/ContentViewGroup only request insets when _isInsetListenerSet is already true. Concrete scenario: a recycled item root attaches with default edges, then a binding/style sets SafeAreaEdges explicitly; the item still has no listener until detach/reattach.
|
/review -b feature/refactor-copilot-yml |
This comment has been minimized.
This comment has been minimized.
kubaflo
left a comment
There was a problem hiding this comment.
Could you check the ai's suggestions?
|
/review -b feature/enhanced-reviewer |
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 2 findings
See inline comments for details.
|
|
||
| // IMauiRecyclerView (Core) — true while the EmptyView adapter is active so that | ||
| // SafeArea inset logic can allow listeners on EmptyView items (#34634 gate exception). | ||
| bool IMauiRecyclerView.IsShowingEmptyView => _emptyViewAdapter != null && GetAdapter() == _emptyViewAdapter; |
There was a problem hiding this comment.
[critical] Build / API correctness — This explicit IMauiRecyclerView.IsShowingEmptyView implementation targets the Core Microsoft.Maui.IMauiRecyclerView interface, but that interface still has no IsShowingEmptyView member. The matching use in MauiWindowInsetListener also reads that member, so the Android build will fail until src/Core/src/Core/IMauiRecyclerView.cs declares it (for example bool IsShowingEmptyView { get; }, with a safe default if needed for other implementers).
| { | ||
| // Check if this view is contained within a registered view first | ||
| if (MauiWindowInsetListener.FindListenerForView(view) is MauiWindowInsetListener localListener) | ||
| // applyRecyclerViewGate=true: skip RecyclerView data-item views with default SafeAreaEdges |
There was a problem hiding this comment.
[major] Android SafeArea / CollectionView recycling — Gating listener attachment here only at OnAttachedToWindow leaves an attached recycled item unable to opt back in later. LayoutViewGroup/ContentViewGroup store the result in _isInsetListenerSet; if the row initially has default SafeAreaEdges, this returns false, and a later SafeAreaEdges mapper call only marks/request-layouts but never retries TrySetMauiWindowInsetListener, so OnLayout will not request/apply insets. Repro: a recycled CollectionView item root starts with default edges, is rebound or updated to explicit SafeAreaEdges, and remains attached. The mapper/mark path needs to attach (or reattempt attach) when HasExplicitSafeAreaEdges becomes true, not only during initial attach.
MauiBot
left a comment
There was a problem hiding this comment.
AI Review Summary
@praveenkumarkarunanithi — new AI review results are available based on this last commit:
67db65d.
fix update To request a fresh review after new comments or commits, comment/review rerun.
Review Sessions — click to expand
Gate — Test Before & After Fix
Gate Result: ⚠️ SKIPPED
No tests were detected in this PR.
Recommendation: Add tests to verify the fix using the write-tests-agent.
UI Tests — CollectionView,Editor,RefreshView,ViewBaseTests
Detected UI test categories: CollectionView,Editor,RefreshView,ViewBaseTests
Pre-Flight — Context & Validation
Issue: #34634 - [.NET 10] Increasing gap in the bottom while scrolling.
PR: #35457 - [Android] Fix increasing bottom gap in CollectionView while scrolling
Platforms Affected: Android
Files Changed: 2 implementation, 0 test
Key Findings
- Android CollectionView/RefreshView/Editor manual scenarios show an increasing bottom gap while scrolling; comments identify PR #33908 as the regression source.
- PR changes Android safe-area inset listener discovery/attachment for RecyclerView children and adds an EmptyView exception.
- Gate result was skipped before this run because no tests were detected in the PR.
- Inline review comments and code review found a compile blocker:
IMauiRecyclerView.IsShowingEmptyViewis referenced/implemented but not declared on the Core interface. - A remaining lifecycle edge case exists for item roots that attach with default
SafeAreaEdgesand later opt in while still attached.
Code Review Summary
Verdict: NEEDS_CHANGES
Confidence: high
Errors: 1 | Warnings: 1 | Suggestions: 0
Key code review findings:
- ❌
src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs:43andsrc/Core/src/Platform/Android/MauiWindowInsetListener.cs:127useIMauiRecyclerView.IsShowingEmptyView, butsrc/Core/src/Core/IMauiRecyclerView.csremains empty. ⚠️ src/Core/src/Platform/Android/MauiWindowInsetListener.cs:528only gates initial attachment; a later default-to-explicitSafeAreaEdgeschange on an already attached recycled item may still not install a listener.
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #35457 | Gate RecyclerView data-item listener attachment unless SafeAreaEdges is explicit; exempt EmptyView; keep reset lookups ungated. |
src/Core/src/Platform/Android/MauiWindowInsetListener.cs, src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs |
Original PR |
Code Review — Deep Analysis
Code Review — PR #35457
Independent Assessment
What this changes: Adds an Android RecyclerView-specific gate to window-inset listener attachment so default CollectionView item roots no longer receive safe-area listeners while EmptyView and explicitly customized SafeAreaEdges item roots remain eligible.
Inferred motivation: Prevent recycled CollectionView item views from retaining stale inset-derived padding that appears as an increasing bottom gap while preserving the SafeAreaEdges behavior added for explicit item-level opt-in.
Reconciliation with PR Narrative
Author claims: The PR fixes #34634 by reintroducing a RecyclerView guard only for initial listener attachment, keeping cleanup/reset paths ungated and exempting EmptyView; CI reports no tests detected.
Agreement/disagreement: The intent matches the code, but the implementation is currently inconsistent with the Core IMauiRecyclerView interface and should not compile. Existing inline review also flagged a remaining runtime transition concern for default-to-explicit SafeAreaEdges changes after attachment; that still appears plausible because _isInsetListenerSet remains false for initially skipped item roots.
Findings
❌ Error — IMauiRecyclerView.IsShowingEmptyView is not declared
src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs:43 explicitly implements IMauiRecyclerView.IsShowingEmptyView, and src/Core/src/Platform/Android/MauiWindowInsetListener.cs:127 reads mauiRecyclerView.IsShowingEmptyView. However, the Core interface at src/Core/src/Core/IMauiRecyclerView.cs is still empty in both origin/main and this PR. This is a concrete compile break (IsShowingEmptyView is not a member of the interface / explicit implementation target is missing). Add the member to the internal Core interface or avoid accessing the property through that interface.
⚠️ Warning — Runtime SafeAreaEdges opt-in after initial attach may still be skipped
src/Core/src/Platform/Android/MauiWindowInsetListener.cs:528 now gates only TrySetMauiWindowInsetListener, which fixes cleanup lookups, but an item root attached while its SafeAreaEdges equals the default will set _isInsetListenerSet to false in LayoutViewGroup/ContentViewGroup. If that same recycled/attached root later changes to explicit SafeAreaEdges, MapSafeAreaEdges can reset and mark layout, but the platform view still has no listener attached and the layout code only requests insets when _isInsetListenerSet is true. Existing review comments already raised this scenario; it should be resolved or explicitly ruled out.
Devil's Advocate
The compile finding is not speculative: I verified the PR does not modify src/Core/src/Core/IMauiRecyclerView.cs, and it remains empty. The runtime warning depends on property-change timing; if MAUI guarantees SafeAreaEdges is set before OnAttachedToWindow for all item roots, it may be acceptable, but bindings/styles and recycling make that guarantee worth confirming. I did not perform authenticated AzDO log analysis, but public GitHub check runs show maui-pr build failures, consistent with the compile issue.
Verdict: NEEDS_CHANGES
Confidence: high
Summary: The PR’s direction is reasonable for Android CollectionView recycling, but the current code references an undeclared internal interface member and should fail compilation. Public check runs show required maui-pr build failures, so this is not ready for LGTM.
Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| 1 | try-fix-1 | Make Android safe-area padding idempotent by applying computed insets relative to stored original padding. | 4 files | Broad global fix; simpler than RecyclerView policy but changes safe-area padding semantics beyond CollectionView. | |
| 2 | try-fix-2 | Keep listener attachment generic; skip/reset default RecyclerView item roots only when insets are applied, allowing later explicit SafeAreaEdges to work. | 1 file | Best alternative found. Avoids PR compile blocker and dynamic opt-in concern; tradeoff is default EmptyView roots inside RecyclerView also pass through. | |
| 3 | try-fix-3 | Reconcile/remove item-root inset listeners at CollectionView templated item bind time. | 2 files | Correct lifecycle layer, but per-bind listener churn is too expensive/risky for scrolling hot path. | |
| PR | PR #35457 | Gate RecyclerView data-item listener attachment unless SafeAreaEdges is explicit; exempt EmptyView; keep cleanup lookups ungated. | 2 files | Original PR currently references IMauiRecyclerView.IsShowingEmptyView without declaring it. |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| maui-expert-reviewer | 1 | Yes | Generated six design families: idempotent padding, ViewHolder recycle cleanup, bind-time attach, mapper-driven SafeAreaEdges attach/remove, native-view listener tags, and CollectionView-local opt-out markers. |
| gpt-5.5 orchestrator | 2 | Yes | Implemented three meaningfully different approaches from those families and fed compile/self-review results into later candidates. |
| gpt-5.5 orchestrator | 3 | No | Remaining unimplemented ideas require either new Android tag resource plumbing or a production-quality per-bind state marker plus regression tests; without device validation they would be trivial/riskier variants rather than demonstrably better candidates. |
Exhausted: Yes
Selected Fix: Candidate #2 (provisional) — It is the strongest alternative because it compiles, avoids the PR's missing-interface compile blocker, keeps cleanup discovery independent of mutable item state, and handles default-to-explicit SafeAreaEdges transitions better than attachment gating. It is not marked as a passing replacement because no Android UI/device regression test ran.
try-fix-1 Details
Approach: Idempotent SafeArea Padding Baseline
Safe-area padding is applied relative to each Android view's stored original padding instead of replacing with raw inset values. This explores the hypothesis that the visible gap is caused by stale/mutated padding state on recycled item roots, not by listener attachment policy.
Different from existing fix: The PR gates listener discovery/attachment for RecyclerView children. This candidate leaves listener discovery generic and changes safe-area padding application to be idempotent across all Android safe-area host views.
Diff
diff --git a/src/Core/src/Handlers/HybridWebView/HybridWebView.js b/src/Core/src/Handlers/HybridWebView/HybridWebView.js
index e4b9d2330c..3e5aa04f8a 100644
--- a/src/Core/src/Handlers/HybridWebView/HybridWebView.js
+++ b/src/Core/src/Handlers/HybridWebView/HybridWebView.js
@@ -5,22 +5,58 @@
* The JavaScript file is generated from TypeScript and should not be modified
* directly. To make changes, modify the TypeScript file and then recompile it.
*/
-(() => {
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+var __generator = (this && this.__generator) || function (thisArg, body) {
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
+ return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
+ function verb(n) { return function (v) { return step([n, v]); }; }
+ function step(op) {
+ if (f) throw new TypeError("Generator is already executing.");
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
+ if (y = 0, t) op = [op[0] & 2, t.value];
+ switch (op[0]) {
+ case 0: case 1: t = op; break;
+ case 4: _.label++; return { value: op[1], done: false };
+ case 5: _.label++; y = op[1]; op = [0]; continue;
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
+ default:
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
+ if (t[2]) _.ops.pop();
+ _.trys.pop(); continue;
+ }
+ op = body.call(thisArg, _);
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
+ }
+};
+(function () {
// Cached function to send messages to the host application.
- let sendMessageFunction = null;
+ var sendMessageFunction = null;
/*
* Initialize the HybridWebView messaging system.
* This method is called once when the page is loaded.
*/
function initHybridWebView() {
function dispatchHybridWebViewMessage(message) {
- const event = new CustomEvent('HybridWebViewMessageReceived', { detail: { message: message } });
+ var event = new CustomEvent('HybridWebViewMessageReceived', { detail: { message: message } });
window.dispatchEvent(event);
}
// Determine the mechanism to receive messages from the host application.
if (window.chrome && window.chrome.webview && window.chrome.webview.addEventListener) {
// Windows WebView2
- window.chrome.webview.addEventListener('message', (arg) => {
+ window.chrome.webview.addEventListener('message', function (arg) {
dispatchHybridWebViewMessage(arg.data);
});
}
@@ -28,29 +64,29 @@
// iOS and MacCatalyst WKWebView
// @ts-ignore - We are extending the global object here
window.external = {
- receiveMessage: (message) => {
+ receiveMessage: function (message) {
dispatchHybridWebViewMessage(message);
},
};
}
else {
// Android WebView
- window.addEventListener('message', (arg) => {
+ window.addEventListener('message', function (arg) {
dispatchHybridWebViewMessage(arg.data);
});
}
// Determine the function to use to send messages to the host application.
if (window.chrome && window.chrome.webview) {
// Windows WebView2
- sendMessageFunction = msg => window.chrome.webview.postMessage(msg);
+ sendMessageFunction = function (msg) { return window.chrome.webview.postMessage(msg); };
}
else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.webwindowinterop) {
// iOS and MacCatalyst WKWebView
- sendMessageFunction = msg => window.webkit.messageHandlers.webwindowinterop.postMessage(msg);
+ sendMessageFunction = function (msg) { return window.webkit.messageHandlers.webwindowinterop.postMessage(msg); };
}
else if (window.hybridWebViewHost) {
// Android WebView
- sendMessageFunction = msg => window.hybridWebViewHost.sendMessage(msg);
+ sendMessageFunction = function (msg) { return window.hybridWebViewHost.sendMessage(msg); };
}
}
/*
@@ -58,7 +94,7 @@
* The message is sent as a string with the following format: `<type>|<message>`.
*/
function sendMessageToDotNet(type, message) {
- const messageToSend = type + '|' + message;
+ var messageToSend = type + '|' + message;
if (sendMessageFunction) {
sendMessageFunction(messageToSend);
}
@@ -71,7 +107,7 @@
* The result is sent as a string with the following format: `<taskId>|<result-json>`.
*/
function invokeJavaScriptCallbackInDotNet(taskId, result) {
- const json = JSON.stringify(result);
+ var json = JSON.stringify(result);
sendMessageToDotNet('__InvokeJavaScriptCompleted', taskId + '|' + json);
}
/*
@@ -79,7 +115,7 @@
* The error message is sent as a string with the following format: `<taskId>|<JSInvokeError>`.
*/
function invokeJavaScriptFailedInDotNet(taskId, error) {
- let errorObj;
+ var errorObj;
if (!error) {
errorObj = {
Message: 'Unknown error',
@@ -105,7 +141,7 @@
StackTrace: Error().stack
};
}
- const json = JSON.stringify(errorObj);
+ var json = JSON.stringify(errorObj);
sendMessageToDotNet('__InvokeJavaScriptFailed', taskId + '|' + json);
}
/*
@@ -126,57 +162,68 @@
*
* @returns A promise that resolves with the result of the .NET method invocation.
*/
- async function invokeDotNet(methodName, paramValues) {
- const body = {
- MethodName: methodName
- };
- // if parameters were provided, serialize them first
- if (paramValues !== undefined) {
- if (!Array.isArray(paramValues)) {
- paramValues = [paramValues];
- }
- for (let i = 0; i < paramValues.length; i++) {
- paramValues[i] = JSON.stringify(paramValues[i]);
- }
- if (paramValues.length > 0) {
- body.ParamValues = paramValues;
- }
- }
- const message = JSON.stringify(body);
- // send the request to .NET
- const requestUrl = `${window.location.origin}/__hwvInvokeDotNet`;
- const rawResponse = await fetch(requestUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Accept': 'application/json',
- 'X-Maui-Invoke-Token': 'HybridWebView',
- 'X-Maui-Request-Body': message // Some platforms (Android) do not expose the POST body
- },
- body: message
+ function invokeDotNet(methodName, paramValues) {
+ return __awaiter(this, void 0, void 0, function () {
+ var body, i, message, requestUrl, rawResponse, response, error;
+ return __generator(this, function (_a) {
+ switch (_a.label) {
+ case 0:
+ body = {
+ MethodName: methodName
+ };
+ // if parameters were provided, serialize them first
+ if (paramValues !== undefined) {
+ if (!Array.isArray(paramValues)) {
+ paramValues = [paramValues];
+ }
+ for (i = 0; i < paramValues.length; i++) {
+ paramValues[i] = JSON.stringify(paramValues[i]);
+ }
+ if (paramValues.length > 0) {
+ body.ParamValues = paramValues;
+ }
+ }
+ message = JSON.stringify(body);
+ requestUrl = "".concat(window.location.origin, "/__hwvInvokeDotNet");
+ return [4 /*yield*/, fetch(requestUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'X-Maui-Invoke-Token': 'HybridWebView',
+ 'X-Maui-Request-Body': message // Some platforms (Android) do not expose the POST body
+ },
+ body: message
+ })];
+ case 1:
+ rawResponse = _a.sent();
+ return [4 /*yield*/, rawResponse.json()];
+ case 2:
+ response = _a.sent();
+ // a null response is a null response
+ if (!response) {
+ return [2 /*return*/, null];
+ }
+ // Check if the response indicates an error
+ if (response.IsError) {
+ error = new Error(response.ErrorMessage || 'Unknown error occurred in .NET method');
+ if (response.ErrorType) {
+ error.dotNetErrorType = response.ErrorType;
+ }
+ if (response.ErrorStackTrace) {
+ error.dotNetStackTrace = response.ErrorStackTrace;
+ }
+ throw error;
+ }
+ // deserialize if there is JSON data
+ if (response.IsJson) {
+ return [2 /*return*/, JSON.parse(response.Result)];
+ }
+ // otherwise return the primitive
+ return [2 /*return*/, response.Result];
+ }
+ });
});
- const response = await rawResponse.json();
- // a null response is a null response
- if (!response) {
- return null;
- }
- // Check if the response indicates an error
- if (response.IsError) {
- const error = new Error(response.ErrorMessage || 'Unknown error occurred in .NET method');
- if (response.ErrorType) {
- error.dotNetErrorType = response.ErrorType;
- }
- if (response.ErrorStackTrace) {
- error.dotNetStackTrace = response.ErrorStackTrace;
- }
- throw error;
- }
- // deserialize if there is JSON data
- if (response.IsJson) {
- return JSON.parse(response.Result);
- }
- // otherwise return the primitive
- return response.Result;
}
/*
* Invoke a JavaScript method from the .NET host application.
@@ -188,18 +235,30 @@
*
* @returns A promise.
*/
- async function invokeJavaScript(taskId, methodName, args) {
- try {
- const result = await methodName(...args);
- invokeJavaScriptCallbackInDotNet(taskId, result);
- }
- catch (ex) {
- console.error(ex);
- invokeJavaScriptFailedInDotNet(taskId, ex);
- }
+ function invokeJavaScript(taskId, methodName, args) {
+ return __awaiter(this, void 0, void 0, function () {
+ var result, ex_1;
+ return __generator(this, function (_a) {
+ switch (_a.label) {
+ case 0:
+ _a.trys.push([0, 2, , 3]);
+ return [4 /*yield*/, methodName.apply(void 0, args)];
+ case 1:
+ result = _a.sent();
+ invokeJavaScriptCallbackInDotNet(taskId, result);
+ return [3 /*break*/, 3];
+ case 2:
+ ex_1 = _a.sent();
+ console.error(ex_1);
+ invokeJavaScriptFailedInDotNet(taskId, ex_1);
+ return [3 /*break*/, 3];
+ case 3: return [2 /*return*/];
+ }
+ });
+ });
}
// Define the public API of the HybridWebView control.
- const HybridWebView = {
+ var HybridWebView = {
SendRawMessage: sendRawMessage,
InvokeDotNet: invokeDotNet,
__InvokeJavaScript: invokeJavaScript
@@ -210,3 +269,4 @@
// Initialize the HybridWebView
initHybridWebView();
})();
+//# sourceMappingURL=HybridWebView.js.map
\ No newline at end of file
diff --git a/src/Core/src/Platform/Android/ContentViewGroup.cs b/src/Core/src/Platform/Android/ContentViewGroup.cs
index ef7a195828..867ae425a7 100644
--- a/src/Core/src/Platform/Android/ContentViewGroup.cs
+++ b/src/Core/src/Platform/Android/ContentViewGroup.cs
@@ -241,7 +241,7 @@ namespace Microsoft.Maui.Platform
_hasStoredOriginalPadding = true;
}
- return SafeAreaExtensions.ApplyAdjustedSafeAreaInsetsPx(insets, CrossPlatformLayout, _context, view);
+ return SafeAreaExtensions.ApplyAdjustedSafeAreaInsetsPx(insets, CrossPlatformLayout, _context, view, _originalPadding);
}
void IHandleWindowInsets.ResetWindowInsets(View view)
diff --git a/src/Core/src/Platform/Android/LayoutViewGroup.cs b/src/Core/src/Platform/Android/LayoutViewGroup.cs
index b7e1361299..4b6964f353 100644
--- a/src/Core/src/Platform/Android/LayoutViewGroup.cs
+++ b/src/Core/src/Platform/Android/LayoutViewGroup.cs
@@ -212,7 +212,7 @@ namespace Microsoft.Maui.Platform
_hasStoredOriginalPadding = true;
}
- return SafeAreaExtensions.ApplyAdjustedSafeAreaInsetsPx(insets, CrossPlatformLayout, _context, view);
+ return SafeAreaExtensions.ApplyAdjustedSafeAreaInsetsPx(insets, CrossPlatformLayout, _context, view, _originalPadding);
}
void IHandleWindowInsets.ResetWindowInsets(View view)
diff --git a/src/Core/src/Platform/Android/MauiScrollView.cs b/src/Core/src/Platform/Android/MauiScrollView.cs
index bc92251800..16e8c5a1da 100644
--- a/src/Core/src/Platform/Android/MauiScrollView.cs
+++ b/src/Core/src/Platform/Android/MauiScrollView.cs
@@ -94,7 +94,7 @@ namespace Microsoft.Maui.Platform
_hasStoredOriginalPadding = true;
}
- return SafeAreaExtensions.ApplyAdjustedSafeAreaInsetsPx(insets, CrossPlatformLayout, _context, view);
+ return SafeAreaExtensions.ApplyAdjustedSafeAreaInsetsPx(insets, CrossPlatformLayout, _context, view, _originalPadding);
}
diff --git a/src/Core/src/Platform/Android/SafeAreaExtensions.cs b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
index 1b870c3e08..59458d6b98 100644
--- a/src/Core/src/Platform/Android/SafeAreaExtensions.cs
+++ b/src/Core/src/Platform/Android/SafeAreaExtensions.cs
@@ -43,7 +43,8 @@ internal static class SafeAreaExtensions
WindowInsetsCompat windowInsets,
ICrossPlatformLayout crossPlatformLayout,
Context context,
- View view)
+ View view,
+ (int left, int top, int right, int bottom) originalPadding)
{
WindowInsetsCompat? newWindowInsets;
var baseSafeArea = windowInsets.ToSafeAreaInsetsPx(context);
@@ -108,7 +109,7 @@ internal static class SafeAreaExtensions
{
if (left == 0 && right == 0 && top == 0 && bottom == 0)
{
- view.SetPadding(0, 0, 0, 0);
+ view.SetPadding(originalPadding.left, originalPadding.top, originalPadding.right, originalPadding.bottom);
return windowInsets;
}
@@ -288,7 +289,11 @@ internal static class SafeAreaExtensions
newWindowInsets = builder.Build();
// Apply all insets to content view group
- view.SetPadding((int)left, (int)top, (int)right, (int)bottom);
+ view.SetPadding(
+ originalPadding.left + (int)left,
+ originalPadding.top + (int)top,
+ originalPadding.right + (int)right,
+ originalPadding.bottom + (int)bottom);
if (left > 0 || right > 0 || top > 0 || bottom > 0)
{
globalWindowInsetsListener?.TrackView(view);
Test Results
Blocked: Android Core build passed, but no PR tests were detected and no Android UI/device regression command was available.
Failure Analysis
Not a test failure; empirical validation is blocked before original-repro execution.
try-fix-2 Details
Approach: Pass Through Default RecyclerView Item Insets
Keep listener attachment and listener discovery generic, but when a window-inset callback is delivered to a RecyclerView item root with default SafeAreaEdges, reset any tracked padding and return the incoming insets unchanged. Explicit item-level SafeAreaEdges still flows through the normal IHandleWindowInsets path because the listener is already attached.
Different from existing fix: The PR skips listener attachment/discovery for default RecyclerView item roots and adds an EmptyView flag to IMauiRecyclerView. This candidate attaches the listener normally and gates only the actual inset application, avoiding the compile-breaking interface addition and allowing later default-to-explicit SafeAreaEdges transitions without detach/reattach.
Diff
diff --git a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs
index 943a1330cf..b0afec5e85 100644
--- a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs
+++ b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs
@@ -201,6 +201,12 @@ namespace Microsoft.Maui.Platform
_pendingView = null;
+ if (ShouldSkipRecyclerViewItemInsets(v))
+ {
+ ResetView(v);
+ return insets;
+ }
+
// Handle custom inset views first
if (v is IHandleWindowInsets customHandler)
{
@@ -211,6 +217,33 @@ namespace Microsoft.Maui.Platform
return ApplyDefaultWindowInsets(v, insets);
}
+ static bool ShouldSkipRecyclerViewItemInsets(AView view)
+ {
+ if (view is MaterialToolbar || HasExplicitSafeAreaEdges(view))
+ {
+ return false;
+ }
+
+ var parent = view.Parent;
+ while (parent is not null)
+ {
+ if (parent is RecyclerView)
+ {
+ return true;
+ }
+
+ parent = parent.Parent;
+ }
+
+ return false;
+ }
+
+ static bool HasExplicitSafeAreaEdges(AView view)
+ {
+ return view is ICrossPlatformLayoutBacking { CrossPlatformLayout: ISafeAreaElement safeAreaElement } &&
+ safeAreaElement.SafeAreaEdges != safeAreaElement.SafeAreaEdgesDefaultValueCreator();
+ }
+
static WindowInsetsCompat? ApplyDefaultWindowInsets(AView v, WindowInsetsCompat insets)
{
var systemBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars());
Test Results
Blocked: Android Core build passed, but no PR tests were detected and no Android UI/device regression command was available.
Failure Analysis
Not a test failure; empirical validation is blocked before original-repro execution.
try-fix-3 Details
Approach: CollectionView Bind-Time Listener Reconciliation
Treat stale safe-area padding as recycled ViewHolder state. On each templated item bind, the Android item container removes any existing window-inset listener/padding from the item root, then reattaches the listener only when the currently bound root has explicit SafeAreaEdges.
Different from existing fix: The PR changes Core listener discovery for all RecyclerView children and adds an EmptyView property to IMauiRecyclerView. This candidate keeps Core discovery unchanged and moves the policy to the CollectionView item binding lifecycle, where recycled item state is known.
Diff
diff --git a/src/Controls/src/Core/Handlers/Items/Android/ItemContentView.cs b/src/Controls/src/Core/Handlers/Items/Android/ItemContentView.cs
index c07bbfa766..d55dcb709d 100644
--- a/src/Controls/src/Core/Handlers/Items/Android/ItemContentView.cs
+++ b/src/Controls/src/Core/Handlers/Items/Android/ItemContentView.cs
@@ -3,6 +3,7 @@ using System;
using Android.Content;
using Android.Views;
using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Platform;
using AView = Android.Views.View;
namespace Microsoft.Maui.Controls.Handlers.Items
@@ -83,6 +84,23 @@ namespace Microsoft.Maui.Controls.Handlers.Items
_pixelSize = size;
}
+ internal void ReconcileWindowInsetListener()
+ {
+ var platformView = PlatformView;
+ if (platformView is null)
+ {
+ return;
+ }
+
+ platformView.RemoveMauiWindowInsetListener(Context);
+
+ if (View is not ISafeAreaElement safeAreaElement ||
+ safeAreaElement.SafeAreaEdges != safeAreaElement.SafeAreaEdgesDefaultValueCreator())
+ {
+ platformView.TrySetMauiWindowInsetListener(Context);
+ }
+ }
+
protected override void OnLayout(bool changed, int l, int t, int r, int b)
{
if (Content == null)
diff --git a/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs b/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs
index 184d53455b..973d32b2f0 100644
--- a/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs
+++ b/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs
@@ -94,6 +94,7 @@ namespace Microsoft.Maui.Controls.Handlers.Items
View.BindingContext = itemBindingContext;
}
+ _itemContentView.ReconcileWindowInsetListener();
itemsView.AddLogicalChild(View);
}
Test Results
Blocked: Android Controls build passed, but no PR tests were detected and no Android UI/device regression command was available.
Failure Analysis
Design is not selected because self-review found a major hot-path listener churn concern.
Report — Final Recommendation
Comparative Report — PR #35457
Candidate ranking
| Rank | Candidate | Result | Rationale |
|---|---|---|---|
| 1 | pr-plus-reviewer |
Best | Fixes the raw PR compile blocker, retries listener attachment after safe-area configuration changes, preserves the PR's intended EmptyView behavior, and builds for Android Controls. |
| 2 | try-fix-2 |
Strong alternative | Builds and avoids both raw PR issues by gating inset application instead of listener attachment. It is simpler and one-file, but it intentionally does not preserve the PR's EmptyView-specific behavior. |
| 3 | try-fix-1 |
Viable but broad | Builds and makes safe-area padding idempotent, but changes Android safe-area padding semantics beyond CollectionView/RecyclerView and has a larger behavioral surface. |
| 4 | try-fix-3 |
Risky | Builds, but self-review found a major scrolling hot-path concern from per-bind listener reconciliation/churn. |
| 5 | pr |
Not acceptable as-is | Raw PR has a compile blocker (IMauiRecyclerView.IsShowingEmptyView is not declared) and a lifecycle gap for default-to-explicit SafeAreaEdges changes on attached recycled item roots. |
Regression-test rule
No candidate had a passing Android regression test because the gate was skipped and no device/UI reproduction command was available. No candidate is ranked above another on the basis of a failed regression test; the ranking is based on build viability, expert review findings, behavioral scope, and lifecycle correctness.
Winner
pr-plus-reviewer is the winning candidate. It keeps the PR's targeted design, adds the missing internal interface member, and reattempts safe-area listener attachment after actual safe-area configuration changes without retrying forever on every default item layout.
Test recommendation
The PR should add Android coverage or at least document manual validation for a CollectionView/RefreshView scrolling scenario with many items, EmptyView, and a recycled item whose root changes from default to explicit SafeAreaEdges.
Future Action — review latest findings
No alternative fix was selected for this run. Review the session findings and CI results before merging.
kubaflo
left a comment
There was a problem hiding this comment.
The build is failing, and can you check the ai's suggestions?
|
Closing in favor of PR #35664, which addresses the same root cause with a more complete implementation. |
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!
Root Cause
While scrolling long CollectionView content on Android, an extra bottom gap appears and progressively increases during scrolling. This regression was introduced by PR #33908, which removed the
IMauiRecyclerViewexclusion inMauiWindowInsetListener.FindListenerForViewso item templates could participate in safe-area inset handling.As a side effect, RecyclerView item views began receiving the window-inset listener by default, allowing safe-area padding to be applied on item roots. Since RecyclerView reuses item views during scrolling, recycled views could retain stale inset-derived padding from previous bindings, resulting in a progressively growing bottom gap.
Description of Change
Reintroduced the
IMauiRecyclerViewguard inMauiWindowInsetListener.FindListenerForView, while preserving the per-itemSafeAreaEdgescapability added in PR #33908 for explicitly opted-in item roots.MauiWindowInsetListener.csFindListenerForView— when the parent is anIMauiRecyclerView, the inset listener is skipped unless the item root explicitly customizesSafeAreaEdges. This prevents recycled-item padding drift while preserving intentional safe-area scenarios.HasExplicitSafeAreaEdges(new helper) — returnstrueonly when the platform view maps to anISafeAreaElementwhose currentSafeAreaEdgesdiffers from its default value, indicating an explicit developer opt-in. Default values are cached in a staticConcurrentDictionary<Type, SafeAreaEdges>usingGetOrAddto avoid repeated computation or per-call allocations. The comparison usesIEquatable<SafeAreaEdges>.Equals(...)on a hoisted local value to avoid boxing, repeated property reads, and to remain stable for futureSafeAreaEdgestype evolution.Issues Fixed
Fixes #34634
Tested the behaviour in the following platforms
Regression
This regression was introduced by PR #33908.
Screenshots
BeforeFix.mov
AfterFix.mov