Skip to content

[Android] SafeArea: Apply insets to views attached during IME animations - #37049

Merged
kubaflo merged 5 commits into
dotnet:inflight/currentfrom
albyrock87:fix-37012-ime-safearea
Aug 9, 2026
Merged

kubaflo merged 5 commits into
dotnet:inflight/currentfrom
albyrock87:fix-37012-ime-safearea

Conversation

@albyrock87

@albyrock87 albyrock87 commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

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

@albyrock87
albyrock87 temporarily deployed to copilot-pat-pool August 3, 2026 17:53 — with GitHub Actions Inactive
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 37049

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 37049"

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
There may be pipelines that require an authorized user to comment /azp run to run.

@albyrock87
albyrock87 temporarily deployed to copilot-pat-pool August 3, 2026 17:53 — with GitHub Actions Inactive
@dotnet-policy-service dotnet-policy-service Bot added the community ✨ Community Contribution label Aug 3, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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.

@albyrock87
albyrock87 temporarily deployed to copilot-pat-pool August 3, 2026 17:54 — with GitHub Actions Inactive
@albyrock87
albyrock87 temporarily deployed to copilot-pat-pool August 3, 2026 17:56 — with GitHub Actions Inactive
@albyrock87
albyrock87 temporarily deployed to copilot-pat-pool August 3, 2026 17:57 — with GitHub Actions Inactive
@github-actions github-actions Bot added area-layout StackLayout, GridLayout, ContentView, AbsoluteLayout, FlexLayout, ContentPresenter platform/android labels Aug 3, 2026
@albyrock87
albyrock87 temporarily deployed to copilot-pat-pool August 3, 2026 17:58 — with GitHub Actions Inactive
@albyrock87

Copy link
Copy Markdown
Contributor Author

/azp run maui-pr

@azure-pipelines

Copy link
Copy Markdown
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>
@kubaflo

This comment has been minimized.

@github-actions github-actions Bot added the s/agent-review-in-progress AI review is currently running for this PR label Aug 5, 2026
@MauiBot MauiBot added s/agent-gate-passed AI verified tests catch the bug (fail without fix, pass with fix) s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) labels Aug 5, 2026

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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);
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(() =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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) { }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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), () =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

[moderate] Regression Prevention and Test Coverage — Fixed-time sampling plus a device-dependent assertion can produce false failures.

  1. 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, so finalPadding (line 85) is sampled mid-transition and the test reports Fail even when the fix works. MAUI convention is to poll for a stable value rather than sample once at a hard-coded delay.
  2. Line 87 requires finalPadding > 0. The page sets NavigationPage.SetHasNavigationBar(this, false), so top padding comes purely from the device status-bar / cutout inset. On an emulator image configured without one, finalPadding == 0 and the test fails regardless of the fix. Report that case distinctly (e.g. Inconclusive: no top inset on this device) instead of Fail.
  3. The capture samples only PaddingTop (lines 85 and 117). The bottom edge is where the new exemption introduces the stale-keyboard-padding risk flagged at MauiWindowInsetListener.cs:260; capturing PaddingBottom as well would make this test guard the change it is shipping with.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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:

  1. 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.
  2. Exemption lifetime — nothing asserts _viewsAttachedDuringImeAnimation is cleared at StartImeAnimation (MauiWindowInsetListener.cs:491) and at OnEnd (line 521), so exemptions leaking from animation N into N+1 would be invisible.
  3. Multi-view drain — the headline change is _pendingView → _pendingViews so that all gated views get RequestApplyInsets. This test exercises one view, so the N>1 behaviour — including the pendingViews[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@MauiBot

This comment has been minimized.

@MauiBot MauiBot removed the s/agent-review-in-progress AI review is currently running for this PR label Aug 5, 2026

@kubaflo kubaflo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@albyrock87

Copy link
Copy Markdown
Contributor Author

@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):

  • Gate lifecycle (3 major): exempted views now stay in _pendingViews so the animation end re-applies their final insets; the gate release drains the live pending set after clearing the flag (no more dropped dispatches in the OnEnd window); and the release posts through an attached pending view or runs synchronously, so a detached view can no longer leave the gate latched for the whole subtree.
  • Hardening (2 moderate): disposed-peer guards in the flush loop, and the SafeAreaEdges config-change path no longer grants the fresh-attach exemption.
  • Test rigor (3 moderate): the UI test now verifies its own precondition (a decor-view IME animation tracker reports whether the pop actually raced the animation — inconclusive runs retry and surface as Assert.Inconclusive, never a false pass), samples by convergence instead of a fixed delay, captures PaddingBottom against the stale-keyboard-padding risk, and new deterministic Android device tests (MauiWindowInsetListenerTests) cover the negative gating case, exemption lifetime, multi-view drain, and a stuck-latch regression.

Verified on the Android emulator: the UI test passes first-attempt-conclusive with the fix and fails with Fail: first frame top=0 settled top=156 on the pre-fix listener, with the precondition confirmed in both runs.

@kubaflo

This comment has been minimized.

@github-actions github-actions Bot added the s/agent-review-in-progress AI review is currently running for this PR label Aug 8, 2026
rmarinho added a commit that referenced this pull request Aug 8, 2026

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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 MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review Summary

@albyrock87 — new AI review results are available based on this last commit: 1a0c9d3.

Gate Passed Confidence High Platform Android


🗂️ 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 RequestApplyInsets after 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.ps1 and its -Restore mode; 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

⚠️ Moderate — the owed settled-insets redispatch can be consumed after its poster detaches.

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:

  1. pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform android -TestFilter "Category=Window"
  2. 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

  1. Snapshot + replay instead of per-view gate exemption. The listener caches the last WindowInsetsCompat dispatched while no IME animation was in flight (_settledInsets). NotifyViewAttached(view) pads the freshly attached view immediately from ViewCompat.GetRootWindowInsets(view) (which already reports the target/settled state during an animation), falling back to _settledInsets. OnApplyWindowInsets keeps the IsImeAnimating gate fully closed for every view, always.
  2. Synchronous gate release instead of a hardened posted release. OnEnd sets IsImeAnimating = false inline and unconditionally issues one hierarchy-wide RequestApplyInsets through 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.

  1. [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 unconditional RequestApplyInsets corrects 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.
  2. [moderate] Logic and Correctness (line 610) — RefreshMauiWindowInsetListener replays settled insets to a possibly already-padded view; safe for ApplyDefaultWindowInsets (absolute padding) but relies on IHandleWindowInsets implementations recomputing rather than accumulating.
  3. [minor] Memory Leak Prevention (line 38) — _settledInsets retains a WindowInsetsCompat Java peer for the listener lifetime; cleared in Dispose(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 (_viewsAttachedDuringImeAnimation present, 9 refs);
    candidate marker _settledInsets is gone.
  • 36 unrelated pre-existing dirty tracked files under .github/ and eng/ are untouched (same count
    as before the attempt), plus the pre-existing untracked scripts.
  • No commits, no pushes, no git checkout/restore/reset used 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" ⚠️ Command exit 0, but sole test was Inconclusive/skipped: emulator reported 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.

  1. [major] Regression Prevention and Test Coverage — known incompatibility with the deterministic fresh-view-bypass assertion.
  2. [moderate] Safe Area and Window Insets — retained position-dependent padding can be stale if a view moves to another window/display during the animation.
  3. [moderate] Memory Leak Prevention — tracked detached views are retained until OnEnd/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 -- src is empty: no candidate source changes remain.
  • PR implementation marker _viewsAttachedDuringImeAnimation is restored (9 references).
  • Candidate markers DetachAppliedSafeAreas and s_mainHandler are absent.
  • All 36 unrelated pre-existing tracked changes under .github/ and eng/ 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 VerifyScreenshot snapshot: 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.

LayoutShouldBeCorrectOnFirstNavigation — `ui-diagnostics` · SafeAreaEdges
Actual (CI)Diff
LayoutShouldBeCorrectOnFirstNavigation actualLayoutShouldBeCorrectOnFirstNavigation diff

📎 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.

@MauiBot MauiBot removed the s/agent-review-in-progress AI review is currently running for this PR label Aug 8, 2026
@kubaflo

kubaflo commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).

@kubaflo
kubaflo merged commit 72e93c3 into dotnet:inflight/current Aug 9, 2026
149 of 163 checks passed
@github-actions github-actions Bot added this to the .NET 10 SR10 milestone Aug 9, 2026
kubaflo pushed a commit that referenced this pull request Aug 12, 2026
…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>
@sheiksyedm sheiksyedm modified the milestones: .NET 10 SR10, .NET 10 SR11 Aug 26, 2026
kubaflo pushed a commit that referenced this pull request Aug 27, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 2, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 3, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 3, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 4, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 6, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 7, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 7, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 8, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 8, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 9, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 9, 2026
…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>
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-layout StackLayout, GridLayout, ContentView, AbsoluteLayout, FlexLayout, ContentPresenter community ✨ Community Contribution platform/android s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-fix-win AI found a better alternative fix than the PR s/agent-gate-passed AI verified tests catch the bug (fail without fix, pass with fix) s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android] Safe-area padding stale for the whole IME hide animation when swapping pages

4 participants