Repository navigation
[Android] SafeArea: Apply insets to views attached during IME animations - #37049
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 37049Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 37049" |
|
Azure Pipelines: Successfully started running 1 pipeline(s). There may be pipelines that require an authorized user to comment /azp run to run. |
|
Hey there @@albyrock87! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
|
Hey there @albyrock87! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
|
/azp run maui-pr |
|
Commenter does not have sufficient privileges for PR 37049 in repo dotnet/maui |
MauiWindowInsetListener gated every insets dispatch while an IME animation was in flight, so page platform views (re)attached during the keyboard hide animation laid out with zero safe-area padding for the whole animation and visibly jumped when it ended. Views that start using the listener while an IME animation is running now bypass the gate for the remainder of that animation, and all gated views (not just the last one) get RequestApplyInsets when it ends. Fixes dotnet#37012 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3dbe24e to
9d9d947
Compare
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 8 findings
See inline comments for details.
| { | ||
| if (insets is null || !insets.HasInsets || v is null || IsImeAnimating) | ||
| if (insets is null || !insets.HasInsets || v is null || | ||
| (IsImeAnimating && !_viewsAttachedDuringImeAnimation.Contains(v))) |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Safe Area and Window Insets — An exempted view can apply mid-IME-animation insets and has no guaranteed final correction.
When v is exempt, the dispatch falls through to HandleWindowInsets → SafeAreaExtensions.ApplyAdjustedSafeAreaInsetsPx, which computes isKeyboardShowing = !windowInsets.GetKeyboardInsetsPx(context).IsEmpty (SafeAreaExtensions.cs:50-51). GetKeyboardInsetsPx just reads insets.GetInsets(Type.Ime()).Bottom (SafeAreaPadding.cs:41-42), which is still non-zero during an IME hide animation — exactly why the IsImeAnimating gate exists per the new comment on line 39. For SafeAreaEdges.All, SafeAreaEdges.IsSoftInput returns true (SafeAreaEdges.cs:76-77), so GetSafeAreaForEdge returns keyBoardInsets.Bottom for edge 3 (SafeAreaExtensions.cs:328-332) and view.SetPadding(..., bottom) (line 291) bakes in the keyboard height.
The problem is that the exempt path then falls to line 270 and removes v from _pendingViews, and OnEnd clears _viewsAttachedDuringImeAnimation (line 521) before snapshotting. So the one class of view that can apply transient IME insets is the one class deliberately excluded from the OnEnd re-apply snapshot (lines 525-533). Correction depends entirely on some later system dispatch arriving after IsImeAnimating flips false on line 529 — and per the separate finding on line 521, a dispatch landing before that runnable executes is dropped.
Concrete scenario: this PR's own repro — Page B has the keyboard open, user taps "hide + pop", Page A (SafeAreaEdges.All) re-attaches mid-hide-animation, is exempted, and gets bottom padding == IME height that outlives the keyboard. The new UI test only samples platformView.PaddingTop (Issue37012.cs:85, 117), so it cannot catch this.
Suggested fix: on the exempt path, _pendingViews.Add(v) instead of Remove(v), so OnEnd always re-applies final insets to views that were allowed through the gate.
There was a problem hiding this comment.
Fixed in 8c29733, exactly as suggested: on the exempt path the view is now kept in _pendingViews instead of removed, so every view allowed through the gate gets a final RequestApplyInsets when the animation ends, correcting any transient animation-time IME padding it applied. Covered end-to-end: the HostApp page now also captures PaddingBottom and asserts it equals the non-IME insets after the keyboard settles, so a leaked keyboard-height bottom padding fails the UI test.
| if (IsImeAnimation(animation)) | ||
| { | ||
| if (_pendingView is AView view) | ||
| _viewsAttachedDuringImeAnimation.Clear(); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Logic and Correctness — OnEnd tears down the gate state synchronously but clears the gate flag asynchronously, so dispatches arriving in between are silently dropped.
Lines 521-526 run immediately: exemptions are cleared, _pendingViews is snapshotted into pendingViews, then cleared. But IsImeAnimating = false only runs on line 529 inside the runnable posted on line 527, i.e. on a later main-looper turn. Any OnApplyWindowInsets arriving in that window sees IsImeAnimating == true and an empty exemption set, takes the early return at lines 259-267, and is added to a _pendingViews collection that has already been drained. Nothing ever flushes it — the runnable iterates only the pendingViews snapshot (lines 530-533), so the view keeps stale animation-time padding until some unrelated inset change occurs.
Concrete scenario: with DispatchModeStop the system's deferred post-animation inset dispatch (IME = 0) is precisely what lands right after OnEnd returns, which is the highest-probability occupant of this window. That is the same class of stale-padding bug #37012 fixes, recreated in a narrower window.
Suggested fix: don't snapshot/clear before posting — clear the flag first inside the runnable and then drain the live collection:
poster.Post(() =>
{
IsImeAnimating = false;
var views = _pendingViews.ToArray();
_pendingViews.Clear();
foreach (var view in views)
ViewCompat.RequestApplyInsets(view);
});There was a problem hiding this comment.
Fixed in 8c29733 with your suggested shape: OnEnd no longer snapshots and clears before posting — EndImeAnimation runs on the posted turn, clears IsImeAnimating first, then drains the live _pendingViews. A dispatch landing in the OnEnd→runnable window is added to the live set and flushed by the same runnable instead of being dropped into a drained collection.
| view.Post(() => | ||
| var pendingViews = _pendingViews.ToArray(); | ||
| _pendingViews.Clear(); | ||
| pendingViews[0].Post(() => |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[major] Async and Threading Safety — pendingViews[0] is an arbitrary HashSet element, yet it is made solely responsible for un-sticking a listener-wide latch.
HashSet<AView>.ToArray() has no defined ordering contract, so [0] is whichever view happens to enumerate first — in practice the earliest-inserted, which in the navigation scenario this PR targets is the most likely to have been detached already. Android.Views.View.Post on a view with no AttachInfo does not run the action on the current looper turn: it is parked in the view's internal HandlerActionQueue and only executes if/when that view is re-attached. If it never re-attaches, line 529 never runs, IsImeAnimating stays true permanently, and from then on every view sharing this listener takes the early return at lines 259-267 — permanent loss of safe-area updates for the whole subtree (e.g. the NavigationPage root registered at NavigationRootManager.cs:84 is shared by all its pages).
The pre-PR code posted to _pendingView, i.e. the most recently gated view; switching to an arbitrary element of a now-multi-element set is a determinism regression on exactly the code path that owns the latch.
Suggested fix: pick a poster deterministically and defensively, e.g. var poster = Array.Find(pendingViews, v => v.IsAttachedToWindow); and if none is attached, clear IsImeAnimating synchronously rather than leaving the latch set.
There was a problem hiding this comment.
Fixed in 8c29733: the release is now posted through the first pending view that is alive and IsAttachedToWindow; when none qualifies (or nothing is pending) the gate is released synchronously, so a detached view's parked Post can never leave IsImeAnimating latched for the whole subtree. Pinned by a new device test, GateReleasesSynchronouslyWhenNoPendingViewIsAttached, which fails on the previous pendingViews[0].Post implementation.
| ViewCompat.RequestApplyInsets(view); | ||
| foreach (var view in pendingViews) | ||
| { | ||
| ViewCompat.RequestApplyInsets(view); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Null Safety and Defensive Coding — No disposed-peer guard on the deferred RequestApplyInsets loop; one bad view now aborts all the others.
This loop runs on a later main-looper turn than OnEnd, over N views captured earlier. A view detached and disposed in between is very plausible here — the scenario this PR fixes is a page pop racing an IME animation. Such a view has Handle == IntPtr.Zero and ViewCompat.RequestApplyInsets(view) throws ObjectDisposedException when marshalling the peer. With no guard, the exception aborts the foreach, so every view after the disposed one silently never gets its insets re-applied, and the exception is unhandled inside a View.post runnable on the main looper.
The pre-PR code handled a single view so the blast radius was one; batching to N widens both the crash surface and the silent-skip surface. Per MAUI Android convention, check the peer and wrap the fire-and-forget platform call:
foreach (var view in pendingViews)
{
if (view.Handle == IntPtr.Zero)
continue;
try { ViewCompat.RequestApplyInsets(view); }
catch (ObjectDisposedException) { }
}There was a problem hiding this comment.
Fixed in 8c29733 as suggested: the flush loop skips views with Handle == IntPtr.Zero and wraps RequestApplyInsets in a catch (ObjectDisposedException), so one disposed peer can neither crash the main-looper runnable nor silently starve the remaining views of their re-apply.
| { | ||
| ViewCompat.SetOnApplyWindowInsetsListener(view, listener); | ||
| ViewCompat.SetWindowInsetsAnimationCallback(view, listener); | ||
| listener.NotifyViewAttached(view); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Logic and Correctness — RefreshMauiWindowInsetListener is not an attach path, so this grants the gate bypass to views that are already attached and already correctly padded.
This method is called from ContentViewGroup.MarkSafeAreaEdgeConfigurationChanged (ContentViewGroup.cs:176), LayoutViewGroup.cs:193 and MauiScrollView.cs:388, all of which are driven by the SafeAreaEdges mapper in ViewHandler.Android.cs:277-287 — i.e. a property change on a live, attached view. That contradicts the XML doc added on lines 243-245 ("a view has (re)attached to the window") and the field comment on lines 38-41 ("a view that just (re)attached has no valid safe-area padding yet"), neither of which holds for this call site.
Concrete scenario: an app binds SafeAreaEdges (or toggles it from a PropertyChanged handler) and it changes while the keyboard is animating away. The already-padded view is exempted for the rest of the animation and re-pads itself from mid-animation IME insets (see the note on line 260), producing visible padding flicker — the exact instability the gate exists to prevent. Note RemoveMauiWindowInsetListener → ResetView is the only thing that revokes the exemption, and the refresh path does not call it on the still-eligible branch.
Suggested fix: only notify when the view actually just attached (e.g. call the notify from TrySetMauiWindowInsetListener only, or gate on view.IsAttachedToWindow transition), or state explicitly in the comment that config-change refreshes are intended to bypass the gate.
There was a problem hiding this comment.
Fixed in 8c29733: RefreshMauiWindowInsetListener no longer calls NotifyViewAttached — you're right that it's a SafeAreaEdges config-change path on a live, already-padded view, so it must stay subject to the gate (with finding #1's fix it still gets its updated insets at the end of any in-flight animation via the pending-set flush). The exemption is now granted only by TrySetMauiWindowInsetListener, whose call sites are genuine attach paths, and a comment at the refresh site documents the asymmetry so it isn't "fixed" back later.
| // Mirror the issue repro: request the keyboard hide, then swap pages while the | ||
| // IME hide animation is still in flight. The short delay guarantees the hide | ||
| // animation has started before Page A's platform views re-attach. | ||
| _ = entry.HideSoftInputAsync(CancellationToken.None); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Regression Prevention and Test Coverage — The repro's precondition is never verified, so the test can degrade into a false pass without anyone noticing.
The bug only reproduces if Page A re-attaches while IsImeAnimating is still true, and the only thing enforcing that is await Task.Delay(50) on line 164. On a loaded CI emulator the continuation can resume long after 50 ms; if the IME hide animation has already completed by the time PopAsync re-attaches Page A, the pre-fix gate at MauiWindowInsetListener.OnApplyWindowInsets never engages, padding is applied normally, and the test reports Success against unfixed code. Gate verification shows it discriminates today, but nothing in the test asserts the in-flight condition, so silent decay is invisible — this is the "gesture/timing tests must verify their precondition" rule.
Make the condition observable: have Page A record (and the result label report) that the IME animation was actually running at re-attach time, so a run where the animation had already finished reports Inconclusive rather than Success.
Separately, line 163 _ = entry.HideSoftInputAsync(CancellationToken.None) is a bare fire-and-forget — a fault is swallowed and surfaces later as an unrelated-looking padding mismatch. Use .FireAndForget() per MAUI convention.
There was a problem hiding this comment.
Fixed in 8c29733: the precondition is now observable — Page B installs a WindowInsetsAnimationCompat.Callback on the activity decor view (DispatchModeContinueOnSubtree, so normal child dispatch is untouched; removed again when the scenario completes since the HostApp process is shared), and Page A records whether the IME animation was actually running at re-attach. A run that loses the race reports Inconclusive: IME animation completed before re-attach — the UI test retries up to 3 times and then reports Assert.Inconclusive, never a false Success. The bare fire-and-forget is gone too: the hide call is awaited (HideSoftInputAsync completes when the request is issued, so the timing of the race is unaffected). Verified on the emulator: with the fix the first attempt is conclusive-and-green; on the pre-fix listener the same run reports Fail: first frame top=0 settled top=156 — so the precondition held in both directions.
| var preDrawListener = new FirstPreDrawListener(platformView, padding => firstDrawPadding = padding); | ||
| platformView.ViewTreeObserver.AddOnPreDrawListener(preDrawListener); | ||
|
|
||
| Dispatcher.StartTimer(TimeSpan.FromMilliseconds(800), () => |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Regression Prevention and Test Coverage — Fixed-time sampling plus a device-dependent assertion can produce false failures.
Dispatcher.StartTimer(TimeSpan.FromMilliseconds(800), ...)is an arbitrary wait asserted by the comment on lines 66-68 to be "once the IME hide animation has certainly completed". On a slow CI emulator the hide animation plus the follow-up inset dispatch can exceed 800 ms, sofinalPadding(line 85) is sampled mid-transition and the test reportsFaileven when the fix works. MAUI convention is to poll for a stable value rather than sample once at a hard-coded delay.- Line 87 requires
finalPadding > 0. The page setsNavigationPage.SetHasNavigationBar(this, false), so top padding comes purely from the device status-bar / cutout inset. On an emulator image configured without one,finalPadding == 0and the test fails regardless of the fix. Report that case distinctly (e.g.Inconclusive: no top inset on this device) instead ofFail. - The capture samples only
PaddingTop(lines 85 and 117). The bottom edge is where the new exemption introduces the stale-keyboard-padding risk flagged atMauiWindowInsetListener.cs:260; capturingPaddingBottomas well would make this test guard the change it is shipping with.
There was a problem hiding this comment.
Fixed in 8c29733, all three points: (1) the fixed 800ms sample is replaced by a polling loop (100ms × up to 4s) that waits until the tracker reports no IME animation, the root insets report the IME hidden, and the padding is stable for 3 consecutive samples; (2) a device without a top inset now reports Inconclusive: no top inset on this device (surfaced as Assert.Inconclusive, not Fail); (3) PaddingBottom is captured on the first frame and after settling, and the settled value is asserted equal to the non-IME insets (SystemBars | DisplayCutout bottom) — which is exactly the guard for the stale-keyboard-padding risk flagged at MauiWindowInsetListener.cs:260.
|
|
||
| [Test] | ||
| [Category(UITestCategories.SafeAreaEdges)] | ||
| public void SafeAreaPaddingAppliedToPageReattachedDuringImeHideAnimation() |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Regression Prevention and Test Coverage — The new gate bypass is only tested in its positive direction, and a lighter, deterministic test tier is available.
The change adds an exemption that lets some views skip a safety gate. Per MAUI review policy a new guard/branch needs a test for the input that must not trip it. Missing:
- Negative case — a view already attached before the IME animation started must still be gated (deferred into
_pendingViews) and must not re-pad mid-animation. Nothing asserts this, so a future change that exempted every tracked view would go undetected. - Exemption lifetime — nothing asserts
_viewsAttachedDuringImeAnimationis cleared atStartImeAnimation(MauiWindowInsetListener.cs:491) and atOnEnd(line 521), so exemptions leaking from animation N into N+1 would be invisible. - Multi-view drain — the headline change is
_pendingView→_pendingViewsso that all gated views getRequestApplyInsets. This test exercises one view, so the N>1 behaviour — including thependingViews[0]poster choice flagged at MauiWindowInsetListener.cs:527 — is untested.
All three are cheap and fully deterministic as an Android device test driving the listener directly (OnPrepare → NotifyViewAttached → OnApplyWindowInsets → OnEnd) with no emulator IME timing involved. There is precedent for reaching these internals from device tests in src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Android.cs:278-396,633. Keep this UI test as a thin end-to-end smoke check and move the discriminating assertions down a tier.
There was a problem hiding this comment.
Fixed in 8c29733: added MauiWindowInsetListenerTests.Android.cs (Controls device tests, following the CollectionView precedent) driving the listener directly with an IHandleWindowInsets recording stub — fully deterministic, no IME timing: (1) negative case — a view tracked before the animation stays gated while a NotifyViewAttached view passes through; (2) exemption lifetime — an exemption granted in animation N does not survive into animation N+1; (3) multi-view drain — with two gated attached views, the posted release opens the gate for all of them; plus a stuck-latch regression test for the poster issue at line 527 that fails on the previous implementation. The UI test remains the end-to-end smoke check, now precondition-verified.
This comment has been minimized.
This comment has been minimized.
kubaflo
left a comment
There was a problem hiding this comment.
Looks good! Could you please check if the ai's suggestions make sense?
MauiWindowInsetListener: - An exempted view processed mid-animation may have applied transient animation-time IME insets; it now stays in _pendingViews so the end of the animation re-applies the final insets. - OnEnd no longer snapshots and clears the pending set before posting the gate release: EndImeAnimation clears the flag and drains the live set, so dispatches gated between OnEnd and the posted runnable are flushed instead of silently dropped. - The release is posted through the first *attached* pending view; if none is attached it runs synchronously — a detached view's Post only executes on re-attach, which could leave the gate stuck for the whole subtree. - The flush loop skips disposed peers and swallows ObjectDisposedException so one dead view cannot abort the re-apply of the others. - RefreshMauiWindowInsetListener no longer grants the gate exemption: it is a SafeAreaEdges config change on a live, already-padded view, not an attach. Tests: - Issue37012 HostApp page now verifies its own precondition via a decor-view IME animation tracker (reports Inconclusive when the pop didn't race the animation), awaits the hide call, polls until the IME is gone and padding is stable instead of sampling at a fixed delay, reports a distinct Inconclusive for devices without a top inset, and captures PaddingBottom to catch stale keyboard-height padding. - The UI test retries inconclusive attempts and reports Assert.Inconclusive rather than a false result. - New MauiWindowInsetListenerTests (Android device tests) drive the listener directly: negative gating for views tracked before the animation, exemption lifetime across animations, synchronous gate release when no pending view is attached (stuck-latch regression), and multi-view drain. Verified on the Android emulator: UI test passes with the fix and fails with "first frame top=0" on the pre-fix listener (precondition tracker confirms the mid-animation re-attach in both runs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@kubaflo I went through all eight AI suggestions — every one of them identified a real issue, and all are addressed in 8c29733 (details in the per-comment replies):
Verified on the Android emulator: the UI test passes first-attempt-conclusive with the fix and fails with |
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 1 findings
See inline comments for details.
| // IsAlive covers null as well as a peer disposed during the one-looper-turn delay. | ||
| // Leave the flag set when we cannot act on it: the re-apply is still owed, and | ||
| // consuming it here would drop the settled insets entirely. | ||
| if (!reapplyThrough.IsAlive()) |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
[moderate] Safe Area and Window Insets / Logic and Correctness — The owed re-apply can be silently consumed and dropped when the poster detaches during the one-looper-turn delay.
OnEnd deliberately validates attachment (FindAttachedTrackedView requires IsAlive() && IsAttachedToWindow) before posting the gate release, and the comment there explains why an unattached poster is unusable. But EndImeAnimation runs a looper turn later and only re-checks IsAlive() — i.e. that the Java peer was not disposed. Attachment is not re-checked, even though it is the property that actually matters for the call being made.
Concrete scenario: an IME hide animation ends while a page is being popped. OnEnd finds an attached tracked view and posts the release. Before the runnable executes, that view is detached (OnDetachedFromWindow) but not disposed — IsAlive() is still true. The runnable then sets _reapplyInsetsWhenAnimationEnds = false and calls ViewCompat.RequestApplyInsets(reapplyThrough), which for a view with no AttachInfo does not reach the ViewRootImpl and produces no inset dispatch. Every view gated during that animation keeps its animation-time padding (e.g. keyboard-height bottom padding, or missing top safe-area padding) until some unrelated inset event happens to arrive — which is the exact failure mode this PR is fixing.
This also contradicts the intent documented two lines above ("Leave the flag set when we cannot act on it: the re-apply is still owed, and consuming it here would drop the settled insets entirely") — the flag is consumed precisely in a case where the code cannot act on it.
Suggested change: gate on attachment too, so the flag stays set and the re-apply remains owed for the next animation end (which is what ReapplyStaysOwedWhenTheAnimationEndsWithNoUsablePoster already asserts for the null-poster case):
if (!reapplyThrough.IsAlive() || !reapplyThrough.IsAttachedToWindow)
{
return;
}or re-run FindAttachedTrackedView() here to pick a still-attached poster before giving up.
Coverage gap: the new device tests cover the null-poster case (ReapplyStaysOwedWhenTheAnimationEndsWithNoUsablePoster) and the alive-and-attached case (GatedDispatchesTriggerOneReapplyWhenTheAnimationEnds), but not "poster was attached at OnEnd and detached before the posted runnable ran". That case is easy to add deterministically with the existing container.RemoveView(...) pattern between OnEnd and NextLooperTurnAsync.
MauiBot
left a comment
There was a problem hiding this comment.
AI Review Summary
@albyrock87 — new AI review results are available based on this last commit:
1a0c9d3.
🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix
Gate Result: ✅ PASSED
Platform: ANDROID · Base: main · Merge base: f27ca83a
✅ Verified (new API / feature) — this PR adds new API and a test that references it in the same project, so reverting the fix un-compiles the test: there is no valid "fails without the fix" baseline to establish (a compile-coupled baseline). The gate instead verified the fix by a clean build + pass with the fix, so this is a real PASS rather than a non-committal INCONCLUSIVE.
| Test | Without Fix (expect FAIL) | With Fix (expect PASS) |
|---|---|---|
📱 MauiWindowInsetListenerTests (ViewTrackedBeforeImeAnimationStaysGatedAndAttachedViewBypassesGateOnce, GateExemptionIsClearedWhenTheAnimationEnds, GateReleasesSynchronouslyWhenNoAttachedViewIsAvailable, ReapplyStaysOwedWhenTheAnimationEndsWithNoUsablePoster, GatedDispatchesTriggerOneReapplyWhenTheAnimationEnds, StaleGateReleaseDoesNotOpenTheGateOfTheNextAnimation) Category=Window |
🛠️ BUILD ERROR | ✅ PASS — 411s |
🖥️ Issue37012 Issue37012 |
✅ FAIL — 2191s | ✅ PASS — 623s |
🔴 Without fix — 📱 MauiWindowInsetListenerTests (ViewTrackedBeforeImeAnimationStaysGatedAndAttachedViewBypassesGateOnce, GateExemptionIsClearedWhenTheAnimationEnds, GateReleasesSynchronouslyWhenNoAttachedViewIsAvailable, ReapplyStaysOwedWhenTheAnimationEndsWithNoUsablePoster, GatedDispatchesTriggerOneReapplyWhenTheAnimationEnds, StaleGateReleaseDoesNotOpenTheGateOfTheNextAnimation): 🛠️ BUILD ERROR · 409s
Error-relevant lines (filtered from the build log):
/home/vsts/work/1/s/src/Controls/tests/DeviceTests/Elements/Window/MauiWindowInsetListenerTests.Android.cs(88,14): error CS1061: 'MauiWindowInsetListener' does not contain a definition for 'NotifyViewAttached' and no accessible extension method 'NotifyViewAttached' accepting a first argument of type 'MauiWindowInsetListener' could be found (are you missing a using directive or an assembly reference?) [/home/vsts/work/1/s/src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/src/Controls/tests/DeviceTests/Elements/Window/MauiWindowInsetListenerTests.Android.cs(111,14): error CS1061: 'MauiWindowInsetListener' does not contain a definition for 'NotifyViewAttached' and no accessible extension method 'NotifyViewAttached' accepting a first argument of type 'MauiWindowInsetListener' could be found (are you missing a using directive or an assembly reference?) [/home/vsts/work/1/s/src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj::TargetFramework=net10.0-android]
Build FAILED.
🟢 With fix — 📱 MauiWindowInsetListenerTests (ViewTrackedBeforeImeAnimationStaysGatedAndAttachedViewBypassesGateOnce, GateExemptionIsClearedWhenTheAnimationEnds, GateReleasesSynchronouslyWhenNoAttachedViewIsAvailable, ReapplyStaysOwedWhenTheAnimationEndsWithNoUsablePoster, GatedDispatchesTriggerOneReapplyWhenTheAnimationEnds, StaleGateReleaseDoesNotOpenTheGateOfTheNextAnimation): PASS ✅ · 411s
(no coded error found; showing last 1200 chars)
:22.981 18314 18361 I DOTNET : Xml file was written to the provided writer.
08-08 09:55:22.981 18314 18361 I DOTNET : === TEST EXECUTION SUMMARY ===
08-08 09:55:22.981 18314 18361 I DOTNET : Tests run: 576 Passed: 6 Inconclusive: 0 Failed: 0 Ignored: 570 Skipped: 0
info: <<XHARNESS_RESULT_START>>
{
"version": 1,
"machineName": "runnervmtroe5",
"exitCode": 0,
"exitCodeName": "SUCCESS",
"platform": "android",
"instrumentationExitCode": 0,
"device": "emulator-5554",
"deviceOsVersion": "API 30",
"architecture": "x86_64",
"files": [
{
"name": "testResults-e39497cc68d04a399b578157343ecbb7.xml",
"type": "test-results"
},
{
"name": "adb-logcat-com.microsoft.maui.controls.devicetests-default.log",
"type": "logcat"
}
]
}
<<XHARNESS_RESULT_END>>
info: Attempting to remove apk 'com.microsoft.maui.controls.devicetests'..
info: Successfully uninstalled com.microsoft.maui.controls.devicetests
XHarness exit code: 0
Passed: 6
Failed: 0
Skipped: 0
Total: 6
Tests completed successfully
🔴 Without fix — 🖥️ Issue37012: FAIL ✅ · 2191s
Error-relevant lines (filtered from the build log):
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: Mono.AndroidTools.InstallFailedException: Unexpected install output: cmd: Failure calling service package: Broken pipe (32) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at Mono.AndroidTools.Internal.AdbOutputParsing.CheckInstallSuccess(String output, String packageName) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at Mono.AndroidTools.AndroidDevice.<>c__DisplayClass105_0.<InstallPackage>b__0(Task`1 t) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: --- End of stack trace from previous location --- [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at AndroidDeviceExtensions.PushAndInstallPackageAsync(AndroidDevice device, PushAndInstallCommand command, CancellationToken token) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at Xamarin.Android.Tasks.FastDeploy.InstallPackage(Boolean installed) [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
/home/vsts/work/1/s/.dotnet/packs/Microsoft.Android.Sdk.Linux/36.1.2/tools/Xamarin.Android.Common.Debugging.targets(333,5): error ADB0010: at Xamarin.Android.Tasks.FastDeploy.RunInstall() [/home/vsts/work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj::TargetFramework=net10.0-android]
Build FAILED.
🟢 With fix — 🖥️ Issue37012: PASS ✅ · 623s
(no coded error found; showing last 1200 chars)
est files matched the specified pattern.
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0)
[xUnit.net 00:00:00.12] Discovering: Controls.TestCases.Android.Tests
[xUnit.net 00:00:00.36] Discovered: Controls.TestCases.Android.Tests
NUnit Adapter 4.5.0.0: Test execution started
Running selected tests in /home/vsts/work/1/s/artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0/Controls.TestCases.Android.Tests.dll
NUnit3TestExecutor discovered 1 of 1 NUnit test cases using Current Discovery mode, Non-Explicit run
>>>>> 08/08/2026 15:05:34 FixtureSetup for Issue37012(Android)
>>>>> 08/08/2026 15:05:37 SafeAreaPaddingAppliedToPageReattachedDuringImeHideAnimation Start
>>>>> 08/08/2026 15:05:44 SafeAreaPaddingAppliedToPageReattachedDuringImeHideAnimation Stop
Passed SafeAreaPaddingAppliedToPageReattachedDuringImeHideAnimation [7 s]
NUnit Adapter 4.5.0.0: Test execution complete
Results File: /home/vsts/work/1/s/CustomAgentLogsTmp/UITests/TestResults/Issue37012.trx
Test Run Successful.
Total tests: 1
Passed: 1
Total time: 22.4076 Seconds
>>> TRX_RESULT_FILE: /home/vsts/work/1/s/CustomAgentLogsTmp/UITests/TestResults/Issue37012.trx
⚠️ Failure Details
- 🛠️ MauiWindowInsetListenerTests (ViewTrackedBeforeImeAnimationStaysGatedAndAttachedViewBypassesGateOnce, GateExemptionIsClearedWhenTheAnimationEnds, GateReleasesSynchronouslyWhenNoAttachedViewIsAvailable, ReapplyStaysOwedWhenTheAnimationEndsWithNoUsablePoster, GatedDispatchesTriggerOneReapplyWhenTheAnimationEnds, StaleGateReleaseDoesNotOpenTheGateOfTheNextAnimation) without fix: build failed before tests could run
/home/vsts/work/1/s/src/Controls/tests/DeviceTests/Elements/Window/MauiWindowInsetListenerTests.Android.cs(88,14): error CS1061: 'MauiWindowInsetListener' does not contain a definition for 'NotifyView...
📁 Fix files reverted (1 files)
src/Core/src/Platform/Android/MauiWindowInsetListener.cs
📋 Pre-Flight — Context & Validation
PR #37049 Pre-Flight
Started: 2026-08-08T15:06:04Z
Platform: Android
PR: [Android] SafeArea: Apply insets to views attached during IME animations
Issue: #37012, [Android] Safe-area padding stale for the whole IME hide animation when swapping pages
Base / merge base: main / f27ca83a4765f6491530f42090672c03e632d14d
Problem
MauiWindowInsetListener.OnApplyWindowInsets gates all inset dispatches while an IME animation is active. A page reattached during keyboard hide has already had its safe-area padding reset, so the gate leaves it at zero padding until OnEnd requests settled insets. The first visible frames can therefore render under system bars and jump when the animation completes. A second failure mode exists when gate release is posted through a detached view: its runnable may never execute, leaving the listener gated.
Existing PR approach
The PR changes src/Core/src/Platform/Android/MauiWindowInsetListener.cs to:
- notify the listener when a view attaches and retain fresh views in
_viewsAttachedDuringImeAnimation; - let those views bypass the animation gate until their first successful apply, while keeping pre-existing views gated;
- remember that settled insets remain owed and issue one hierarchy-wide
RequestApplyInsetsafter animation; - choose an alive attached tracked/gated view to post the delayed release, fall back to synchronous release if none exists, and guard against a stale release opening a later animation.
Any candidate must use a different root strategy rather than renaming or relocating this per-view exemption design.
PR diff
src/Core/src/Platform/Android/MauiWindowInsetListener.cs— functional fix (+178/-17).src/Controls/tests/DeviceTests/Elements/Window/MauiWindowInsetListenerTests.Android.cs— six deterministic listener tests.src/Controls/tests/TestCases.HostApp/Issues/Issue37012.cs— Android repro page.src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue37012.cs— end-to-end UI test.
Required validation
The prior gate already passed and must not be rerun. Run only:
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform android -TestFilter "Category=Window"
pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform android -TestFilter "Issue37012"Gate evidence: all six selected device tests passed (6/6), and Issue37012.SafeAreaPaddingAppliedToPageReattachedDuringImeHideAnimation passed (1/1). The selected device tests cover fresh/pre-existing views, exemption cleanup, detached poster handling, owed reapply, one hierarchy-wide reapply, and stale release isolation.
Constraints and repository state
- One implementation/test pass per candidate, with at most one focused correction/retest.
- Do not run full suites or gate verification.
- Use
EstablishBrokenBaseline.ps1and its-Restoremode; never manually restore fix files. - Preserve all unrelated pre-existing worktree changes.
- Applicable guidance: Android UI-thread rules, platform async/race rules, hot-path allocation rules, device-test rules, and UI-test rules.
- Mechanical regression cross-reference found no overlap with recent labeled bug-fix PRs.
🔬 Code Review — Deep Analysis
Expert PR Evaluation
Verdict: NEEDS_CHANGES
The submitted fix correctly separates the IME-animation gate, newly attached-view exemption, owed redispatch state, and posted-release generation. The state transitions are bounded to an animation, detached views are removed from per-view state, and a stale posted release cannot open the gate after a new animation starts. The deterministic Android device tests cover the principal positive and negative branches, including one-shot exemptions, synchronous release without a poster, retained redispatch debt, hierarchy-wide redispatch, and stale-release suppression.
Actionable finding
OnEnd chooses only an alive, attached poster, but EndImeAnimation runs one looper turn later and rechecks only IsAlive(). If that view detaches during the delay without being disposed, the code clears _reapplyInsetsWhenAnimationEnds and calls RequestApplyInsets on a view that no longer reaches ViewRootImpl. The redispatch is lost and gated views can retain animation-time or missing safe-area padding until an unrelated inset event.
The candidate should retain the owed flag when the poster is no longer attached:
if (!reapplyThrough.IsAlive() || !reapplyThrough.IsAttachedToWindow)
{
return;
}Add a deterministic device test that removes the selected poster between OnEnd and the next looper turn and verifies that redispatch remains owed and is delivered through a later usable poster.
Confidence: High. The failure follows directly from the attachment precondition already enforced when selecting the poster and from Android's unattached-view inset-request behavior.
🛠️ Try-Fix — Analysis & Comparison
Try-Fix — PR #37049 (alternative fix candidates)
Bounded alternative-fix evaluation. Each candidate proposes a materially different root strategy
from the PR's _viewsAttachedDuringImeAnimation per-view exemption design, and must pass both
required commands:
pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform android -TestFilter "Category=Window"pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform android -TestFilter "Issue37012"
| Candidate | Strategy | Result | Self-review findings |
|---|---|---|---|
| 1 | Replay settled insets on attach; never open the gate; synchronous gate release | ✅ Pass | 3 (0 critical, 0 major, 2 moderate, 1 minor) |
| 2 | Preserve tracked padding across transient detach; main-looper gate release | ❌ Fail | 3 (0 critical, 1 major, 2 moderate, 0 minor) |
Candidate 1
Result: Pass
Attempt artifacts: CustomAgentLogsTmp/PRState/37049/PRAgent/try-fix/attempt-1/
Baseline: EstablishBrokenBaseline.ps1 reverted src/Core/src/Platform/Android/MauiWindowInsetListener.cs to merge-base f27ca83a (1 fix file). Unrelated pre-existing dirty .github//eng/ changes were temporarily hidden with git update-index --assume-unchanged (and immediately un-hidden after the script's clean-tree gate) — no worktree content was touched.
Approach: replay the settled insets on attach; never open the gate
- Snapshot + replay instead of per-view gate exemption. The listener caches the last
WindowInsetsCompatdispatched while no IME animation was in flight (_settledInsets).NotifyViewAttached(view)pads the freshly attached view immediately fromViewCompat.GetRootWindowInsets(view)(which already reports the target/settled state during an animation), falling back to_settledInsets.OnApplyWindowInsetskeeps theIsImeAnimatinggate fully closed for every view, always. - Synchronous gate release instead of a hardened posted release.
OnEndsetsIsImeAnimating = falseinline and unconditionally issues one hierarchy-wideRequestApplyInsetsthrough any alive attached tracked view.
Why this differs materially from the PR fix
| PR #37049 | Candidate 1 | |
|---|---|---|
| Root hypothesis (failure mode A) | Some views must be exempted from the gate so animation-time insets reach them | The gate is right — no dispatch should ever be processed mid-animation; the defect is that the listener discards the settled inset state it already had, so a reattached view has nothing to be padded with |
| Mechanism A | _viewsAttachedDuringImeAnimation HashSet, one-shot exemption removal, cleanup in ResetView/Dispose, clearing at 3 animation lifecycle sites |
One cached WindowInsetsCompat; padding pushed at attach time. No per-view state at all |
| Values a fresh view receives | Animation-time insets, corrected later by an owed re-apply | Settled/target insets immediately |
| Root hypothesis (failure mode B) | The deferred release needs a better poster (attached-tracked/gated search + sync fallback + _gateReleaseScheduled stale guard) |
The deferred release is the defect source — deleting it removes the detached-poster and cross-animation-staleness classes outright |
| Owed-reapply bookkeeping | _reapplyInsetsWhenAnimationEnds + _lastGatedView |
None — re-apply is unconditional at IME animation end |
RefreshMauiWindowInsetListener (SafeAreaEdges None→All mid-animation) |
Documented caveat, deliberately left unfixed | Fixed — replaying settled insets is idempotent, so that path is notified too |
Net state delta vs. PR: −5 pieces of state (_viewsAttachedDuringImeAnimation, _lastGatedView, _gateReleaseScheduled, _reapplyInsetsWhenAnimationEnds, _pendingView), +1 cached insets reference.
Files changed
src/Core/src/Platform/Android/MauiWindowInsetListener.cs(+94 / −16 vs. broken baseline)
No test files were modified. MauiWindowInsetListenerTests.Android.cs, TestCases.HostApp/Issues/Issue37012.cs and TestCases.Shared.Tests/Tests/Issues/Issue37012.cs are unchanged from the PR.
Test results
| # | Command | Result | Duration |
|---|---|---|---|
| 1 | pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform android -TestFilter "Category=Window" |
✅ Pass — Tests run: 21, Passed: 21, Failed: 0 (XHarness exit 0) | ~4m50s (15:16:33 → 15:21:23 UTC) |
| 2 | pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform android -TestFilter "Issue37012" |
✅ Pass — Test Run Successful, Passed: 1 | ~6m50s (15:21:23 → 15:28:13 UTC) |
Device: emulator-5554, API 30, x86_64. Full output (both commands, with headings) in try-fix/attempt-1/test-output.log.
Iterations: one compile-error fix only — CA1822 required the extracted ApplyInsets helper to be static. First run failed the build at 15:16:50 UTC; after the one-line fix both commands passed. No logic correction round was needed.
Failure analysis: n/a (Pass).
Self-review (inline, per maui-expert-reviewer.md)
3 findings — 0 critical, 0 major, 2 moderate, 1 minor. Recorded in try-fix/attempt-1/reviewer-findings.json.
[moderate] Safe Area and Window Insets(line 521) — synchronous release means a deferred post-animation dispatch carrying animation-time IME insets is applied rather than suppressed for one looper turn. The unconditionalRequestApplyInsetscorrects it in the normal case, but not when no tracked view is attached at that instant. This is the main trade-off versus the PR, which keeps that protection at the cost of the poster machinery.[moderate] Logic and Correctness(line 610) —RefreshMauiWindowInsetListenerreplays settled insets to a possibly already-padded view; safe forApplyDefaultWindowInsets(absolute padding) but relies onIHandleWindowInsetsimplementations recomputing rather than accumulating.[minor] Memory Leak Prevention(line 38) —_settledInsetsretains aWindowInsetsCompatJava peer for the listener lifetime; cleared inDispose(true).
A pre-test correction round (allowed by Step 6) removed the owed-reapply flag so the end-of-animation re-dispatch is unconditional, closing the case where an animation with no gated dispatch released the gate without ever requesting settled values.
Candidate diff
diff --git a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs
index 546bb1a84f..f685a1992d 100644
--- a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs
+++ b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs
@@ -31,7 +31,11 @@ namespace Microsoft.Maui.Platform
readonly HashSet<AView> _trackedViews = [];
bool IsImeAnimating { get; set; }
- AView? _pendingView;
+ // The last insets that were dispatched while no IME animation was in flight, i.e. the
+ // settled safe-area state. A view that (re)attaches mid-animation has had its padding
+ // reset and the gate is suppressing every dispatch, so it is padded from this snapshot
+ // instead of the gate being opened for it (#37012).
+ WindowInsetsCompat? _settledInsets;
// Static tracking for views that have local inset listeners.
// This registry allows child views to find their appropriate listener without
@@ -230,20 +234,47 @@ namespace Microsoft.Maui.Platform
{
}
+ /// <summary>
+ /// Notifies this listener that a view has (re)attached to the window and started using it.
+ /// While an IME animation is in flight every dispatch is gated, so such a view is padded
+ /// directly from the settled insets rather than being left unpadded until the animation ends.
+ /// Must be called on UI thread.
+ /// </summary>
+ /// <param name="view">The view that attached</param>
+ internal void NotifyViewAttached(AView view)
+ {
+ if (!IsImeAnimating || !view.IsAlive())
+ {
+ return;
+ }
+
+ // Prefer the live root insets: during an animation they already report the target
+ // (settled) state, so they beat the snapshot taken before the animation started.
+ var insets = (view.IsAttachedToWindow ? ViewCompat.GetRootWindowInsets(view) : null) ?? _settledInsets;
+
+ if (insets is null || !insets.HasInsets)
+ {
+ return;
+ }
+
+ ApplyInsets(view, insets);
+ }
+
public virtual WindowInsetsCompat? OnApplyWindowInsets(AView? v, WindowInsetsCompat? insets)
{
if (insets is null || !insets.HasInsets || v is null || IsImeAnimating)
{
- if (IsImeAnimating)
- {
- _pendingView = v;
- }
-
return insets;
}
- _pendingView = null;
+ // No animation is in flight, so these are the settled insets
+ _settledInsets = insets;
+
+ return ApplyInsets(v, insets);
+ }
+ static WindowInsetsCompat? ApplyInsets(AView v, WindowInsetsCompat insets)
+ {
// Handle custom inset views first
if (v is IHandleWindowInsets customHandler)
{
@@ -429,6 +460,7 @@ namespace Microsoft.Maui.Platform
if (disposing)
{
ResetAllViews();
+ _settledInsets = null;
}
base.Dispose(disposing);
}
@@ -476,22 +508,43 @@ namespace Microsoft.Maui.Platform
{
base.OnEnd(animation);
- if (IsImeAnimation(animation))
+ if (!IsImeAnimation(animation))
{
- if (_pendingView is AView view)
- {
- _pendingView = null;
- view.Post(() =>
- {
- IsImeAnimating = false;
- ViewCompat.RequestApplyInsets(view);
- });
- }
- else
+ return;
+ }
+
+ // Release the gate synchronously. Deferring it through a view's Post is what makes
+ // the gate stick: a detached view's runnable only runs if that view ever re-attaches,
+ // and a runnable posted by one animation can otherwise open the gate in the middle of
+ // the next one. Nothing needs deferring here — views that attached mid-animation were
+ // already padded from the settled insets rather than waiting for this release.
+ IsImeAnimating = false;
+
+ // Everything the gate suppressed has to be re-delivered, and the values views were
+ // padded with while the animation ran (the settled snapshot / live root insets) are
+ // only a stand-in for what the system finally dispatches. RequestApplyInsets is not
+ // per-view: it walks up to the ViewRootImpl and re-dispatches insets across the whole
+ // hierarchy, so one call on any attached view covers every view this listener serves.
+ // Doing it unconditionally also overwrites any deferred post-animation dispatch that
+ // still carried animation-time IME insets.
+ if (FindAttachedTrackedView() is AView poster)
+ {
+ ViewCompat.RequestApplyInsets(poster);
+ }
+ }
+
+ AView? FindAttachedTrackedView()
+ {
+ foreach (var view in _trackedViews)
+ {
+ // A view can be disposed while still tracked when a cleanup path is skipped
+ if (view.IsAlive() && view.IsAttachedToWindow)
{
- IsImeAnimating = false;
+ return view;
}
}
+
+ return null;
}
/// <summary>
@@ -521,6 +574,7 @@ internal static class MauiWindowInsetListenerExtensions
{
ViewCompat.SetOnApplyWindowInsetsListener(view, localListener);
ViewCompat.SetWindowInsetsAnimationCallback(view, localListener);
+ localListener.NotifyViewAttached(view);
return true;
}
@@ -550,6 +604,10 @@ internal static class MauiWindowInsetListenerExtensions
{
ViewCompat.SetOnApplyWindowInsetsListener(view, listener);
ViewCompat.SetWindowInsetsAnimationCallback(view, listener);
+ // A view that just became eligible (e.g. SafeAreaEdges None -> All) has no padding
+ // yet. Replaying the settled insets is idempotent for a view that already has them,
+ // so this path is safe to notify as well.
+ listener.NotifyViewAttached(view);
return true;
}
Restore status
pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore → Restored: True, 1 file restored
(src/Core/src/Platform/Android/MauiWindowInsetListener.cs).
Verified after restore:
git status --porcelain -- src/is empty — no candidate source changes remain.- The PR's own implementation is back in the file (
_viewsAttachedDuringImeAnimationpresent, 9 refs);
candidate marker_settledInsetsis gone. - 36 unrelated pre-existing dirty tracked files under
.github/andeng/are untouched (same count
as before the attempt), plus the pre-existing untracked scripts. - No commits, no pushes, no
git checkout/restore/resetused on fix files.
Try-Fix Candidate 2 — PR #37049
Result: Fail
Attempt artifacts: CustomAgentLogsTmp/PRState/37049/PRAgent/try-fix/attempt-2/
Approach: preserve applied padding across transient IME detach
The listener keeps the IME animation gate closed for every normal inset dispatch. Instead of exempting a newly attached view or replaying inset data, detach cleanup preserves the already-applied padding of tracked views while an IME animation is active. At animation end it cleans up views that remained detached, releases the gate from a main-looper post independent of any view queue, and requests one settled hierarchy dispatch through an attached tracked view when available. If no tracked view is attached, it releases synchronously so the gate cannot stick.
NotifyViewAttached does not apply or exempt anything; for an already-tracked reattached view it only records that a settled hierarchy dispatch is owed.
Differentiation
| PR #37049 | Candidate 1 | Candidate 2 | |
|---|---|---|---|
| Root strategy | Let a fresh view bypass the gate once | Replay cached/root settled insets on attach | Avoid destroying the view's already-applied padding during transient detach |
| Attach-time inset behavior | Accept one animation-time dispatch | Directly apply cached/root insets | Apply nothing; retain existing padding |
| Per-view fresh state | _viewsAttachedDuringImeAnimation HashSet |
None | None |
| Insets cache/replay | None | _settledInsets snapshot/root lookup |
None |
| Gate release | View-owned delayed post with attached-poster search, sync fallback, stale guard | Synchronous release and unconditional reapply | Main-looper delayed release independent of view attachment, sync fallback when no attached tracked view, generation stale guard |
This is materially different from both prohibited strategies: it changes detach cleanup rather than attach dispatch, does not cache/replay insets, and never bypasses the animation gate.
Files changed
src/Core/src/Platform/Android/MauiWindowInsetListener.cs(+92 / −9 versus broken baseline)- No test files changed.
Test results
| # | Command | Result | Duration |
|---|---|---|---|
| 1 | pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform android -TestFilter "Category=Window" |
❌ Fail — 21 selected, 20 passed, 1 failed: ViewTrackedBeforeImeAnimationStaysGatedAndAttachedViewBypassesGateOnce expected handled count 1, actual 0 |
263s (15:36:20–15:40:43 UTC) |
| 2 | pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform android -TestFilter "Issue37012" |
no top inset on this device; behavioral fix not established |
392s (15:41:00–15:47:32 UTC) |
Both complete command outputs, under headings with timestamps, durations, and exit codes, are in try-fix/attempt-2/test-output.log.
Iterations: One implementation/test pass; zero correction rounds. A correction was not attempted because satisfying the sole device-test failure would require reintroducing the expressly prohibited fresh-view one-shot bypass or attach-time inset replay/application.
Failure analysis
The behavioral strategy assumes the reattached page is the same tracked native view whose safe-area padding was reset by detach cleanup. Preserving that padding makes its first reattached frame correct while the global gate remains closed. Five of six listener-specific tests passed, including detached-poster release, owed reapply, one hierarchy request, and stale-release isolation.
The one failure is expected and was identified in pre-test self-review: the deterministic test creates a brand-new untracked recording view with no padding to preserve and explicitly requires the PR's one-shot bypass semantics. Candidate 2 deliberately does not implement those semantics. The end-to-end test was the appropriate behavioral discriminator, but the test environment could not exercise its precondition because it exposed no top inset.
Expert self-review
3 findings — 0 critical, 1 major, 2 moderate, 0 minor.
[major] Regression Prevention and Test Coverage— known incompatibility with the deterministic fresh-view-bypass assertion.[moderate] Safe Area and Window Insets— retained position-dependent padding can be stale if a view moves to another window/display during the animation.[moderate] Memory Leak Prevention— tracked detached views are retained untilOnEnd/dispose; an omitted terminal callback can extend retention.
See try-fix/attempt-2/reviewer-findings.json. The diff did not change during testing, so the pre-test review remained current.
Candidate diff
diff --git a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs
index 546bb1a84f..33c8c6f9bb 100644
--- a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs
+++ b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using Android.Content;
+using Android.OS;
using Android.Views;
using AndroidX.Core.Graphics;
using AndroidX.Core.View;
@@ -32,6 +33,9 @@ namespace Microsoft.Maui.Platform
bool IsImeAnimating { get; set; }
AView? _pendingView;
+ int _imeAnimationGeneration;
+
+ static readonly Handler s_mainHandler = new(Looper.MainLooper!);
// Static tracking for views that have local inset listeners.
// This registry allows child views to find their appropriate listener without
@@ -223,13 +227,28 @@ namespace Microsoft.Maui.Platform
ViewCompat.SetWindowInsetsAnimationCallback(view, null);
// Reset any tracked views within this view
- UnregisterView(view)?.ResetAppliedSafeAreas(view);
+ UnregisterView(view)?.DetachAppliedSafeAreas(view);
}
public MauiWindowInsetListener() : base(DispatchModeStop)
{
}
+ /// <summary>
+ /// Notifies this listener that a view has reattached. Insets do not need to be replayed:
+ /// tracked views retain their applied padding across an IME-animation detach.
+ /// Must be called on UI thread.
+ /// </summary>
+ internal void NotifyViewAttached(AView view)
+ {
+ if (IsImeAnimating && _trackedViews.Contains(view))
+ {
+ // Ensure the settled hierarchy dispatch is still requested even if Android
+ // does not send another gated callback after this view reattaches.
+ _pendingView ??= view;
+ }
+ }
+
public virtual WindowInsetsCompat? OnApplyWindowInsets(AView? v, WindowInsetsCompat? insets)
{
if (insets is null || !insets.HasInsets || v is null || IsImeAnimating)
@@ -372,6 +391,16 @@ namespace Microsoft.Maui.Platform
_trackedViews.Remove(view);
}
+ internal void DetachView(AView view)
+ {
+ if (IsImeAnimating)
+ {
+ return;
+ }
+
+ ResetView(view);
+ }
+
public void ResetAllViews()
{
// Create a copy to avoid modification during enumeration
@@ -401,6 +430,16 @@ namespace Microsoft.Maui.Platform
}
}
+ void DetachAppliedSafeAreas(AView view)
+ {
+ if (IsImeAnimating)
+ {
+ return;
+ }
+
+ ResetAppliedSafeAreas(view);
+ }
+
/// <summary>
/// Checks if a view is a descendant of a parent view
/// </summary>
@@ -439,6 +478,7 @@ namespace Microsoft.Maui.Platform
if (IsImeAnimation(animation))
{
IsImeAnimating = true;
+ _imeAnimationGeneration++;
}
}
@@ -447,6 +487,7 @@ namespace Microsoft.Maui.Platform
if (IsImeAnimation(animation))
{
IsImeAnimating = true;
+ _imeAnimationGeneration++;
}
return bounds;
@@ -478,20 +519,61 @@ namespace Microsoft.Maui.Platform
if (IsImeAnimation(animation))
{
- if (_pendingView is AView view)
+ var poster = FindAttachedTrackedView();
+ if (poster is null)
+ {
+ EndImeAnimation(null);
+ }
+ else
{
- _pendingView = null;
- view.Post(() =>
+ var generation = _imeAnimationGeneration;
+ s_mainHandler.Post(() =>
{
- IsImeAnimating = false;
- ViewCompat.RequestApplyInsets(view);
+ if (generation == _imeAnimationGeneration)
+ {
+ EndImeAnimation(poster);
+ }
});
}
- else
+ }
+ }
+
+ AView? FindAttachedTrackedView()
+ {
+ foreach (var view in _trackedViews)
+ {
+ if (view.IsAlive() && view.IsAttachedToWindow)
+ {
+ return view;
+ }
+ }
+
+ return null;
+ }
+
+ void EndImeAnimation(AView? reapplyThrough)
+ {
+ IsImeAnimating = false;
+
+ foreach (var view in _trackedViews.ToArray())
+ {
+ if (!view.IsAlive())
+ {
+ _trackedViews.Remove(view);
+ }
+ else if (!view.IsAttachedToWindow)
{
- IsImeAnimating = false;
+ ResetView(view);
}
}
+
+ if (_pendingView is null || !reapplyThrough.IsAlive() || !reapplyThrough.IsAttachedToWindow)
+ {
+ return;
+ }
+
+ _pendingView = null;
+ ViewCompat.RequestApplyInsets(reapplyThrough);
}
/// <summary>
@@ -521,6 +603,7 @@ internal static class MauiWindowInsetListenerExtensions
{
ViewCompat.SetOnApplyWindowInsetsListener(view, localListener);
ViewCompat.SetWindowInsetsAnimationCallback(view, localListener);
+ localListener.NotifyViewAttached(view);
return true;
}
@@ -573,6 +656,6 @@ internal static class MauiWindowInsetListenerExtensions
// Reset view state - prefer local listener if available, otherwise use global
var listener = MauiWindowInsetListener.FindRegisteredListenerForView(view);
- listener?.ResetView(view);
+ listener?.DetachView(view);
}
}Restore status
pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore returned Restored: True and restored src/Core/src/Platform/Android/MauiWindowInsetListener.cs from HEAD.
Verified after restore:
git status --short -- srcis empty: no candidate source changes remain.- PR implementation marker
_viewsAttachedDuringImeAnimationis restored (9 references). - Candidate markers
DetachAppliedSafeAreasands_mainHandlerare absent. - All 36 unrelated pre-existing tracked changes under
.github/andeng/remain; assume-unchanged count is 0. - No commit, push, checkout, restore, or reset command was used.
📝 PR Finalize — Recommended Title & Description
Assessment: ✏️ Recommend updating — the title is accurate, but the description claims TrackView-aware exemption retention, an untracked-view redispatch candidate, and two tests that the actual winning diff does not implement.
Recommended title
[Android] SafeArea: Apply insets to views attached during IME animations
Recommended description
### Description of Change
On Android, `MauiWindowInsetListener` drops inset dispatches while an IME animation is in flight (`OnApplyWindowInsets` gates dispatches while `IsImeAnimating` is true). Combined with safe-area padding being reset when a view detaches, a page platform view that is (re)attached while the soft keyboard is animating closed can lay out with zero safe-area padding for the hide animation, render under the status bar, and then visibly jump when deferred insets arrive.
Two behaviors change in `src/Core/src/Platform/Android/MauiWindowInsetListener.cs`:
1. **Views attached during an IME animation may bypass the gate for their first valid inset dispatch.** `TrySetMauiWindowInsetListener`, called from the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The listener records the attached view in `_viewsAttachedDuringImeAnimation`, allowing it to process one valid dispatch while the animation gate remains closed for pre-existing views.
The exemption is consumed before dispatching valid insets to either `IHandleWindowInsets` or the default inset handler. The values are animation-time values, so `_reapplyInsetsWhenAnimationEnds` records that a settled hierarchy redispatch remains owed. Invalid or empty inset dispatches do not consume the exemption. Once the valid dispatch is processed, the view is gated like every other view for the rest of the animation.
`RefreshMauiWindowInsetListener` represents a `SafeAreaEdges` configuration change rather than an attach and deliberately does not grant the exemption.
2. **The gate release can no longer get stuck behind a detached view or lose an owed redispatch.** Previously, `OnEnd` posted its `IsImeAnimating = false` reset through the single remembered `_pendingView`. `View.Post` on a view with no `AttachInfo` can remain parked until that view reattaches, which may never happen after a page pop, leaving the gate closed for every view sharing the listener.
The release is now posted through an alive, attached tracked or gated view, with a synchronous gate release when none is available. A release scheduled by one animation is ignored if a new animation starts before its runnable executes.
Because the selected poster can detach during the one-looper-turn delay, `EndImeAnimation` rechecks both liveness and attachment before consuming `_reapplyInsetsWhenAnimationEnds`. If the poster is no longer usable, the settled redispatch remains owed and a later animation end with an attached poster delivers it.
Insets are reapplied at animation end with one `RequestApplyInsets`. That request reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal, so one call covers every gated view. Calling it once per view would only coalesce into the same traversal, and requesting on a descendant does not bypass a consuming ancestor because dispatch restarts at the root.
The added state is listener-scoped and bounded by the animation. No work is added to `OnProgress`; exemption and redispatch state changes only on attach, inset dispatch, reset, and animation-boundary callbacks.
### Tests
`MauiWindowInsetListenerTests` Android device tests drive the listener directly without relying on emulator IME timing. They cover:
- a pre-existing view remaining gated while an attached view receives one initial apply;
- exemptions being cleared at animation end;
- synchronous gate release when no attached poster exists;
- an owed redispatch being retained when no usable poster exists and delivered later;
- an owed redispatch being retained when the selected poster detaches before the delayed release;
- one hierarchy-wide `RequestApplyInsets` for multiple gated views; and
- a stale release from one animation not opening the next animation's gate.
The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. Page A captures its platform padding on the first frame after reattach and after the IME settles, verifying that top safe-area padding is present immediately and keyboard-height bottom padding does not remain stale.
### Issues Fixed
Fixes #37012
🏁 Report — Final Recommendation
⚠️ Final Recommendation: REQUEST CHANGES
Winner: pr-plus-reviewer. It preserves the submitted fix's well-tested state machine while closing the expert review's concrete redispatch-loss race. The raw PR can clear its owed-redispatch flag after the selected poster detaches during the one-looper-turn delay; the winning candidate retains that debt and adds deterministic coverage for delivering it through a later usable poster.
Comparative ranking
| Rank | Candidate | Validation | Assessment |
|---|---|---|---|
| 1 | pr-plus-reviewer |
✅ Window device tests 22/22; Issue37012 1/1 |
Best balance of correctness and scope. It fixes the expert-found detach race without replacing the PR's one-shot attach exemption, stale-release guard, or hierarchy-wide settled redispatch. |
| 2 | pr |
✅ Trusted Gate passed: listener tests 6/6; Issue37012 1/1 |
Correctly fixes the reported attach-during-IME failure and stuck gate, but has a concrete remaining race: a poster can detach after OnEnd selects it, causing the owed redispatch to be cleared without reaching ViewRootImpl. |
| 3 | try-fix-1 |
✅ Window device tests 21/21; Issue37012 1/1 |
The settled-insets replay strategy passes both commands and uses less state, but its synchronous gate release can admit a deferred animation-time dispatch when no tracked poster can immediately correct it. It also assumes every IHandleWindowInsets replay is idempotent and retains a WindowInsetsCompat peer for the listener lifetime. |
| 4 | try-fix-2 |
❌ Window device tests 20/21; UI test inconclusive | Must rank below passing candidates. Preserving padding across detach does not handle a newly attached, untracked view, and the deterministic fresh-view regression test fails. It also risks stale position-dependent padding across window/display changes. |
Why the winner should replace the submitted state
The expert review traced all gate, exemption, redispatch, and stale-release state transitions and found the PR's overall design sound. The required refinement is narrowly coupled to that design: OnEnd already requires an attached poster, so EndImeAnimation must preserve the same precondition when its delayed runnable executes. The added test exercises exactly the missing transition rather than broadening behavior.
The trusted Gate permits approval of the original bug fix, but the expert finding is actionable and reproducible from the state transition. Because pr-plus-reviewer, not the raw pr, wins, the submitted PR should be updated before approval.
📱 UI Tests — SafeAreaEdges,ViewBaseTests
Detected UI test categories: SafeAreaEdges,ViewBaseTests
✅ Deep UI tests — 180 passed, 0 failed, 1 skipped across 2 categories on platform-pool agent (replaces in-process counts above).
🧪 UI Test Execution Results (deep, platform pool)
| Category | Tests | Snapshot diffs |
|---|---|---|
SafeAreaEdges |
62/62 ✓ | 1 diff PNG |
ViewBaseTests |
118/119 (1 skipped) ✓ | — |
📸 Snapshot differences — baseline vs actual vs diff (all 1)
For each failing
VerifyScreenshotsnapshot: the committed baseline, the actual render on this CI agent, and the computed diff. Ordered by likely PR-relevance — snapshots whose baseline/test file this PR changed are shown first. A large, uniform diff across many snapshots is usually a cross-machine baseline/environment mismatch (e.g. the macOS TitleBar / window chrome), not a code regression — compare against baseline history before concluding.
📎 Download drop-deep-uitests artifact (TRX + snapshot diffs)
🧭 Next Steps — review latest findings
No alternative fix was selected for this run. Review the session findings and CI results before merging.
|
/azp run |
|
Azure Pipelines: Successfully started running 3 pipeline(s). |
…ons (#37049) ### Description of Change On Android, `MauiWindowInsetListener` gates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends. This PR changes `src/Core/src/Platform/Android/MauiWindowInsetListener.cs` in two ways: 1. **A view attached during an IME animation can bypass the gate for one valid inset dispatch.** `TrySetMauiWindowInsetListener`, used by the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed. `RefreshMauiWindowInsetListener` is a configuration refresh rather than a fresh attach and does not grant the exemption. 2. **The animation gate no longer depends on posting through a detached pending view.** Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started. One `RequestApplyInsets` is enough because it reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work in `OnProgress`. ### Tests `MauiWindowInsetListenerTests` Android device tests cover: - an existing view remaining gated while a newly attached view bypasses the gate once; - attach exemptions being cleared at animation end; - synchronous gate release when no attached poster is available; - an owed redispatch being retained when an animation ends without a usable poster and delivered later; - one hierarchy-wide `RequestApplyInsets` for multiple gated views; and - a stale release from one animation not opening the next animation's gate. The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles. ### Issues Fixed Fixes #37012 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ons (#37049) ### Description of Change On Android, `MauiWindowInsetListener` gates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends. This PR changes `src/Core/src/Platform/Android/MauiWindowInsetListener.cs` in two ways: 1. **A view attached during an IME animation can bypass the gate for one valid inset dispatch.** `TrySetMauiWindowInsetListener`, used by the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed. `RefreshMauiWindowInsetListener` is a configuration refresh rather than a fresh attach and does not grant the exemption. 2. **The animation gate no longer depends on posting through a detached pending view.** Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started. One `RequestApplyInsets` is enough because it reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work in `OnProgress`. ### Tests `MauiWindowInsetListenerTests` Android device tests cover: - an existing view remaining gated while a newly attached view bypasses the gate once; - attach exemptions being cleared at animation end; - synchronous gate release when no attached poster is available; - an owed redispatch being retained when an animation ends without a usable poster and delivered later; - one hierarchy-wide `RequestApplyInsets` for multiple gated views; and - a stale release from one animation not opening the next animation's gate. The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles. ### Issues Fixed Fixes #37012 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ons (#37049) ### Description of Change On Android, `MauiWindowInsetListener` gates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends. This PR changes `src/Core/src/Platform/Android/MauiWindowInsetListener.cs` in two ways: 1. **A view attached during an IME animation can bypass the gate for one valid inset dispatch.** `TrySetMauiWindowInsetListener`, used by the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed. `RefreshMauiWindowInsetListener` is a configuration refresh rather than a fresh attach and does not grant the exemption. 2. **The animation gate no longer depends on posting through a detached pending view.** Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started. One `RequestApplyInsets` is enough because it reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work in `OnProgress`. ### Tests `MauiWindowInsetListenerTests` Android device tests cover: - an existing view remaining gated while a newly attached view bypasses the gate once; - attach exemptions being cleared at animation end; - synchronous gate release when no attached poster is available; - an owed redispatch being retained when an animation ends without a usable poster and delivered later; - one hierarchy-wide `RequestApplyInsets` for multiple gated views; and - a stale release from one animation not opening the next animation's gate. The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles. ### Issues Fixed Fixes #37012 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ons (#37049) ### Description of Change On Android, `MauiWindowInsetListener` gates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends. This PR changes `src/Core/src/Platform/Android/MauiWindowInsetListener.cs` in two ways: 1. **A view attached during an IME animation can bypass the gate for one valid inset dispatch.** `TrySetMauiWindowInsetListener`, used by the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed. `RefreshMauiWindowInsetListener` is a configuration refresh rather than a fresh attach and does not grant the exemption. 2. **The animation gate no longer depends on posting through a detached pending view.** Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started. One `RequestApplyInsets` is enough because it reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work in `OnProgress`. ### Tests `MauiWindowInsetListenerTests` Android device tests cover: - an existing view remaining gated while a newly attached view bypasses the gate once; - attach exemptions being cleared at animation end; - synchronous gate release when no attached poster is available; - an owed redispatch being retained when an animation ends without a usable poster and delivered later; - one hierarchy-wide `RequestApplyInsets` for multiple gated views; and - a stale release from one animation not opening the next animation's gate. The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles. ### Issues Fixed Fixes #37012 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ons (#37049) ### Description of Change On Android, `MauiWindowInsetListener` gates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends. This PR changes `src/Core/src/Platform/Android/MauiWindowInsetListener.cs` in two ways: 1. **A view attached during an IME animation can bypass the gate for one valid inset dispatch.** `TrySetMauiWindowInsetListener`, used by the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed. `RefreshMauiWindowInsetListener` is a configuration refresh rather than a fresh attach and does not grant the exemption. 2. **The animation gate no longer depends on posting through a detached pending view.** Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started. One `RequestApplyInsets` is enough because it reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work in `OnProgress`. ### Tests `MauiWindowInsetListenerTests` Android device tests cover: - an existing view remaining gated while a newly attached view bypasses the gate once; - attach exemptions being cleared at animation end; - synchronous gate release when no attached poster is available; - an owed redispatch being retained when an animation ends without a usable poster and delivered later; - one hierarchy-wide `RequestApplyInsets` for multiple gated views; and - a stale release from one animation not opening the next animation's gate. The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles. ### Issues Fixed Fixes #37012 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ons (#37049) ### Description of Change On Android, `MauiWindowInsetListener` gates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends. This PR changes `src/Core/src/Platform/Android/MauiWindowInsetListener.cs` in two ways: 1. **A view attached during an IME animation can bypass the gate for one valid inset dispatch.** `TrySetMauiWindowInsetListener`, used by the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed. `RefreshMauiWindowInsetListener` is a configuration refresh rather than a fresh attach and does not grant the exemption. 2. **The animation gate no longer depends on posting through a detached pending view.** Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started. One `RequestApplyInsets` is enough because it reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work in `OnProgress`. ### Tests `MauiWindowInsetListenerTests` Android device tests cover: - an existing view remaining gated while a newly attached view bypasses the gate once; - attach exemptions being cleared at animation end; - synchronous gate release when no attached poster is available; - an owed redispatch being retained when an animation ends without a usable poster and delivered later; - one hierarchy-wide `RequestApplyInsets` for multiple gated views; and - a stale release from one animation not opening the next animation's gate. The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles. ### Issues Fixed Fixes #37012 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ons (#37049) ### Description of Change On Android, `MauiWindowInsetListener` gates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends. This PR changes `src/Core/src/Platform/Android/MauiWindowInsetListener.cs` in two ways: 1. **A view attached during an IME animation can bypass the gate for one valid inset dispatch.** `TrySetMauiWindowInsetListener`, used by the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed. `RefreshMauiWindowInsetListener` is a configuration refresh rather than a fresh attach and does not grant the exemption. 2. **The animation gate no longer depends on posting through a detached pending view.** Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started. One `RequestApplyInsets` is enough because it reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work in `OnProgress`. ### Tests `MauiWindowInsetListenerTests` Android device tests cover: - an existing view remaining gated while a newly attached view bypasses the gate once; - attach exemptions being cleared at animation end; - synchronous gate release when no attached poster is available; - an owed redispatch being retained when an animation ends without a usable poster and delivered later; - one hierarchy-wide `RequestApplyInsets` for multiple gated views; and - a stale release from one animation not opening the next animation's gate. The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles. ### Issues Fixed Fixes #37012 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ons (#37049) ### Description of Change On Android, `MauiWindowInsetListener` gates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends. This PR changes `src/Core/src/Platform/Android/MauiWindowInsetListener.cs` in two ways: 1. **A view attached during an IME animation can bypass the gate for one valid inset dispatch.** `TrySetMauiWindowInsetListener`, used by the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed. `RefreshMauiWindowInsetListener` is a configuration refresh rather than a fresh attach and does not grant the exemption. 2. **The animation gate no longer depends on posting through a detached pending view.** Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started. One `RequestApplyInsets` is enough because it reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work in `OnProgress`. ### Tests `MauiWindowInsetListenerTests` Android device tests cover: - an existing view remaining gated while a newly attached view bypasses the gate once; - attach exemptions being cleared at animation end; - synchronous gate release when no attached poster is available; - an owed redispatch being retained when an animation ends without a usable poster and delivered later; - one hierarchy-wide `RequestApplyInsets` for multiple gated views; and - a stale release from one animation not opening the next animation's gate. The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles. ### Issues Fixed Fixes #37012 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ons (#37049) ### Description of Change On Android, `MauiWindowInsetListener` gates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends. This PR changes `src/Core/src/Platform/Android/MauiWindowInsetListener.cs` in two ways: 1. **A view attached during an IME animation can bypass the gate for one valid inset dispatch.** `TrySetMauiWindowInsetListener`, used by the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed. `RefreshMauiWindowInsetListener` is a configuration refresh rather than a fresh attach and does not grant the exemption. 2. **The animation gate no longer depends on posting through a detached pending view.** Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started. One `RequestApplyInsets` is enough because it reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work in `OnProgress`. ### Tests `MauiWindowInsetListenerTests` Android device tests cover: - an existing view remaining gated while a newly attached view bypasses the gate once; - attach exemptions being cleared at animation end; - synchronous gate release when no attached poster is available; - an owed redispatch being retained when an animation ends without a usable poster and delivered later; - one hierarchy-wide `RequestApplyInsets` for multiple gated views; and - a stale release from one animation not opening the next animation's gate. The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles. ### Issues Fixed Fixes #37012 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ons (#37049) ### Description of Change On Android, `MauiWindowInsetListener` gates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends. This PR changes `src/Core/src/Platform/Android/MauiWindowInsetListener.cs` in two ways: 1. **A view attached during an IME animation can bypass the gate for one valid inset dispatch.** `TrySetMauiWindowInsetListener`, used by the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed. `RefreshMauiWindowInsetListener` is a configuration refresh rather than a fresh attach and does not grant the exemption. 2. **The animation gate no longer depends on posting through a detached pending view.** Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started. One `RequestApplyInsets` is enough because it reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work in `OnProgress`. ### Tests `MauiWindowInsetListenerTests` Android device tests cover: - an existing view remaining gated while a newly attached view bypasses the gate once; - attach exemptions being cleared at animation end; - synchronous gate release when no attached poster is available; - an owed redispatch being retained when an animation ends without a usable poster and delivered later; - one hierarchy-wide `RequestApplyInsets` for multiple gated views; and - a stale release from one animation not opening the next animation's gate. The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles. ### Issues Fixed Fixes #37012 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ons (#37049) ### Description of Change On Android, `MauiWindowInsetListener` gates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends. This PR changes `src/Core/src/Platform/Android/MauiWindowInsetListener.cs` in two ways: 1. **A view attached during an IME animation can bypass the gate for one valid inset dispatch.** `TrySetMauiWindowInsetListener`, used by the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed. `RefreshMauiWindowInsetListener` is a configuration refresh rather than a fresh attach and does not grant the exemption. 2. **The animation gate no longer depends on posting through a detached pending view.** Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started. One `RequestApplyInsets` is enough because it reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work in `OnProgress`. ### Tests `MauiWindowInsetListenerTests` Android device tests cover: - an existing view remaining gated while a newly attached view bypasses the gate once; - attach exemptions being cleared at animation end; - synchronous gate release when no attached poster is available; - an owed redispatch being retained when an animation ends without a usable poster and delivered later; - one hierarchy-wide `RequestApplyInsets` for multiple gated views; and - a stale release from one animation not opening the next animation's gate. The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles. ### Issues Fixed Fixes #37012 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ons (#37049) ### Description of Change On Android, `MauiWindowInsetListener` gates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends. This PR changes `src/Core/src/Platform/Android/MauiWindowInsetListener.cs` in two ways: 1. **A view attached during an IME animation can bypass the gate for one valid inset dispatch.** `TrySetMauiWindowInsetListener`, used by the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed. `RefreshMauiWindowInsetListener` is a configuration refresh rather than a fresh attach and does not grant the exemption. 2. **The animation gate no longer depends on posting through a detached pending view.** Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started. One `RequestApplyInsets` is enough because it reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work in `OnProgress`. ### Tests `MauiWindowInsetListenerTests` Android device tests cover: - an existing view remaining gated while a newly attached view bypasses the gate once; - attach exemptions being cleared at animation end; - synchronous gate release when no attached poster is available; - an owed redispatch being retained when an animation ends without a usable poster and delivered later; - one hierarchy-wide `RequestApplyInsets` for multiple gated views; and - a stale release from one animation not opening the next animation's gate. The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles. ### Issues Fixed Fixes #37012 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ons (#37049) ### Description of Change On Android, `MauiWindowInsetListener` gates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends. This PR changes `src/Core/src/Platform/Android/MauiWindowInsetListener.cs` in two ways: 1. **A view attached during an IME animation can bypass the gate for one valid inset dispatch.** `TrySetMauiWindowInsetListener`, used by the `OnAttachedToWindow` paths of `ContentViewGroup`, `LayoutViewGroup`, and `MauiScrollView`, calls a new `NotifyViewAttached` hook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed. `RefreshMauiWindowInsetListener` is a configuration refresh rather than a fresh attach and does not grant the exemption. 2. **The animation gate no longer depends on posting through a detached pending view.** Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started. One `RequestApplyInsets` is enough because it reaches the `ViewRootImpl` and redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work in `OnProgress`. ### Tests `MauiWindowInsetListenerTests` Android device tests cover: - an existing view remaining gated while a newly attached view bypasses the gate once; - attach exemptions being cleared at animation end; - synchronous gate release when no attached poster is available; - an owed redispatch being retained when an animation ends without a usable poster and delivered later; - one hierarchy-wide `RequestApplyInsets` for multiple gated views; and - a stale release from one animation not opening the next animation's gate. The Android `Issue37012` UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles. ### Issues Fixed Fixes #37012 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>


Description of Change
On Android,
MauiWindowInsetListenergates inset dispatches while an IME animation is in flight. Because safe-area padding is reset when a view detaches, a page platform view that reattaches while the keyboard is animating closed can otherwise draw its first frame with zero safe-area padding and jump into place only after the animation ends.This PR changes
src/Core/src/Platform/Android/MauiWindowInsetListener.csin two ways:A view attached during an IME animation can bypass the gate for one valid inset dispatch.
TrySetMauiWindowInsetListener, used by theOnAttachedToWindowpaths ofContentViewGroup,LayoutViewGroup, andMauiScrollView, calls a newNotifyViewAttachedhook. The exemption is one-shot: after a valid dispatch gives the newly attached view initial padding, it is gated like existing views for the rest of the animation. Invalid or empty inset dispatches do not consume the exemption. Because the applied values are animation-time values, a settled hierarchy redispatch remains owed.RefreshMauiWindowInsetListeneris a configuration refresh rather than a fresh attach and does not grant the exemption.The animation gate no longer depends on posting through a detached pending view. Gated or exempted dispatches record that settled insets must be reapplied. At animation end, the listener selects an alive, attached tracked or gated view and posts the gate release for the next looper turn; if no usable poster exists, it releases the gate synchronously while retaining the owed redispatch for a later animation with an attached poster. A release posted by an earlier animation cannot open the gate after a new animation has started.
One
RequestApplyInsetsis enough because it reaches theViewRootImpland redispatches insets across the hierarchy on the next traversal. The added state is listener-scoped and bounded by the animation, with no per-frame work inOnProgress.Tests
MauiWindowInsetListenerTestsAndroid device tests cover:RequestApplyInsetsfor multiple gated views; andThe Android
Issue37012UI test hides the keyboard and pops from Page B to Page A during the IME hide animation. It verifies that Page A has safe-area padding on its first frame after reattach and that keyboard-height bottom padding is not retained after the IME settles.Issues Fixed
Fixes #37012