Skip to content

[iOS] Delegate large-title safe area to scroll content - #37548

Closed
jfversluis wants to merge 124 commits into
inflight/currentfrom
jfversluis-fix-33037-follow-up
Closed

jfversluis wants to merge 124 commits into
inflight/currentfrom
jfversluis-fix-33037-follow-up

Conversation

@jfversluis

@jfversluis jfversluis commented Aug 17, 2026 •

Copy link
Copy Markdown
Member

Description

Fixes the remaining non-Shell large-title transition reported in #33037 on iOS 26.

A root MAUI Layout currently applies its Container safe area by physically moving nested scroll content as the navigation bar changes height. UIKit requires the active vertical scroll host to remain edge-to-edge to transition from the leading large title to the centered compact title. The bar therefore collapsed while the approximately 42pt large title remained clipped instead of becoming the native approximately 22pt compact title.

This change introduces explicit safe-area layout ownership:

  • MauiView supplies full bounds and resolved safe-area insets through an internal ISafeAreaLayout contract.
  • Layout first performs its normal safe-area arrangement, then delegates the top safe-area gap to one unambiguous vertical native UIScrollView by extending its frame edge-to-edge and transferring the gap to UIKit content/indicator insets.
  • Inset ownership is delta-based, preserves live side/bottom and keyboard-related values, preserves relative scroll position outside active gestures, and is restored during disconnect or when eligibility changes.
  • Explicit SafeAreaEdges remains authoritative. Ambiguous layouts retain the existing behavior rather than guessing.
  • The final boundary is native UIKit capability, not a MAUI control allow-list: custom and third-party controls backed by a vertical UIScrollView, UITableView, or UICollectionView participate without framework-specific dependencies. A custom MAUI View backed directly by UITableView covers this path.

The old renderer-level SetContentScrollView investigation has been removed; this fix uses standard UIKit frames and UIScrollView inset APIs only.

Eligibility safeguards

Delegation requires iOS 26+, an implicit top Container safe area, a visible translucent navigation bar that prefers large titles, one exclusive vertical scroll subtree, and valid overlay stacking for fixed top siblings. Hidden, transparent, horizontal, explicitly safe-area-configured, or ambiguous candidates are rejected.

Runtime validation

Verified on iPhone 17 Pro / iOS 26.5:

  • Modal nested ListView: leading large title collapses to a centered compact title and expands again on return to top.
  • Direct/wrapped ScrollView, ListView, TableView, CollectionView, CarouselView, and WebView scenarios.
  • Fixed-header and programmatic-scroll scenarios.
  • Custom third-party-like MAUI View whose handler exposes a native UITableView.
  • Scroll-host frame remains stable and edge-to-edge throughout the transition.
  • Repeated close/reopen after scrolling does not crash.

Original nested scenario

Expanded Collapsed
Expanded large title Centered compact title

Custom native-scroll control

Expanded Collapsed
Custom native UITableView with expanded title Custom native UITableView with compact title

Tests

  • iOS Controls device tests, Category=Layout: 31 passed, 1 skipped, 0 failed.
  • Controls HostApp iOS simulator build: succeeded with 0 warnings/errors.
  • Shared UI test assembly build: succeeded with 0 warnings/errors.
  • Full maui-pr and Build Analysis checks: passed, including Windows/macOS builds, Helix unit tests, and integration legs.

Note

This remains a draft architectural spike for maintainer review of the new safe-area ownership contract.

<!--
!!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING
MAIN. !!!!!!!
-->

On iOS, Shell FlyoutItems are rendered inside the SafeArea.

<!-- Enter description of the fix in this section -->
Updated the Shell Flyout layout logic to ensure FlyoutItems are
consistently rendered within the SafeArea for both portrait and
landscape modes.

<!-- Please make sure that there is a bug logged for the issue being
fixed. The bug should describe the problem and how to reproduce it. -->

Fixes #32275
Fixes #35583

<!--
Are you targeting main? All PRs should target the main branch unless
otherwise noted.
-->

| Before  | After  |
|---------|--------|
| **iOS**<br> <video
src="https://github.com/user-attachments/assets/2cd0ead7-2ca7-4517-ade5-f46471b9530d"
width="300" height="600"> | **iOS**<br> <video
src="https://github.com/user-attachments/assets/786d847e-f085-49b9-a026-b54f4ed5be9a"
width="300" height="600"> |

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This pull request introduces a new test page for verifying SafeArea
handling within a `Border` control, adds supporting code-behind logic,
and integrates the new test into the SafeArea feature matrix. The main
focus is to enable testing of how the `Border` responds to different
safe area configurations and soft input (keyboard) insets, especially on
Android. Below are the most important changes:

**New SafeArea Border Test Page:**
- Added a new XAML page, `SafeAreaBorderPage.xaml`, which demonstrates
and tests safe area behavior when applied to a `Border` rather than the
entire page. The UI includes edge indicators, controls for toggling safe
area modes, and an entry for keyboard interaction testing.

**Supporting Logic and Platform Handling:**
- Implemented `SafeAreaBorderPage.xaml.cs` with logic to update insets,
handle navigation, and respond to user interactions. On Android, the
page sets the soft input mode to allow the `Border` to handle insets and
listens for window inset changes to update the UI in real time.

**Test Integration and Navigation:**
- Added a "Border SafeArea" button to the main SafeArea feature test
page (`SafeAreaFeaturePage.xaml`) to allow navigation to the new border
test.
- Implemented the corresponding click handler in
`SafeAreaFeaturePage.xaml.cs` to display the new `SafeAreaBorderPage`.

**Issue Found:**
#34872

---------

Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

> [!IMPORTANT]
> 🤖 **AI-generated PR.** This pull request was produced automatically by
the **Memory Leak Fixer** agentic workflow on dotnet/maui. The
regression test below was empirically proven to **fail on `main` without
the fix** and **pass with it** during this run. Please review carefully
before merging.

Fixes #36343
Refs: #36343
Target branch: `main`
Attempt: 1/3

## The leak

`SwipeItemView.OnCommandChanged()` subscribed to the assigned command
with a **strong** delegate:

```csharp
Command.CanExecuteChanged += OnCommandCanExecuteChanged;
```

Because `OnCommandCanExecuteChanged` is an instance method, the
delegate's `Target` is the `SwipeItemView`. Any command that raises
`CanExecuteChanged` through a **strong** event (e.g. a custom
`ICommand`, or `CommunityToolkit.Mvvm`'s `RelayCommand`) therefore keeps
the `SwipeItemView` — and its entire visual subtree — alive for as long
as the command lives. The matching `-=` only ran in
`OnCommandChanging()` (the property-*changing* path), so a view that is
simply unloaded/discarded while a longer-lived command outlives it is
never unsubscribed, and it leaks.

> `Microsoft.Maui.Controls.Command` raises `CanExecuteChanged` through a
`WeakEventManager` and does **not** leak — the regression test covers
that non-leaking case too, to prove the fix preserves it.

## The fix

Replace the raw subscription with the `WeakCommandSubscription` helper
already used by `Button`, `ImageButton`, `RefreshView`, `MenuItem`,
`SearchBar`, `TextCell`, etc. It links the command to the view through a
`DependentHandle`, so the command references the view only **weakly**
and the view can be collected once nothing else roots it:

```csharp
WeakCommandSubscription _commandSubscription;

void OnCommandChanged()
{
    IsEnabled = Command?.CanExecute(CommandParameter) ?? true;

    if (Command == null)
        return;

    _commandSubscription = new WeakCommandSubscription(this, Command, OnCommandCanExecuteChanged);
}

void OnCommandChanging()
{
    _commandSubscription?.Dispose();
    _commandSubscription = null;
}
```

- No public API change — the field is private and
`WeakCommandSubscription` is internal.
- All existing `IsEnabled` behavior is preserved; only the subscription
lifetime changes.
- Idiomatic: mirrors exactly how the sibling command-bearing controls
already harden this same subscription.

## Regression test

Added the `SwipeItemView` case to the existing
`CommandsSubscribedToCanExecuteCollect` theory in
`src/Controls/tests/Core.UnitTests/CommandTests.cs`, right alongside
every other command-bearing control. It assigns a strong-event
`ICommand`, drops the view in a nested scope, and asserts the view is
collected (`WaitForCollect` returns `false`).

| State | `SwipeItemView, useWeakEventHandler: False` | Full theory |
|-------|---------------------------------------------|-------------|
| Without fix (`main`) | ❌ FAIL — `Assert.False` (view still alive) | 15
passed / **1 failed** |
| With fix | ✅ PASS | **16 passed** / 0 failed |

No regressions with the fix applied: `CommandTests` (45/45) and
`SwipeViewTests` (24/24) pass. Validated by building MAUI from source on
the net library TFM.

## Scope

- Managed, cross-platform product change only:
`src/Controls/src/Core/SwipeView/SwipeItemView.cs`.
- Test: `src/Controls/tests/Core.UnitTests/CommandTests.cs`.
- No test was muted, skipped, weakened, or deleted; no
`[ActiveIssue]`/`Skip=`; no public API change.

---
_Generated by the Memory Leak Fixer agentic workflow (dotnet/maui). This
description is AI-generated — please review before merging._




> Generated by [Memory Leak
Fixer](https://github.com/dotnet/maui/actions/runs/28685456694) · 2.6K
AIC · ⌖ 212.1 AIC · ⊞ 60.5K ·
[◷](https://github.com/search?q=repo%3Adotnet%2Fmaui+%22gh-aw-workflow-id%3A+leak-fixer%22&type=pullrequests)

<!-- gh-aw-agentic-workflow: Memory Leak Fixer, engine: copilot,
version: 1.0.60, model: claude-opus-4.8, id: 28685456694, workflow_id:
leak-fixer, run: https://github.com/dotnet/maui/actions/runs/28685456694
-->

<!-- gh-aw-workflow-id: leak-fixer -->
<!-- gh-aw-workflow-call-id: dotnet/maui/leak-fixer -->

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…6369)

> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

> 🤖 **AI-generated.** This pull request was produced automatically by
the **Memory Leak Fixer** agentic workflow (`leak-fixer`) for
dotnet/maui. It contains a regression test empirically proven to fail
without the fix and pass with it (red → green on this run's `main`
source).

Fixes #36344
Refs: #36344
Target branch: main
Attempt: 1/3

## The leak

`ListView.OnRefreshCommandChanged` subscribed to
`RefreshCommand.CanExecuteChanged` with a plain instance handler:

```csharp
newCommand.CanExecuteChanged += OnCommandCanExecuteChanged;
```

Because `OnCommandCanExecuteChanged` is an instance method, the
delegate's target is the `ListView`. When the assigned `ICommand` is
long-lived (a singleton/DI/static command, or a reused view-model
command) **and** exposes `CanExecuteChanged` as a strong .NET event,
that event's invocation list roots the entire `ListView` — including its
cells, templates and binding context. The matching unsubscribe (`-=`)
only runs when `RefreshCommand` is reassigned, never when the `ListView`
is simply unloaded, so the control is never released.

> `Microsoft.Maui.Controls.Command` routes `CanExecuteChanged` through a
`WeakEventManager`, so it does **not** reproduce the leak. The leak
surfaces with any custom `ICommand` (e.g. CommunityToolkit's
`RelayCommand`) that raises the event directly — which is exactly what
the issue reports.

## The fix

Replace the direct subscription with a `WeakCommandSubscription` — the
same weak-subscription helper already used by `CommandElement`, `Button`
and `RefreshView`:

```csharp
_refreshCommandSubscription = new WeakCommandSubscription(this, newCommand, OnCommandCanExecuteChanged);
```

On reassignment the previous subscription is disposed.
`WeakCommandSubscription` holds only a weak reference to the `ListView`
(via `DependentHandle`), so the command can no longer keep it alive,
while `CanExecuteChanged` is still delivered for as long as the
`ListView` is reachable. No public API changes.

## Regression test

`RefreshCommandDoesNotLeakListView` (in
`Controls.Core.UnitTests/ListViewTests.cs`) assigns a custom
strong-event `ICommand` to `ListView.RefreshCommand`, drops the only
strong reference to the `ListView`, forces GC and asserts the `ListView`
was collected while the command stays alive.

Empirical red → green on this run's source:

- **Without the fix (red):** `Failed RefreshCommandDoesNotLeakListView
[895 ms]` — `ListView should not be alive!` (Failed: 1)
- **With the fix (green):** `Passed RefreshCommandDoesNotLeakListView
[47 ms]` (Passed: 1, Test Run Successful)
- **No regressions:** full `ListViewTests` class — Total tests: 71,
Passed: 71 (including `RefreshCommandCanExecute` and
`SendRefreshingDoesNothingWhenCannotExecute`, which exercise the
`CanExecute` path)

## Scope

Managed, cross-platform change only — one product file
(`src/Controls/src/Core/ListView/ListView.cs`) plus the test. No
existing test was muted, skipped or weakened; no behavioural change to
`RefreshCommand`/`CanExecute` handling.




> Generated by [Memory Leak
Fixer](https://github.com/dotnet/maui/actions/runs/28693580490) · 2.4K
AIC · ⌖ 101 AIC · ⊞ 60.6K ·
[◷](https://github.com/search?q=repo%3Adotnet%2Fmaui+%22gh-aw-workflow-id%3A+leak-fixer%22&type=pullrequests)

<!-- gh-aw-agentic-workflow: Memory Leak Fixer, engine: copilot,
version: 1.0.60, model: claude-opus-4.8, id: 28693580490, workflow_id:
leak-fixer, run: https://github.com/dotnet/maui/actions/runs/28693580490
-->

<!-- gh-aw-workflow-id: leak-fixer -->
<!-- gh-aw-workflow-call-id: dotnet/maui/leak-fixer -->

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…s=None (#36072)

<!-- Please let the below note in for people that find this PR -->
   > [!NOTE]
   > Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
  Thank you!
 
### Root Cause
When `NavigationPage.HasNavigationBar` is set to `false`,
`ToolbarExtensions.UpdateIsVisible` collapses the toolbar height to `0`.
However, the `AppBarLayout` parent retains stale top padding from a
previous inset dispatch — for example, `159px` from the status
bar/cutout.

Even with toolbar height `0`, `AppBarLayout.MeasuredHeight` remains at
`159px` because it is driven by padding alone. `MauiWindowInsetListener`
checks `MeasuredHeight > 0` to determine whether the AppBar has content.
Since it sees `159 > 0`, it treats the AppBar as having content,
consumes the top system inset, and the inset never reaches
`SafeAreaExtensions` downstream.

The result is a blank gap at the top of the screen equal to the status
bar height — visible on both `NavigationPage` (when a page is pushed
with the nav bar hidden) and `Shell` (when the navigation bar is hidden
at runtime).

### Description of Change
A single line is added to `ToolbarExtensions.UpdateIsVisible` in the
`!showNavBar` branch to call `SetPadding(0, 0, 0, 0)` on the
`AppBarLayout`.

This clears the stale padding so `MeasuredHeight` collapses to `0`. The
inset listener then correctly identifies the AppBar as having no
content, passes the top inset downstream, and `SafeAreaEdges=None` is
fully respected with no blank gap.

The re-show path is safe — `RequestApplyInsets` is called in both
branches of `UpdateIsVisible`, so the next inset dispatch naturally
restores the correct padding when the nav bar is shown again.

Parent traversal uses
`nativeToolbar.Parent?.GetParentOfType<AppBarLayout>()` — the
established MAUI pattern used in `ShellToolbarTracker.cs`.

Two new device tests pin the fix for both affected paths:

* `PushingToPageWithoutNavigationBarClearsAppBarInsetPadding` for the
`NavigationPage` scenario
* `HiddenShellNavigationBarClearsAppBarInsetPadding` for the `Shell`
scenario
These tests were adopted from open contributor PR #35555 .
 
### Issues Fixed
Fixes #35103 
Fixes #34472 
 
Tested the behaviour in the following platforms
- [x] Android
- [ ] Windows
- [ ] iOS
- [ ] Mac

### Screenshots
| Before Issue Fix | After Issue Fix |
|------------------|-----------------|
| <video width="350" alt="withoutfix"
src="https://github.com/user-attachments/assets/ce6064b7-6a8e-47af-a2c7-3259f71af379"
/> | <video width="350" alt="withfix"
src="https://github.com/user-attachments/assets/67c5821b-8057-4048-9e05-e0c508f67ae5"
/> |
…e space when no items are present (#36127)

<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

- On iOS/MacCatalyst, a CollectionView with no items incorrectly expands
to fill the entire screen height instead of collapsing to zero height,
pushing all sibling views off screen. Android behaves correctly.

- PR #30978 introduced an unconditional expansive fallback in
GetDesiredSize — when ContentSize == 0, it returned
base.GetDesiredSize() with no empty-CV guard. PR #31902 refactored this
into EnsureContentSizeForScrollDirection but preserved the same flawed
logic, as did subsequent refactors. On iOS/MacCatalyst,
EnsureContentSizeForScrollDirection cannot distinguish between a
genuinely empty CollectionView (0 items) and one whose items haven't
been laid out yet by UICollectionViewCompositionalLayout — both report
ContentSize == 0 — so it always applies the expansive fallback size,
causing an empty CV to expand to fill the screen instead of collapsing
to zero height.

- Updated ItemsViewHandler2.iOS.cs to avoid applying an expansive
fallback size when the CollectionView is empty (no items, no EmptyView).
Both the IsEmpty guard and the zero-frame check are scoped to
CollectionView only. Also handles the case where the CollectionView's
frame is zero in the scroll direction after transitioning from empty to
non-empty.
- Added the IsEmpty property to ItemsViewController2.cs using the
_isEmpty field (instead of ItemsSource?.ItemCount == 0) to correctly
handle a null ItemsSource and expose empty-state to the handler.

Fixes #35889

- [ ] Windows
- [ ] Android
- [x] iOS
- [x] Mac

| Before | After |
|----------|----------|
| <img
src="https://github.com/user-attachments/assets/2bb16dd8-3c4a-471b-b117-a9588c1467aa">
| <img
src="https://github.com/user-attachments/assets/df0dbf6c-6dd6-4145-9089-277cebd0cfbc">
|

---------

Co-authored-by: Shane Neuville <5375137+PureWeen@users.noreply.github.com>
Co-authored-by: PureWeen <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

### Root Cause

Controls replaces the core `Button.Text` mapper on iOS/MacCatalyst so it
can handle Controls-specific behavior such as `TextTransform` and
`ContentLayout`. That Controls mapper updated the native `UIButton`
title but did not rebuild the attributed title used when
`CharacterSpacing` is applied, so UIKit could continue displaying the
previous `CurrentAttributedTitle` after `Button.Text` changed.

### Description of Change

After Controls updates the native button text on iOS/MacCatalyst, it now
reapplies the core button formatting mapper so the attributed title is
rebuilt from the latest text.

The iOS Controls `UIButton.UpdateText` helper also keeps
`TitleLabel.Text` synchronized with the transformed text for every text
update. This gives `UpdateCharacterSpacing` the current text when it
rebuilds the attributed title.

### Key Technical Details

- `Button.iOS.cs` now calls `ButtonHandler.MapFormatting(handler,
button)` after `UpdateText(button)`.
- `ButtonExtensions.UpdateText` now assigns
`platformButton.TitleLabel.Text = text` for all text updates, not only
when the logical `Button.Text` is empty.
- The regression test checks the native `CurrentAttributedTitle` text
and kerning after `Text` changes with `CharacterSpacing` applied.

### Edge Cases Covered

| Scenario | Coverage |
| --- | --- |
| `CharacterSpacing` + text update | Device test and external repro |
| `CharacterSpacing` + `ContentLayout` + text update | Device test and
external repro |
| `CharacterSpacing` + `TextTransform` + text update | Device test and
external repro |

@tdeborde2 @weases could you please try the PR artifacts when CI
publishes them and confirm whether this resolves the issue in your apps?

### Issues Fixed

Fixes #21488

### Validation

- Reproduced the issue with the external repro project from #21488 on
.NET 10 / MAUI 10.0.80.
- Verified the same repro passes when rebuilt against the fixed local
MAUI artifacts, including CharacterSpacing, CharacterSpacing +
ContentLayout, and CharacterSpacing + TextTransform cases.
- Ran Controls Button device tests on MacCatalyst: 21/21 passed.
- Built the iOS Controls device-test app successfully.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
FlexLayout's SelfSizing callback measured items using GetConstraints(),
which returns the flex container's full size and ignores the item's own
margins. The flex engine then arranges the item at the stretched
cross-axis size (container minus margins), which is narrower. Wrapping
content (e.g. Label text with LineBreakMode=WordWrap) wraps against a
width the item never receives, and since the arrange pass never
re-measures, the overflow line is cut off.

The engine already passes the correct available size into the callback
(the ref w/h parameters); clamp the measure constraints with those
values when they are known.

Fixes #27520

Manual testing: See the test file I added in 27520. I have manually
tested over 100 repetitions of re-clicking the button to generate new
labels and not been able to reproduce the isssue. Previously, around 1
in every 5 re-draws would reproduce the issue.

---------
#36442)

<!-- Please keep the note below for people who find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment whether this change resolves your
issue. Thank you!<!--
!!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING
MAIN. !!!!!!! -->

This pull request addresses the handling of stroke line caps for borders
on Windows and updates related tests. The main focus is to ensure that
`StrokeDashCap` is set correctly along with `StrokeStartLineCap` and
`StrokeEndLineCap`, and to enable previously disabled tests now that the
related issues are resolved.

### Description of Change

**Platform rendering improvements:**

* Updated `UpdateStrokeLineCap` in `BorderExtensions.cs` to set
`StrokeDashCap` in addition to `StrokeStartLineCap` and
`StrokeEndLineCap`, ensuring consistent rendering of dashed borders on
Windows.

**Test updates:**

* Re-enabled previously disabled border stroke dash array tests for
Windows in `BorderFeatureTests.cs`, allowing them to run on all
platforms now that the underlying issues are fixed.
[[1]](diffhunk://#diff-a65f4bf692bd3303cdf056e3471f1280afb39d184f4e32769a8698f2a35db0a7L502)
[[2]](diffhunk://#diff-a65f4bf692bd3303cdf056e3471f1280afb39d184f4e32769a8698f2a35db0a7L631)


<!-- Enter description of the fix in this section -->

### Issues Fixed

<!-- Please make sure that there is a bug logged for the issue being
fixed. The bug should describe the problem and how to reproduce it. -->

Fixes #29741 

### Tested the behavior in the following platforms

- [x] Windows
- [ ] Android
- [ ] iOS
- [ ] Mac


### Snapshots :

 | Before Fix | After Fix |
 |--------------------------|---------------------------|
| <img width="1429" height="876" alt="BeforeFix29741"
src="https://github.com/user-attachments/assets/34a7b94e-8da0-4aba-97ea-d38d32558656"
/> | <img width="1285" height="1016" alt="AfterFix29471"
src="https://github.com/user-attachments/assets/eae441de-e66c-40aa-af63-97519b1866ab"
/>|
<!--
Are you targeting main? All PRs should target the main branch unless
otherwise noted.
-->
…36370)

> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

> [!IMPORTANT]
> This pull request was generated automatically by the **Memory Leak
Fixer** agentic workflow in dotnet/maui. It contains a regression test
empirically proven to fail without the fix and pass with it. Please
review carefully — it is AI-generated.

Fixes #36345
Refs: #36345
Target branch: main
Attempt: 1/3

## The leak

`BackButtonBehavior` subscribed to `ICommand.CanExecuteChanged` with a
**strong** delegate:

```csharp
newCommand.CanExecuteChanged += CanExecuteChanged;
```

Because an `ICommand` is commonly stored on a long-lived view model (or
otherwise shared/retained), that strong subscription keeps the
`BackButtonBehavior` — and everything it transitively roots — alive for
as long as the command lives. The only unsubscribe happened when the
`Command` property itself changed, so a behavior whose command outlives
it is never released.

Retention path (from issue #36345):

`ICommand (long-lived) -> CanExecuteChanged event ->
BackButtonBehavior.CanExecuteChanged delegate -> BackButtonBehavior`

## The fix

Route the subscription through the existing `WeakCommandSubscription`
helper (already used by `CommandElement` for `Button`, `ImageButton`,
`RefreshView`, etc.). It uses a `DependentHandle` so the command
references the behavior only weakly, and the previous subscription is
disposed whenever the command changes.

`src/Controls/src/Core/Shell/BackButtonBehavior.cs`:

```csharp
WeakCommandSubscription _commandSubscription;

void OnCommandChanged(ICommand oldCommand, ICommand newCommand)
{
    _commandSubscription?.Dispose();
    _commandSubscription = null;

    if (newCommand != null)
    {
        _commandSubscription = new WeakCommandSubscription(this, newCommand, CanExecuteChanged);
        IsEnabledCore = Command.CanExecute(CommandParameter);
    }
    else
    {
        IsEnabledCore = true;
    }
}
```

No public API change (private field, internal helper). All
`IsEnabled`/`CanExecute` behavior is preserved.

## Regression test

Extends the existing `CommandsSubscribedToCanExecuteCollect` theory in
`src/Controls/tests/Core.UnitTests/CommandTests.cs` with
`BackButtonBehavior` rows for both the weak-event and strong-event
command variants. The strong-event variant (`useWeakEventHandler:
false`) reproduces the leak.

Verified in this run (net TFM, `Controls.Core.UnitTests`):

| State | `BackButtonBehavior, false` | Result |
| --- | --- | --- |
| Without fix (red) | `Assert.False() Failure — Expected: False, Actual:
True` (behavior still alive) | ❌ FAIL |
| With fix (green) | behavior collected | ✅ PASS |

Red run (unpatched source):

```
Failed CommandsSubscribedToCanExecuteCollect(controlType: typeof(Microsoft.Maui.Controls.BackButtonBehavior), useWeakEventHandler: False) [1 s]
  Assert.False() Failure
Expected: False
Actual:   True
...
Total tests: 16
     Passed: 15
     Failed: 1
```

Green run (with fix):

```
Passed CommandsSubscribedToCanExecuteCollect(controlType: typeof(Microsoft.Maui.Controls.BackButtonBehavior), useWeakEventHandler: False) [82 ms]
...
Total tests: 16
     Passed: 16
```

Full `CommandTests` class after the fix: **45 passed, 0 failed** (no
regressions).

## Scope

- Managed, cross-platform only:
`src/Controls/src/Core/Shell/BackButtonBehavior.cs`.
- No test was muted, skipped, or weakened; no `[ActiveIssue]` / `Skip=`
/ timeout changes.
- Mirrors the same weak-subscription hardening already applied to
sibling command controls.

---
🤖 Generated by the **Memory Leak Fixer** agentic workflow (dotnet/maui).
This PR is AI-generated — please review before merging.




> Generated by [Memory Leak
Fixer](https://github.com/dotnet/maui/actions/runs/28694768688) · 2.2K
AIC · ⌖ 176.3 AIC · ⊞ 60.6K ·
[◷](https://github.com/search?q=repo%3Adotnet%2Fmaui+%22gh-aw-workflow-id%3A+leak-fixer%22&type=pullrequests)

<!-- gh-aw-agentic-workflow: Memory Leak Fixer, engine: copilot,
version: 1.0.60, model: claude-opus-4.8, id: 28694768688, workflow_id:
leak-fixer, run: https://github.com/dotnet/maui/actions/runs/28694768688
-->

<!-- gh-aw-workflow-id: leak-fixer -->
<!-- gh-aw-workflow-call-id: dotnet/maui/leak-fixer -->

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…33183)

<!--
!!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING
MAIN. !!!!!!!
-->

### Issue Details

On iOS, LoadHtml uses LoadHtmlString(), which does not add the HTML page
to WebView history.
Because of this, GoBack() cannot navigate back after loading HTML.

### Description of Change

<!-- Enter description of the fix in this section -->
MauiWKWebView.cs — Replaced LoadHtmlString() with LoadSimulatedRequest()
(iOS 15+ / MacCatalyst 15+). This method loads the HTML content while
creating a proper WKWebView back/forward history entry, enabling
GoBack()/GoForward() to work correctly. For iOS 13/14 and earlier
MacCatalyst, the implementation falls back to LoadHtmlString() to
preserve compatibility.

### Issues Fixed

<!-- Please make sure that there is a bug logged for the issue being
fixed. The bug should describe the problem and how to reproduce it. -->

Fixes #30381

<!--
Are you targeting main? All PRs should target the main branch unless
otherwise noted.
-->

**Tested the behavior in the following platforms.**
- [x] Android
- [x] Windows
- [x] iOS
- [x] Mac

| Before  | After  |
|---------|--------|
| **iOS**<br> <video
src="https://github.com/user-attachments/assets/1b328f84-c5d4-4b6f-a2b3-bbffbf338895"
width="300" height="600"> | **iOS**<br> <video
src="https://github.com/user-attachments/assets/4ca2e343-b452-4958-afd3-b8f1ff5eafde"
width="300" height="600"> |
…ies (#35944)

<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

### Issue Details:

MediaPicker - PickPhotosAsync doesn't return on Android when using
multiple Activities
       
### Root Cause:

Commit
[0c25c62](0c25c62)
introduced a guard in ActivityForResultRequest.Register() that checked
whether an existing ActivityResultLauncher was already registered for a
still-alive activity. If one existed, the method returned early without
registering a new launcher. This guard was designed to prevent duplicate
registrations (fixing issue #32845), but it had an unintended side
effect: when a child AppCompatActivity called Platform.Init(), the guard
saw the main activity's launcher as still alive and silently skipped
registration entirely. As a result, the child activity had no
ActivityResultLauncher of its own. On Android API 36, launcher ownership
is strictly enforced — the system only delivers an ActivityResult to the
launcher that belongs to the activity that launched the intent. Since
the child activity had no launcher, PickPhotosAsync() launched the photo
picker but its TaskCompletionSource was never resolved, causing the
await to hang indefinitely.

### Description of Change:

The fix replaces the single global launcher slot and its guard with a
ConditionalWeakTable<ComponentActivity, ActivityResultLauncher>. This
table stores one launcher entry per activity instance. Because
ConditionalWeakTable uses weak keys, entries are automatically eligible
for garbage collection when the activity is destroyed, preventing memory
leaks. The Register() method now checks whether the specific calling
activity already has an entry (idempotent per-instance), and if not,
creates and stores a new launcher for it. At call time,
GetLauncherForCurrentActivity() resolves the current foreground activity
and retrieves its entry from the table. This ensures every activity —
whether the main activity or any child — gets its own launcher, fixing
the hang while also preserving the original #32845 fix since no activity
can overwrite another's entry.

**Tested the behavior in the following platforms:**

- [x] Android
- [ ] Windows
- [ ] iOS
- [ ] Mac

### Reference:

N/A

### Issues Fixed:

Fixes  #35826          

### Screenshots
| Before  | After  |
|---------|--------|
| <Video
src="https://github.com/user-attachments/assets/79983dcf-bc04-4536-af48-2b1ff2215611"
Width="600" Height="300"/> | <Video
src="https://github.com/user-attachments/assets/8775de80-9200-496d-ac8d-33d049502425"
Width="600" Height="300"/> |

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

### Description of Change

Re-enables the Android memory-leak UI tests that were disabled under
#27411.

Verified locally on an Android API 30 emulator (multiple runs, stable):

| Test | Result |
| --- | --- |
| `Bugzilla40955.MemoryLeakInFormsAppCompatActivity` | ✅ now passes —
Android `[Ignore]` removed |
| `Bugzilla42329.MemoryLeakB42329` | ✅ now passes — Android `[Ignore]`
removed |
| `Bugzilla44166.Bugzilla44166Test` | ❌ still leaks (`FlyoutPage` not
collected) — kept skipped, now points at #36479 |

The device-level memory tests referenced by #27411
(`MemoryTests.PagesDoNotLeak`,
`NavigationPageTests.ChildPagesDoNotLeak`, `ShellTests.PagesDoNotLeak`)
are already enabled on `main`.

This closes out the umbrella #27411 and carves the one remaining Android
leak into a dedicated tracking issue.

### Issues Fixed

Fixes #27411
Tracks remaining leak: #36479

---
_This PR was prepared by GitHub Copilot CLI on behalf of @kubaflo, based
on local device-test verification._

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…36252)

<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

> [!NOTE]
> 🤖 **AI-generated PR.** This fix and its regression test were produced
by an AI-driven memory-leak workflow and verified locally. Please review
carefully before merging.

Fixes #35775

### The leak

`IndicatorView.ItemsSource` subscribed the `IndicatorView` to the
assigned collection with a plain, non-weak delegate —
`collection.CollectionChanged += OnCollectionChanged` in
`ResetItemsSource`
(`src/Controls/src/Core/IndicatorView/IndicatorView.cs`). Because
`OnCollectionChanged` is an instance method, the delegate `Target` is
the `IndicatorView`, and the only `-=` ran when `ItemsSource` itself
changed — never on unload/navigation. The documented usage is to bind
the **same long-lived collection as a `CarouselView`**, so that shared
`ObservableCollection` strongly retained every `IndicatorView` attached
to it (and its visual subtree / behaviors) for the collection's whole
lifetime.

### The fix

Replace the plain subscription with a `WeakNotifyCollectionChangedProxy`
stored in a field, and add a finalizer that calls `Unsubscribe()`. This
mirrors the **existing `BindableLayoutController` pattern**
(`src/Controls/src/Core/BindableLayout/BindableLayout.cs`): the
collection now references only the proxy, which holds the
`IndicatorView` weakly, so it can be collected once unreferenced; the
handler delegate is kept alive in a field while the `IndicatorView`
lives, and the finalizer tears the proxy down from the still-alive
collection.

### Regression test

`IndicatorViewTests.IndicatorViewItemsSourceDoesNotLeak` in
`src/Controls/tests/Core.UnitTests/IndicatorViewLayoutTests.cs`: assigns
a long-lived `ObservableCollection` to a new `IndicatorView`, drops the
only strong reference, forces GC, and asserts the view is collected via
`WeakReference.WaitForCollect()`.

**Verified locally against this PR's base (`main`),
`Controls.Core.UnitTests`:**

| State | Result |
|---|---|
| Without fix (current `main`) | ❌ FAILS — `IndicatorView should not be
alive!` |
| With fix | ✅ `Passed IndicatorViewItemsSourceDoesNotLeak` |

### Scope

Managed cross-platform change → all platforms. The only public-API
addition is the finalizer, recorded in the per-TFM
`PublicAPI.Unshipped.txt` files.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…using weak event source (#36503)

<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!


<!--
!!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING
MAIN. !!!!!!!
-->

### Issue Details

The Accelerometer.Default  is a singleton instance that persists for the
entire lifetime of the application. When a page, view model, or service
subscribes to  Accelerometer.ReadingChanged  or  ShakeDetected  using
 +=  but does not explicitly unsubscribe with  -=  — a common oversight,
since calling  Stop()  may appear to perform cleanup but does not remove
the event subscription — the singleton retains a strong reference to the
subscriber indefinitely. Consequently, the subscriber, along with any
objects it references, is never eligible for garbage collection. This
results in a progressively growing memory leak each time the pattern
recurs.

### Root Cause

The issue occurs because Accelerometer.ReadingChanged and ShakeDetected
being backed by plain multicast delegate events on Accelerometer.Default
, which is an app-lifetime static singleton. When a subscriber registers
with += but never explicitly unsubscribes with -= (a common mistake,
since calling Stop() looks like cleanup but does not clear the
delegate), the singleton's multicast delegate holds a strong reference
to the subscriber for as long as the process runs, preventing it from
ever being garbage collected — even after the subscriber's owning page,
view model, or service should have gone out of scope.

### Description of Change

The fix involves replacing the plain multicast delegate events with a
custom  WeakEventSource  /  WeakEventSource<TEventArgs>  type that
weakly references each subscriber's target object while keeping its
closed delegate in a  ConditionalWeakTable  keyed by that target,
invoking handlers via direct delegate calls rather than reflection. This
avoids the  MethodInfo.Invoke  overhead and exception-wrapping behavior
of the pattern used by  Microsoft.Maui.WeakEventManager  elsewhere in
the codebase (not reused directly here due to a circular
project-reference constraint between Essentials and Core), so subscriber
exceptions propagate with their original type and instance intact.

Dead entries are pruned lazily during  Subscribe ,  Unsubscribe ,
 Raise , and  HasHandlers . This allows a forgotten subscriber to be
collected normally while still-alive subscribers continue to receive
events correctly, and ensures a collected subscriber does not keep shake
processing enabled indefinitely — verified by unit tests covering the
leak scenario, the live-subscriber-after-GC scenario, and direct
exception propagation.

Validated the behavior in the following platforms
 
- [x] Android
- [x] Windows
- [x] iOS
- [x] Mac
 
### Issues Fixed
  
Fixes #36216

### Output  ScreenShot

|Platform|Before|After|
|--|--|--|
|Android| <video
src="https://github.com/user-attachments/assets/708f2d8e-8ee4-495c-a2a3-cb3771532121"
>| <video
src="https://github.com/user-attachments/assets/3d787398-b7fa-43d4-a362-9f1d1eef6380">|
|Mac|<video
src="https://github.com/user-attachments/assets/4236c5ed-dbdc-4002-95b6-e08dc6c95ca2">
|<video
src="https://github.com/user-attachments/assets/13475a2c-35a6-4065-8097-d62d68e7be76">|
|iOS|<video
src="https://github.com/user-attachments/assets/f54329ba-5c30-47d3-94d0-38093ba92cf4">|<video
src="https://github.com/user-attachments/assets/6f75dc4e-8ca5-47a2-b357-16fa906b9add">|
|Windows|<video
src="https://github.com/user-attachments/assets/e5e9455a-58c7-4ca8-9e2b-a15b4f6ba271">|<video
src="https://github.com/user-attachments/assets/2cfc5011-f79a-45d4-aa18-94abd8f6b576">
|
<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

<!--
!!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING
MAIN. !!!!!!!
-->


### Issue Details
SwipeItem BackgroundColor with AppThemeBinding does not update when app
theme changes between light and dark modes.

### Root Cause
SwipeItem.BackgroundColorProperty` had no `propertyChanged` callback, so
a theme-triggered value change never notified the handler. Additionally,
`SwipeItemMenuItemHandler`'s property mapper was keyed on
`IView.Background` instead of `ISwipeItemMenuItem.Background`, so
`UpdateValue` calls for that property never matched the mapper entry

### Description of Change
Added a `propertyChanged` callback on `BackgroundColorProperty` that
calls `Handler?.UpdateValue(nameof(ISwipeItemMenuItem.Background))`, and
corrected the `SwipeItemMenuItemHandler` mapper key to
`ISwipeItemMenuItem.Background` so the update now reaches
`MapBackground`.

Validated the behavior in the following platforms
 
- [x] Android
- [ ] Windows
- [x] iOS
- [x] Mac
 
### Issues Fixed
  
Fixes #31917 

### Output  ScreenShot

|Before|After|
|--|--|
| <video
src="https://github.com/user-attachments/assets/1f9b95cd-56ab-413b-b70e-1f094b105d00"
>| <video
src="https://github.com/user-attachments/assets/d3748379-8443-424e-ad0d-2e307d94eade">|
<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

Fixes #35857

Reviewer: @StephaneDelcroix

## Description

The BindingSourceGen source generator used the full source file path as
part of its generated source hint name. When
`EmitCompilerGeneratedFiles` is enabled, Roslyn mirrors that hint name
into the generated file path, which can exceed Windows `MAX_PATH` for
projects in deeper directory structures.

This changes the generated binding source hint name to use a compact
stable hash of the source file path plus the binding line and column,
avoiding long absolute paths while preserving deterministic uniqueness.

## Testing

- `dotnet test
src\Controls\tests\BindingSourceGen.UnitTests\Controls.BindingSourceGen.UnitTests.csproj
--no-restore --verbosity minimal`

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…6428)

<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

### Issue Detail
CarouselView2 does not respect the IsSwipeEnabled setting.

### Root Cause
**iOS:**

- CarouselView2 uses UICollectionViewCompositionalLayout with
OrthogonalScrollingBehavior, which internally creates a private embedded
UIScrollView that handles paging and swipe gestures. Disabling
UICollectionView.ScrollEnabled does not affect this internal scroller.
The previous fix (PR #29996) only reapplied the swipe state when the
embedded scroller was added through AddSubview, leaving two scenarios
uncovered: the scroller could already exist before the IsSwipeEnabled
mapper executed during initial layout, or UIKit could add it through
other insertion APIs such as InsertSubview or InsertSubviewBelow.

- Additionally, disabling scrolling (ScrollEnabled = false) does not
disable rubber-band (bounce) behavior because bouncing is controlled by
the independent Bounces and AlwaysBounce properties. The embedded
scroller also ignored the existing IsBounceEnabled property and instead
tied its bounce behavior directly to the swipe state, causing
inconsistent behavior between the outer collection view and the internal
scroller.

**Android:**

MauiCarouselRecyclerView already prevented gesture interception when
IsSwipeEnabled was false by overriding OnInterceptTouchEvent. However,
Android's touch dispatch can invoke OnTouchEvent directly when a child
view does not consume the initial `ACTION_DOWN` event (for example, when
the touch begins on an empty area of the carousel). Since OnTouchEvent
did not check IsSwipeEnabled, the RecyclerView could still process the
gesture and scroll.

### Description of Change
**iOS:**

- Cache the current swipe and bounce state and reapply it whenever
subviews are added.
- Override LayoutSubviews() to reapply the cached state on every layout
pass, ensuring the embedded orthogonal scroller is updated regardless of
when or how it is created.
- Apply ScrollEnabled, Bounces, AlwaysBounceHorizontal, and
AlwaysBounceVertical consistently to both the outer UICollectionView and
the embedded orthogonal `UIScrollView`.
- Update the embedded scroller to honor the existing IsBounceEnabled
property independently of IsSwipeEnabled, matching the behavior of the
outer collection view.

**Android:**

- Added an IsSwipeEnabled check to OnTouchEvent, matching the existing
guard in OnInterceptTouchEvent, so direct touch dispatch can no longer
initiate scrolling when swipe is disabled.

### Validated the behaviour in the following platforms
- [x] Android
- [ ] Windows
- [x] iOS
- [x] Mac

### Issues Fixed:
Fixes #36267 

### Screenshots
| Before  | After |
|---------|--------|
|  <video
src="https://github.com/user-attachments/assets/2b0e8ecb-da19-4b6c-94f6-f56bbf36ae02">
|   <video
src="https://github.com/user-attachments/assets/3c23a0b2-d7f9-4962-b7b3-2aa72ce10752"> 
|

---------

Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…36275)

This pull request enhances the `Label` feature matrix test pages by
expanding the set of configurable properties for label controls and
improving the test UI for better usability and coverage. The changes
include adding new bindable properties, updating the options page to
expose more label features, and refining input handling for better
reliability.

**Label feature expansion and UI improvements:**

* The main label in `LabelControlPage.xaml` now binds additional
properties such as `IsEnabled`, `IsVisible`, `FlowDirection`, `Shadow`,
and `BackgroundColor`, and supports tap gestures with result display.
* The options page (`LabelOptionsPage.xaml`) introduces new controls for
toggling and configuring `IsEnabled`, `IsVisible`, `Shadow`,
`FlowDirection`, and `BackgroundColor`, as well as improving layout and
consistency (smaller font sizes, more grid rows, and radio button
grouping).
[[1]](diffhunk://#diff-d4e7b74fdae15709029db19b1014b9a0302d465a4994810088f70e92f41ff41eL13-R97)
[[2]](diffhunk://#diff-d4e7b74fdae15709029db19b1014b9a0302d465a4994810088f70e92f41ff41eL103-R209)
[[3]](diffhunk://#diff-d4e7b74fdae15709029db19b1014b9a0302d465a4994810088f70e92f41ff41eL208-R410)

**Input handling and code quality:**

* The padding input handler in `LabelOptionsPage.xaml.cs` now trims
whitespace from user input and removes unnecessary try/catch blocks,
making input parsing more robust and the code cleaner.

These updates make the label test pages more comprehensive and
user-friendly, allowing for more thorough testing of label features.
…en Moving to Next Item in CV2 (#36287)

### Issue Details:

The position and the IndicatorView don't update to reflect the current
position in the CarouselView.


### Root Cause

- The issue occurs only in a two-page navigation flow, where
CarouselView.Position is updated while the CarouselView handler is
detached (for example, after navigating to another page).

- During this time, the managed Position property is updated through
data binding, but CurrentItem remains unchanged because it is only
synchronized while the handler is attached. When the page is navigated
back to, the existing reattachment logic always restored the carousel
based on CurrentItem, effectively treating it as the source of truth. As
a result, any Position changes made while detached were ignored, leaving
the CarouselView, IndicatorView, and bound Position state out of sync.
- A second issue was observed on iOS 26, where a pending _scrollDebounce
callback could still execute after the controller was torn down. This
stale callback could invoke ScrollToPosition using outdated state and
overwrite the internally tracked position, causing the carousel position
to drift across navigation cycles.

### Description of Change

- Introduced _lastSyncedPosition to track the last position that was
successfully synchronized between the controller and the CarouselView.
- During reattachment, the handler now determines whether Position
changed while it was detached by comparing the current Position with
_lastSyncedPosition.
- If Position changed while detached, it is treated as the source of
truth and the carousel is restored to the updated position.
- Otherwise, the existing CurrentItem-based restoration is preserved,
which correctly handles scenarios such as collection changes while the
page was inactive.
- This ensures that explicit Position updates made through bindings or
commands while the handler is detached are honored when the page is
shown again, while preserving the previous behavior for collection
mutation scenarios.
- Resolved a secondary synchronization issue by cancelling and disposing
the pending _scrollDebounce operation during TearDown, preventing
delayed scroll callbacks from executing after the controller has been
detached.
- Additionally, a guard was added to ignore debounce callbacks when the
resolved CurrentItem position is invalid, preventing stale callbacks
from invoking ScrollToPosition and overwriting the tracked synchronized
position. This eliminates the position drift observed on repeated
navigation cycles on iOS 26.

### Validated the behaviour in the following platforms
- [ ] Android
- [ ] Windows
- [x] iOS
- [x] Mac

### Issues Fixed:
Fixes #29312 

### Screenshots
| Before  | After |
|---------|--------|
| <video
src="https://github.com/user-attachments/assets/b0ac0a55-1322-4fa4-ab9d-66c9351b05af">
| <video
src="https://github.com/user-attachments/assets/feffbd82-d626-41a4-9b32-975461781045">
|

---------

Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…namically Assigning RefreshView or ScrollView Content (#36430)

<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!
### Issue Details
A previous fix (#30047) called DisconnectHandler before clearing the old
content. This cascaded into child handlers and broke WinUI's internal
pointer event routing when views were orphaned from the visual tree.

### Description of Change

<!-- Enter description of the fix in this section -->
Get the platform view first (while the old panel is still live), detach
it from any existing parent, then clear and add to the new panel. This
avoids touching the internal handler hierarchy of views like ScrollView
and RefreshView during a content switch.

### Issues Fixed

<!-- Please make sure that there is a bug logged for the issue being
fixed. The bug should describe the problem and how to reproduce it. -->

Fixes #36298 

<!--
Are you targeting main? All PRs should target the main branch unless
otherwise noted.
-->

**Tested the behavior in the following platforms.**
- [ ] Android
- [x] Windows
- [ ] iOS
- [ ] Mac

| Before  | After  |
|---------|--------|
| **Windows**<br> <video
src="https://github.com/user-attachments/assets/10a349ed-eff4-4650-a0ca-274c77add95a"
width="600" height="300"> | **Windows**<br> <video
src="https://github.com/user-attachments/assets/1eec3997-5959-453d-b52e-1189d05656c8"
width="600" height="300"> |
…tring when font contains a STAT table (#36564)

<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

### Issue Details
When a Label uses FormattedText with a custom font that has an OpenType
STAT table, tapping a Span with a TapGestureRecognizer doesn't work
correctly on iOS. The tap hitbox is vertically offset from where the
text is actually drawn — tapping the visible text does nothing, tapping
below it fires the event.


### Description of Change

<!-- Enter description of the fix in this section -->
NSLayoutManager.UsesFontLeading is now set to false on iOS 16+ so the
tap hitbox calculation uses the same line metrics that CoreText uses to
render the text, eliminating the vertical offset. The container size is
also updated to use finalSize (MAUI's layout size) instead of
control.Bounds, which can be zero/stale during layout on Mac Catalyst.

### Issues Fixed

<!-- Please make sure that there is a bug logged for the issue being
fixed. The bug should describe the problem and how to reproduce it. -->

Fixes #36505 

<!--
Are you targeting main? All PRs should target the main branch unless
otherwise noted.
-->

**Tested the behavior in the following platforms.**
- [ ] Android
- [ ] Windows
- [x] iOS
- [x] Mac


| Before  | After  |
|---------|--------|
| **iOS**<br> <video
src="https://github.com/user-attachments/assets/622bbc78-ea45-48a3-8433-53a6ed2b1ce9"
width="300" height="600"> | **iOS**<br> <video
src="https://github.com/user-attachments/assets/e42b373b-6d5f-4982-8106-fd4f66ea0aff"
width="300" height="600"> |
This PR fixes `SearchHandler.ShowsResults` so that changes made at
runtime are immediately reflected across supported platforms.

Previously, `ShowsResults` was only evaluated during the initial
`SearchHandler` setup, causing the search results UI to remain visible
after setting `ShowsResults` to `false`, or preventing it from
reappearing after setting it back to `true`.

The implementation includes the following changes:

* Update Android `ShellSearchView` to react to `ShowsResults` property
changes.
* Reuse dropdown visibility logic through a shared
`UpdateShowsResults()` helper.
* Recreate `UISearchController` on iOS/Mac Catalyst when `ShowsResults`
changes, preserving the current query.
* Update the Windows `AutoSuggestBox` to respect `ShowsResults` at
runtime.
* Keep `ItemsSource` updates synchronized with the current
`ShowsResults` state.

The fix was validated using the reproduction project attached to the
issue and verified on Android and Windows. iOS/Mac Catalyst builds were
also verified successfully.

Fixes #35767
<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

> [!NOTE]
> 🔍 **AI-generated PR.** This fix and its regression tests were produced
by the Memory Leak Fixer workflow. Please review carefully before
merging.

Fixes #36355

## Problem

A long-lived or shared `TableRoot` kept its owning `TableView` alive.
`TableSectionModel` subscribed instance handlers directly to collection,
section, and property-change events on the root, so those strong
subscriptions retained the `TableView`. Reassigning `Root` was the only
teardown path. A shared `TableSection` could also retain a dropped
`TableRoot` through the root's direct section event handlers.

## Fix

`TableRoot` now owns a `WeakEventManager` and exposes internal weak
notification surfaces for root collection changes, child-section
collection changes, and title changes. `TableSectionModel` subscribes
directly to those weak events and explicitly moves its subscriptions
when `Root` changes.

Section collection/property notifications now use weak proxies owned by
a private finalizable subscription helper. `TableRoot` retains the
handler delegates while alive, duplicate sections share one
subscription, and the private holder always unsubscribes remaining
proxies when finalized. Keeping finalization on the private holder
avoids adding a tracked `TableRoot.~TableRoot()` public API entry. This
prevents both a shared root retaining a dropped `TableView` and a shared
section retaining a dropped `TableRoot`.

## Regression tests

`TableViewUnitTests` covers:

- setter and constructor assignment with a shared `TableRoot`
- collection of a dropped `TableView` while the root remains alive
- root collection, section collection, root-title, and section-title
notifications after forced GC for both assignment paths
- complete detachment from the old root and attachment to the
replacement root for both constructor and property-setter initial
assignment paths
- section indexer replacement detaches the old section and subscribes
the replacement section
- a rooted/shared `TableSection` does not retain a dropped `TableRoot`

The focused `TableViewUnitTests` suite passes with 16 tests on
`net10.0`.

## Scope

Managed cross-platform change under `src/Controls`. No public API
additions.

> Generated by [Memory Leak
Fixer](https://github.com/dotnet/maui/actions/runs/29133575104)

<!-- gh-aw-agentic-workflow: Memory Leak Fixer, engine: copilot,
version: 1.0.63, model: claude-opus-4.8, id: 29133575104, workflow_id:
leak-fixer, run: https://github.com/dotnet/maui/actions/runs/29133575104
-->

<!-- gh-aw-workflow-id: leak-fixer -->
<!-- gh-aw-workflow-call-id: dotnet/maui/leak-fixer -->

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…36348)

> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

> [!IMPORTANT]
> This pull request was generated automatically by the **Memory Leak
Fixer** agentic workflow (Track B — add missing regression coverage). It
is **test-only**; no product code is modified.

## Summary

Fixes #36337

The `[leak-test-gap]` issue #36337 (filed by the Daily Memory Leak
Hunter) flagged that the `VerticalStackLayout` layout family had no
coverage in the memory-leak suites. This PR adds that coverage so a
future regression is caught.

## The gap

The `HandlerDoesNotLeak` theory in
`src/Controls/tests/DeviceTests/Memory/MemoryTests.cs` exercises handler
/ platform-view teardown for many controls but did not include the core
stack / absolute layouts. There was also no runner-runnable managed leak
guard for these layouts in `Controls.Core.UnitTests`.

## What this PR adds

**1. Device test coverage** —
`src/Controls/tests/DeviceTests/Memory/MemoryTests.cs`

Adds four `[InlineData]` entries to the existing `HandlerDoesNotLeak`
`[Theory]`:

- `typeof(StackLayout)`
- `typeof(VerticalStackLayout)`
- `typeof(HorizontalStackLayout)`
- `typeof(AbsoluteLayout)`

No `SetupBuilder` change is required: all four derive from `Layout`, and
`handlers.AddHandler<Layout, LayoutHandler>()` is already registered, so
handler resolution walks to the nearest registered base.

**2. Runner-runnable managed guard** —
`src/Controls/tests/Core.UnitTests/Layouts/LayoutMemoryTests.cs` (new)

`LayoutDoesNotLeakAfterChildRemoved` (a `[Theory]` over the same four
types) verifies that a **long-lived child** does not keep a **transient
layout** alive once the child has been removed — the managed teardown
path that runs when a page is navigated away from and its layout tree is
torn down. It models the existing `GridLayoutTests` leak tests
(`WeakReference` + `TestHelpers.Collect()` + `WaitForCollect()`).

## Validation

- **Managed guard (`Controls.Core.UnitTests`):** built from source and
run on the `net` TFM in this run. All four cases **pass on `main`** —
these layouts do not leak today, so the test is a forward regression
guard, exactly what a `[leak-test-gap]` fix should be:

  ```
Passed LayoutDoesNotLeakAfterChildRemoved(layoutType:
typeof(StackLayout))
Passed LayoutDoesNotLeakAfterChildRemoved(layoutType:
typeof(VerticalStackLayout))
Passed LayoutDoesNotLeakAfterChildRemoved(layoutType:
typeof(HorizontalStackLayout))
Passed LayoutDoesNotLeakAfterChildRemoved(layoutType:
typeof(AbsoluteLayout))
  Total tests: 4  Passed: 4
  ```

- **Device tests:** `src/Controls/tests/DeviceTests/**` cannot run on
this Linux runner (they require platform workloads). The added
`[InlineData]` lines are purely additive, use types already in scope via
`using Microsoft.Maui.Controls;` (so they resolve to the modern,
non-obsolete layout types), and are validated for structure here. They
will execute on the **maui-pr-devicetests** pipeline.

## Scope

- Test-only. No product / managed code changed; no public API surface
affected.
- No existing test is muted, skipped, weakened, or deleted.

---

Fixes #36337
Refs: #36337
Target branch: main

_Generated by the Memory Leak Fixer agentic workflow (Track B)._




> Generated by [Memory Leak
Fixer](https://github.com/dotnet/maui/actions/runs/28679863808) · 2.8K
AIC · ⌖ 137.6 AIC · ⊞ 63.4K ·
[◷](https://github.com/search?q=repo%3Adotnet%2Fmaui+%22gh-aw-workflow-id%3A+leak-fixer%22&type=pullrequests)

<!-- gh-aw-agentic-workflow: Memory Leak Fixer, engine: copilot,
version: 1.0.60, model: claude-opus-4.8, id: 28679863808, workflow_id:
leak-fixer, run: https://github.com/dotnet/maui/actions/runs/28679863808
-->

<!-- gh-aw-workflow-id: leak-fixer -->
<!-- gh-aw-workflow-call-id: dotnet/maui/leak-fixer -->

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
)

> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

> [!NOTE]
> This pull request was generated automatically by the **Memory Leak
Fixer** agentic workflow. It contains regression tests, including one
proven to fail on unpatched `main`, and the managed product fix that
makes them pass.

Fixes #36365
Refs: #36365
Target branch: main
Attempt: 1/3

## The leak

`GeometryGroup` subscribed to its `Children` (`GeometryCollection`) via
plain `CollectionChanged += OnChildrenCollectionChanged` and to each
child `Geometry` via `PropertyChanged += OnChildrenPropertyChanged`.
When the `GeometryCollection` is shared/long-lived, those strong
delegate references from the collection (and children) back to the
`GeometryGroup` keep the group — and everything it roots — alive after
it should have been collected.

Retention path: shared `GeometryCollection` → `CollectionChanged`
delegate → `GeometryGroup` (never released while the collection lives).

## The fix

Replace the strong subscriptions in
`src/Controls/src/Core/Shapes/GeometryGroup.cs` with the
weak-subscription helpers already used across the codebase
(`WeakNotifyCollectionChangedProxy` / `WeakNotifyPropertyChangedProxy`
from `Internals/WeakEventProxy.cs`), managed by a private
`ChildrenSubscriptions` holder with a finalizer that unsubscribes.
Collection add/remove/reset events keep child `PropertyChanged`
subscriptions in sync. Rendering and flattening treat a null `Children`
collection and null entries within it as empty, and Android, iOS/macOS,
Tizen, and Windows geometry conversion paths apply the same behavior.
Finalizer cleanup unsubscribes child proxies in place to avoid
allocating while releasing the group. No public API change (private
nested class + fields).

## Regression tests

Added `src/Controls/tests/Core.UnitTests/GeometryGroupMemoryTests.cs`:
- `GeometryGroupDoesNotLeakWhenSharingChildren` — verifies a shared
collection does not retain the group.
- `GeometryGroupDoesNotLeakWhenSharingChild` — verifies a shared child
does not retain the group.
- `ChildGeometryChangesStillInvalidateAfterGc` — preserves live child
invalidation after GC.
- `AssigningChildrenWithNullEntrySubscribesValidChildren` — covers
direct assignment with a null entry while preserving valid-child
invalidation.
- `AppendPathSkipsNullChildren` — verifies rendering skips null entries
while still appending valid geometry.
- `AppendPathAllowsNullChildrenCollection` — verifies rendering treats a
null `Children` collection as empty.
- `FlattenGeometrySkipsNullChildren` /
`FlattenGeometryAllowsNullChildrenCollection` — verify flattening
follows the same null-as-empty contract.
- `SharedChildrenInvalidateEachLiveGroupAfterGc` — preserves
invalidation for multiple groups sharing one collection.
- `RemovingAndReplacingChildrenMovesSubscriptions` — moves subscriptions
for item removal and replacement.
- `ReplacingChildrenCollectionMovesSubscriptions` — detaches the old
collection and children and attaches the replacement collection and
children.
- `ReplacingChildrenThroughNullMovesSubscriptions` — verifies old → null
→ new transitions detach stale sources and resume invalidation on the
replacement.
- `MovingChildrenPreservesSubscriptions` — reordering children preserves
exactly one invalidation subscription per child.
- `DuplicateChildrenPreserveOccurrenceSubscriptions` — tracks duplicate
occurrences independently.
- `ResetChildrenResubscribesLaterAdditions` — detaches cleared children
and subscribes later additions.

| State | Result |
| --- | --- |
| Without fix (origin/main) | Leak and subscription-lifecycle tests fail
|
| With fix | All 15 `GeometryGroupMemoryTests` pass |

## Scope

Changes are limited to `GeometryGroup` subscription cleanup, null-safe
geometry conversion and flattening paths on Android, iOS/macOS, Tizen,
and Windows, plus focused unit tests. No public API changes.




> Generated by [Memory Leak
Fixer](https://github.com/dotnet/maui/actions/runs/29174565512) · 392.8
AIC · ⌖ 30.3 AIC · ⊞ 19.6K ·
[◷](https://github.com/search?q=repo%3Adotnet%2Fmaui+%22gh-aw-workflow-id%3A+leak-fixer%22&type=pullrequests)

<!-- gh-aw-agentic-workflow: Memory Leak Fixer, engine: copilot,
version: 1.0.63, model: claude-opus-4.8, id: 29174565512, workflow_id:
leak-fixer, run: https://github.com/dotnet/maui/actions/runs/29174565512
-->

<!-- gh-aw-workflow-id: leak-fixer -->
<!-- gh-aw-workflow-call-id: dotnet/maui/leak-fixer -->

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
… exception is thrown in windows platform" (#34354)

<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!


### Issue Details:

When focus is on entry and closing the app the "object disposed
exception is thrown in windows platform"
        
### Root Cause:

When closing a Windows MAUI app with style triggers on IsFocused:
 
1. Window.Destroying() calls Handler?.DisconnectHandler() → sets
Window's Handler = null, disposes the WinUI window content
2. Then calls mauiContext?.DisposeWindowScope() → disposes the
window-scoped IServiceProvider
3. After this (async via WinUI's message pump), FocusManager.LostFocus
fires on the focused Entry's TextBox
4. ViewHandler.FocusManager_LostFocus → handler.UpdateIsFocused(false) →
virtualView.IsFocused = false
5. IsFocused property change fires the style trigger → setter applies
FontAttributes.Bold → HandleFontChanged() → Handler.UpdateValue("Font")
→ MapFont →
handler.GetRequiredService<IFontManager>() → ObjectDisposedException on
the already-disposed window scope

### Description of Change:

In ElementHandlerExtensions.cs, the existing CanInvokeMappers() method —
which already had an Android guard for disposed native views — was
extended with a new cross-platform check: if the handler's MauiContext
is a MauiContext instance and its IsWindowScopeDisposed flag is true,
the method returns false. This means any mapper invocation that goes
through this guard (including the IsFocused → style trigger →
HandleFontChanged → MapFont → GetRequiredService<IFontManager>() chain
that was crashing) is silently skipped once the window scope is
disposed, preventing any attempt to resolve services from the
already-disposed IServiceScope.

**Tested the behavior in the following platforms.**

- [ ] Android
- [x] Windows
- [ ] iOS
- [ ] Mac

### Reference:

N/A

### Issues Fixed:

Fixes  #34272      

### Screenshots
| Before  | After  |
|---------|--------|
| <img width="831" height="150"
alt="555802171-27ff9777-aec5-46d1-b869-361b91f4cd4e"
src="https://github.com/user-attachments/assets/56131197-d131-4c5d-bb76-4b8666cb1e43"
/> | <Video
src="https://github.com/user-attachments/assets/a91cea91-39ea-4649-ae3d-9a86144e8f35"
Width="300" Height="600"> |
…bility (#30311)

<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

<!--
!!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING
MAIN. !!!!!!!
-->
### Root Cause

On Windows, a LayoutPanel with InputTransparent=true and a non-null
background loses its visible background color. This happens because
making a Panel invisible to input requires its Background to be null in
WinUI — but a null background is also visually transparent, so the color
disappears. The original workaround for this inserted a non-hit-testable
Canvas at index 0 of CachedChildren to serve as the visible background
layer. Inserting at index 0 shifted all real logical child indexes,
causing misalignment in the layout handler's child management and
disrupting layout rendering.

### Description of Change

This fix retains the non-hit-testable background layer concept but
eliminates the child index misalignment:

- **Background layer appended, not inserted**: The background Canvas is
now added via CachedChildren.Add() instead of CachedChildren.Insert(0,
...), so real layout children retain their correct logical indexes.

- **Z-order enforced via Canvas.ZIndex**:
Canvas.SetZIndex(_backgroundLayer, int.MinValue) ensures the background
layer always renders visually behind all real children without occupying
a logical slot at the front.

- **Explicit layout in ArrangeOverride**: The background layer is
explicitly measured and arranged to fill the full panel bounds on every
layout pass, so the background remains fully visible.


### Issues Fixed

Fixes #17389

**Tested the behaviour in the following platforms**

- [ ] Android
- [x]  Windows
- [ ]  iOS
- [ ] Mac

| Before| After|
|--|--|
| <img
src="https://github.com/user-attachments/assets/4534d12d-d85e-4cfe-8912-55e029c2cad1">
| <img
src="https://github.com/user-attachments/assets/1943aec4-def9-4f2a-a627-19170c23176a">
|

---------

Co-authored-by: Dhivya-SF4094 <127717131+Dhivya-SF4094@users.noreply.github.com>
Co-authored-by: Dhivya-SF4094 <dhivya.venkatesan@syncfusion.com>
### Root Cause
Runtime scenarios for ShellContent were not properly handled. When
ShellContent was added or removed dynamically, its property changes
(such as title updates) were not tracked correctly.


### Description of Change

Windows: Moved title update handling from ShellContentHandler.Windows to
ShellSectionHandler.Windows. This modification ensures that title
changes are handled properly for both initial and dynamically added
ShellContent items.


### Issues Fixed

<!-- Please make sure that there is a bug logged for the issue being
fixed. The bug should describe the problem and how to reproduce it. -->

Fixes #27494 

### Regarding test case
Added a test case for runtime-added ShellContent title updates in PR
#26062 . These tests are applicable to my changes as well


**Tested the behavior in the following platforms.**
- [ ] Android
- [x] Windows
- [ ] iOS
- [ ] Mac

### Output Screenshot

| Before  | After  |
|---------|--------|
| **Android**<br> <video
src="https://github.com/user-attachments/assets/fda0a962-fdef-425e-9ec6-cfc263af4c53"
width="260" height="500"> |**Android**<br> <video
src="https://github.com/user-attachments/assets/cb3ed516-2955-46c6-b9aa-4c7ee22207ad"
width="260" height="500"> |
| **Windows**<br> <video
src="https://github.com/user-attachments/assets/dc8db704-dc67-44ed-8752-0165ed124dd7"
width="300" height="600"> | **Windows**<br> <video
src="https://github.com/user-attachments/assets/0b14e6c2-b898-4c71-b464-80c33b5bab9e"
width="300" height="600"> |

---------

Co-authored-by: Vignesh-SF3580 <102575140+Vignesh-SF3580@users.noreply.github.com>
<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

### Issue Details:
A shared, long-lived Span keeps a strong event reference to every
FormattedString that uses it. As a result, even when those
FormattedString instances are no longer used, the garbage collector
cannot free them, causing a memory leak that grows over time.

### Root Cause

- Element.Parent already stores its parent as a weak reference, so the
logical parent relationship (span.Parent = formattedString) was not
causing the memory leak.
- The actual leak came from the event subscriptions made when a Span was
added to a FormattedString. In FormattedString.OnCollectionChanged, the
FormattedString subscribed to the Span's PropertyChanged and
PropertyChanging events.
- These event handlers held strong references back to the
FormattedString. As a result, if a Span was long-lived or shared, it
kept every FormattedString it had been added to alive, preventing them
from being garbage collected.

### Description of Change

**FormattedString.cs**

- Replaced the direct `Span` event subscriptions with per-occurrence
weak subscription tokens.

- Added two private nested types:

- `SpanSubscription` — represents a single subscription to one `Span`.
It holds the owning `FormattedString` only through a
`WeakReference<FormattedString>`, and subscribes to the span's
`PropertyChanging`/`PropertyChanged` events. When an event fires, it
forwards to the owner if still alive; if the owner has already been
collected, it unsubscribes itself.

- `SpanSubscriptions` — manages a `List<SpanSubscription>` for the
`FormattedString`. Each `Add` creates an independent subscription token,
and `Remove` unsubscribes and removes a single matching token. This
gives correct add/remove symmetry even when the same `Span` is added
multiple times.

- When a `Span` is added, a new `SpanSubscription` is created and
tracked. When a `Span` is removed or the collection is cleared, the
corresponding subscription is unsubscribed and removed from the list.

- Because the `WeakReference` breaks the strong path from the span back
to the `FormattedString`, a shared/long-lived `Span` no longer keeps
discarded `FormattedString` instances alive.

- Added a finalizer on `SpanSubscriptions` that unsubscribes any
remaining subscriptions as a safety net, following the same cleanup
pattern used by
[Border](https://github.com/dotnet/maui/blob/main/src/Controls/src/Core/Border/Border.cs#L29).

### Issues Fixed:
Fixes #36289 

### Screenshots
| Before  | After |
|---------|--------|
|  <img width="634" height="375"
src="https://github.com/user-attachments/assets/400c456e-c8ae-4c07-8856-a7d9ad9cd470">
|  <img width="634" height="375"
src="https://github.com/user-attachments/assets/62c8f19a-4a2b-4272-81ef-2f96df036e16"> 
|
MauiBot

This comment was marked as outdated.

@MauiBot MauiBot added s/agent-gate-failed AI could not verify tests catch the bug and removed s/agent-review-in-progress AI review is currently running for this PR labels Aug 18, 2026
Move delegated inset ownership into MauiScrollView, avoid a second managed arrange, weaken native view retention, and add negative coverage for ineligible delegation paths.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: aa11dde2-b825-4091-8a8f-02c90939fe01
@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 22, 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 — 13 findings

See inline comments for details.

Assert.That(topMarker.Y, Is.GreaterThan(20),
"Content should remain below the status bar when the navigation bar is hidden.");
}

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)

[critical] Regression Prevention and Test Coverage — The added test block is structurally unbalanced: the try opened in HiddenNavigationBarPreservesTopSafeArea is closed by a bare } with no catch/finally, so the following [Test] public void OrdinaryHeaderPreservesSafeAreaLayout() (and the ones after it) are parsed as nested local declarations. The whole TestCases.Shared.Tests assembly fails to compile: CS1524: Expected catch or finally at the closing brace above, CS0106: The modifier 'public' is not valid for this item at 230/249/272/298, and CS1513: } expected at 317/322.

Concrete consequence: pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform ios -TestFilter "Issue33037NonShell" fails to build both with and without the fix, so this PR currently ships zero executable regression coverage for #33037 — the fix is unverified end-to-end. Add the missing finally { App.Back(); } to close each try at its intended method boundary (matching the pattern used at line 208) and re-balance the trailing braces.


if (_isTopSafeAreaDelegated)
{
ContentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentBehavior.Never;

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)

[critical] Safe Area and Window Insets — When top-inset delegation is active this branch hard-forces ContentInsetAdjustmentBehavior = Never and return trues before the per-edge region switch below. Never disables UIKit's automatic adjustment for every edge, not just Top, but ApplyDelegatedTopInset only re-adds a Top inset — nothing re-applies Left/Right/Bottom.

Concrete scenario: a NavigationPage page whose root Layout hosts a ScrollView with the default SafeAreaRegions.Container (which previously mapped to Always, i.e. UIKit supplied the bottom inset). On an iOS 26 device with a home indicator, as soon as delegation engages the bottom safe-area inset drops to 0 and the last item scrolls under the home indicator / is unreachable. The same applies to landscape left/right notch insets.

The delegated path needs to preserve the non-Top edge insets — either fall through to the existing region switch and layer the delegated Top inset on top of the resulting ContentInset, or explicitly compute and apply Left/Right/Bottom manual insets before returning.


_delegatedTopInset = (nfloat)topInset;
_delegatedScrollIndicatorTopInset = (nfloat)topInset;
nativeScrollView.ContentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentBehavior.Never;

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 — Same non-Top-inset loss as the MauiScrollView delegated path, but here it is applied to a foreign UIScrollView that MAUI does not own — WKWebView.ScrollView (via the WebView fallback in ResolveNativeScrollView), UITableView from ListView/TableView, or a third-party handler's scroll view. Forcing ContentInsetAdjustmentBehavior = Never on those cancels the bottom/side safe-area insets that UIKit was supplying for them, and only the Top inset is re-added on the following lines.

Concrete scenario: Layout > WebView under a large-title NavigationPage on iOS 26 — page content now runs under the home indicator, and the web view's own scroll indicator insets no longer clear the bottom edge. Additionally, _previousInsetAdjustmentBehavior is captured only inside the identity-change branch at line 96, so if the same scroll view instance is re-delegated after an external component changed its behavior, ResetNativeScrollInsetOwnership restores a stale value.

@@ -585,7 +585,6 @@ void HandlePropertyChanged(object sender, PropertyChangedEventArgs e)
else if (e.PropertyName == PrefersLargeTitlesProperty.PropertyName)
{
UpdateUseLargeTitles();

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] Regression Prevention and Test Coverage — This PR deletes the entire large-title proxy scroll view implementation from ParentingViewController (_largeTitleProxyScrollView, _largeTitleObservedScrollView, _largeTitleContentOffsetObserver, UpdateLargeTitleProxyScrollView, TearDownLargeTitleProxyScrollView, ScheduleLargeTitleProxyScrollObservation, and the _isAppeared gating in ViewDidAppear/ViewDidDisappear/ViewDidLayoutSubviews), plus this UpdateCurrentPageLargeTitleProxy() call.

git log shows that code was added by #36511 "[iOS] NavigationPage: Fix large-title collapse for wrapped scroll content". The mechanical regression scan reported CLEAN (it only matches labeled bug-fix PRs), so this revert is unflagged. The proxy path covered all iOS versions, whereas the replacement (ShouldDelegateTopSafeAreaToScrollContent) is gated on OperatingSystem.IsIOSVersionAtLeast(26) and on the host being a Microsoft.Maui.Controls.Layout implementing ISafeAreaLayout.

Concrete scenario: on iOS 18, or on any page whose root is a ContentView/ScrollView rather than a Layout, #36511's wrapped-scroll-content large-title collapse no longer works and there is nothing left to restore it. Please confirm the #36511 scenarios are still covered (or intentionally regressed) and keep its regression test green.

return null;
}

static UIScrollView FindNativeScrollView(UIView view, bool verticallyScrollableOnly)

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] Performance-Critical Path Optimization — FindNativeScrollView performs an unbounded, uncached, recursive walk of the entire native UIView subtree, and it is reachable from FindVerticalScrollContent for every child of the layout on every CrossPlatformArrange pass (line 39 loop → line 187 ResolveNativeScrollView). ResolveNativeScrollView may run the walk twice for a WebView (once with verticallyScrollableOnly: true, once with false).

Arrange runs on every LayoutSubviews, i.e. on every scroll-driven relayout, rotation, keyboard show/hide, and safe-area change. For a page with a deep native tree (CollectionView cells, WebView, nested Borders) this is a per-frame O(native-subtree) traversal on the layout hot path. Cache the resolved UIScrollView alongside the existing _delegatedNativeScrollView weak reference and invalidate on child/handler change, or bail out of the search before the recursive descent when the candidate is already known.

return arranged;
}

const double tolerance = 1;

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 Verification — The delegation eligibility test is driven by four undocumented magic numbers with no named constants or rationale: tolerance = 1 (point? pixel? device-scale independent?), frame.Height >= safeBounds.Height / 2 ("occupies most of"), and Opacity <= 0.01 repeated at lines 41, 172, and 217.

Concrete scenario: a page with a header taking 55% of the height and a scroll view taking 45% silently loses large-title collapse, with nothing in the code explaining that 50% is the cut-off. On a 3x device a 1-point tolerance is 3 physical pixels, which is a different tolerance than on a 2x device — the codebase's existing convention for this (see EqualsAtPixelLevel in the iOS safe-area code) is pixel-level comparison. Please extract these to named constants with comments justifying each threshold.


void ISafeAreaLayout.DisconnectSafeArea() => ResetNativeScrollInsetOwnership();

void TransferNativeScrollInsetOwnership(UIScrollView nativeScrollView, double topInset)

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 — TransferNativeScrollInsetOwnership/ResetNativeScrollInsetOwnership contain the riskiest logic in the PR — save/restore of ContentInset, VerticalScrollIndicatorInsets, ContentInsetAdjustmentBehavior, and ContentOffset compensation via distanceFromTop — yet the only coverage added is the UI test file that currently does not compile (see Issue33037NonShell.cs:227).

This math is unit-testable without a device: repeated Transfer calls must be idempotent (the baseContentTop = contentInset.Top - _delegatedTopInset subtraction is what prevents inset accumulation), and Reset must return the scroll view to exactly its pre-delegation insets/behavior. Add device tests in Core.DeviceTests (iOS) asserting round-trip inset restoration and no accumulation across N arrange passes, so a regression here is caught without relying on screenshot UI tests.

bounds = AdjustForSafeArea(bounds);
}
else if (!CellSafeAreaOverride.IsEmpty)
else

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] Handler Mapper and Property Patterns — DisconnectSafeArea() is invoked here on every LayoutSubviews pass for any ISafeAreaLayout whose _appliesSafeAreaAdjustments is false, not just on an actual disconnect. That conflates two different lifecycle events on the same entry point, and it means the reset path (which reads ContentOffset/AdjustedContentInset and writes ContentInset on the previously delegated scroll view) is on the arrange hot path.

It is also asymmetric with LayoutHandler.iOS.cs:125, which calls the same method from DisconnectHandler. Consider naming the per-layout call something like ReleaseDelegatedSafeArea() and keeping DisconnectSafeArea for the handler teardown, so a future change to teardown semantics does not silently alter arrange behavior.

if (!OperatingSystem.IsIOSVersionAtLeast(26) ||
_safeArea.Top <= 0 ||
View is not ISafeAreaView2 { HasExplicitSafeAreaEdges: false } safeAreaView)
return false;

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)

[minor] Safe Area and Window Insets — GetSafeAreaRegionsForEdge(1) uses a bare magic int for the Top edge. The rest of this same file annotates these calls (see line 471: GetSafeAreaRegionsForEdge(3); // 3 = bottom edge) or assigns them to named locals (lines 108-111). Add the // 1 = top edge comment or a shared named constant so a future edge-index change does not silently invert this gate.

namespace Microsoft.Maui
{
internal interface ISafeAreaLayout
{

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)

[minor] Architectural Layer Placement — ISafeAreaLayout is declared unconditionally for all target frameworks, but its only implementer is Layout.iOS.cs and its only consumers are MauiView.cs (iOS) and LayoutHandler.iOS.cs (iOS). The companion ISafeAreaScrollView in the same file is guarded by #if IOS. The inconsistency ships an unused interface into the Android/Windows/Tizen builds of Microsoft.Maui.dll and invites a future non-iOS implementation of a contract whose delegateTopInset parameter has iOS-only meaning. Either put both interfaces behind #if IOS, or drop the guard from both and document the cross-platform contract.

MauiBot

This comment was marked as outdated.

@MauiBot MauiBot removed the s/agent-review-in-progress AI review is currently running for this PR label Aug 22, 2026
@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 24, 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 — 12 findings

See inline comments for details.

{
if (_appliesSafeAreaAdjustments)
{
if (CrossPlatformLayout is ISafeAreaLayout safeAreaLayout)

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)

❌ [critical] Logic and Correctness / Safe Area — This new branch calls ValidateSafeArea() directly and returns, bypassing AdjustForSafeArea(bounds) entirely. AdjustForSafeArea is not just _safeArea.InsetRect(bounds) — it also contains the keyboard hook:

if (KeyboardAutoManagerScroll.ShouldIgnoreSafeAreaAdjustment)
    KeyboardAutoManagerScroll.ShouldScrollAgain = true;

ValidateSafeArea();
return _safeArea.InsetRect(bounds);

Because CrossPlatformLayout is ISafeAreaLayout is true for every Microsoft.Maui.Controls.Layout (Grid/StackLayout/etc.) that applies safe-area adjustments — i.e. the overwhelmingly common page-content root — ShouldScrollAgain is now never set from this path. Concrete scenario: a page whose content is a Grid with safe-area insets; an Entry near the bottom is focused while the keyboard is animating in. KeyboardAutoManagerScroll sets ShouldIgnoreSafeAreaAdjustment, expects the subsequent arrange to flag ShouldScrollAgain, and never gets it, so the follow-up scroll that brings the focused field above the keyboard is skipped.

The delegateTopInset feature is scoped to iOS 26 + large titles, but this bypass is unconditional. Route this branch through AdjustForSafeArea (or hoist the keyboard block above the ISafeAreaLayout check) so the keyboard path is preserved.

return false;

if (safeAreaView.GetSafeAreaRegionsForEdge(1) != SafeAreaRegions.Container)
return false;

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] Performance-Critical Path — ShouldDelegateTopSafeAreaToScrollContent() is evaluated on every CrossPlatformArrange of every safe-area-adjusting Layout, and each call does a responder-chain walk (FindResponder<UIViewController>()) plus five separate ObjC bridge property reads (NavigationController, NavigationBar, Hidden, Translucent, PrefersLargeTitles, NavigationItem.LargeTitleDisplayMode). Arrange runs on every layout pass, on rotation, on keyboard show/hide, and on every scroll-induced relayout.

The cheap, high-selectivity guards already come first (OperatingSystem.IsIOSVersionAtLeast(26), _safeArea.Top <= 0), which limits the damage, but the responder walk still runs on every arrange for any iOS 26 page with a top inset — including pages with no NavigationPage at all, where FindResponder<UIViewController>() walks the full chain only to return a controller with a null NavigationController.

Cache the resolved UIViewController/UINavigationBar (they only change on MovedToWindow/re-parenting) and invalidate on window/superview transitions rather than re-walking per arrange.


var controller = this.FindResponder<UIViewController>();
var navigationController = controller?.NavigationController;
var navigationBar = navigationController?.NavigationBar;

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] Regression Prevention — The removed ParentingViewController code re-evaluated the large-title state reactively, from OnPropertyChanged for NavigationPage.PrefersLargeTitles, NavigationPage.HasNavigationBar, LargeTitleDisplay, and ContentPage.Content (UpdateLargeTitleProxyScrollView() / RestartLargeTitleProxyScrollView()). This replacement is evaluated only during CrossPlatformArrange.

None of those four property changes is guaranteed to invalidate the content Layout and force an arrange pass — PrefersLargeTitles and LargeTitleDisplay change the navigation bar, not the layout's measured size. Concrete scenario: a page has the delegated top inset applied, then user code calls NavigationPage.SetPrefersLargeTitles(navPage, false) (or sets LargeTitleDisplay = Never, or NavigationPage.SetHasNavigationBar(page, false)). ShouldDelegateTopSafeAreaToScrollContent() is not re-run, so ResetNativeScrollInsetOwnership() never fires and the scroll view keeps a stale ContentInset.Top sized for a large title that no longer exists — content is pushed down by a phantom inset until an unrelated relayout happens.

Add an explicit invalidation path (e.g. InvalidateSafeArea()/SetNeedsLayout() on the content view) driven by those navigation-bar property changes, and a regression test that toggles PrefersLargeTitles/HasNavigationBar at runtime after the inset has been delegated.

if (!OperatingSystem.IsIOSVersionAtLeast(26) ||
_safeArea.Top <= 0 ||
View is not ISafeAreaView2 { HasExplicitSafeAreaEdges: false } safeAreaView)
return false;

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)

💡 [minor] Safe Area — GetSafeAreaRegionsForEdge(1) uses a bare magic index for the Top edge. The same magic indices appear throughout MauiView/MauiScrollView (0=Left, 1=Top, 2=Right, 3=Bottom), but this is new code adding another unlabeled call site in a file where an off-by-one silently reads the wrong edge with no compile-time signal. Introduce named constants (or an enum) for the edge indices rather than propagating the literal.

_isTopSafeAreaDelegated = true;
_delegatedTopInset = (nfloat)topInset;
_delegatedScrollIndicatorTopInset = (nfloat)topInset;
UpdateContentInsetAdjustmentBehavior();

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)

❌ [critical] Layout Measure-Arrange / Safe Area — The bool returned by UpdateContentInsetAdjustmentBehavior() is discarded here (and again in ResetDelegatedTopInset, line 354). Every other caller uses it — ValidateSafeArea() does:

if (UpdateContentInsetAdjustmentBehavior())
{
    InvalidateConstraintsCache();
    _safeAreaInvalidated = true;
}

Because this call also latches _previousTopSafeAreaDelegation = _isTopSafeAreaDelegated, the true return that signals the delegation transition is consumed and thrown away here. The next ValidateSafeArea() sees _previousEdges.Equals(safeAreaEdges) && _previousTopSafeAreaDelegation == _isTopSafeAreaDelegated and returns false, so InvalidateConstraintsCache() / _safeAreaInvalidated = true are never run for the transition.

Concrete consequence: switching ContentInsetAdjustmentBehavior from Always (the SafeAreaRegions.Container case) to Never flips UIKitCompensatesForSafeArea, which changes which source ValidateSafeArea reads (SafeAreaInsets vs SystemAdjustedContentInset) — but _safeArea is not recomputed and the measurement cache is not invalidated. _blockedEdgesCacheValid is likewise left untouched, even though the delegated scroll view's frame is simultaneously extended up under the navigation bar (Layout.iOS.cs:77), which changes its raw SafeAreaInsets.Top from 0 to non-zero. The scroll view keeps measuring against the pre-delegation safe area for at least one pass, and if nothing else dirties it, indefinitely.

Call InvalidateSafeArea() (or honor the return value the way ValidateSafeArea does) from both ApplyDelegatedTopInset and ResetDelegatedTopInset.


void ISafeAreaLayout.DisconnectSafeArea() => ResetNativeScrollInsetOwnership();

void TransferNativeScrollInsetOwnership(UIScrollView nativeScrollView, double topInset)

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] Complexity Reduction / Architectural Layer — TransferNativeScrollInsetOwnership (lines 89–137) and ResetNativeScrollInsetOwnership (lines 141–186) duplicate, line for line, the inset-transfer math in MauiScrollView.ApplyDelegatedTopInset / ResetDelegatedTopInset — same distanceFromTop computation, same baseContentTop/baseIndicatorTop subtraction, same UIEdgeInsets reconstruction, same !Dragging && !Decelerating offset restore. Two copies of subtle scroll-inset arithmetic will drift; the ISafeAreaScrollView copy in Core has already grown a _previousTopSafeAreaDelegation latch that this copy does not have.

The non-ISafeAreaScrollView fallback also carries two state bugs of its own:

  • _previousInsetAdjustmentBehavior is captured only when the delegated target changes (line 98), but line 115 forces Never on every call. If anything else legitimately changes that scroll view's ContentInsetAdjustmentBehavior while delegation is active, ResetNativeScrollInsetOwnership restores the stale captured value instead of the current one.
  • ClearNativeScrollInsetOwnership() resets _delegatedTopInset and _delegatedScrollIndicatorTopInset but leaves _previousInsetAdjustmentBehavior stale, so it survives into an unrelated later delegation of an ISafeAreaScrollView target (which never reassigns it).

Extract the shared math into one helper (e.g. a UIScrollView extension) that both paths call, and reset _previousInsetAdjustmentBehavior in ClearNativeScrollInsetOwnership.

double contentTopInset = 0;
var candidateCount = 0;

foreach (var child in this)

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] Performance-Critical Path — This candidate scan runs inside CrossPlatformArrange — the arrange hot path — and allocates on every pass:

  • foreach (var child in this) enumerates Layout through IEnumerable<IView>, boxing/allocating an enumerator per arrange pass.
  • FixedTopSiblingsAreOverlays (line 218) re-enumerates all siblings for each candidate child, making this O(n²) with another enumerator allocation per candidate.
  • FindVerticalScrollContent (line 239) is called for every child and recurses via visualElement.GetVisualChildren() (line 281), which allocates a fresh IReadOnlyList at every node of the subtree, every pass.

For a page with a moderately deep Grid/StackLayout tree this is dozens of allocations and a full visual-tree walk per layout pass, repeated on every scroll-induced relayout and every keyboard/rotation transition. Per the hot-path rule, use indexed for (int i = 0; i < Count; i++) over this[i], hoist the sibling scan out of the per-candidate loop (single pass computing the topmost non-overlay sibling bottom), and cache the resolved scroll host, invalidating on child-collection changes rather than re-deriving it every arrange.

return null;
}

static UIScrollView FindNativeScrollView(UIView view, bool verticallyScrollableOnly)

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] Performance-Critical Path / iOS Platform — FindNativeScrollView does an unbounded recursive depth-first walk of the native view hierarchy, and every level reads view.Subviews, which marshals a fresh UIView[] across the ObjC bridge (NSArray → managed array) for each node. This is invoked from ResolveNativeScrollView, which is called both inside FindVerticalScrollContent (line 265) and again on the winning candidate (line 68) — so the subtree is walked at least twice per arrange pass, and for WebView a third time via the verticallyScrollableOnly: false fallback (line 195) whenever the first walk misses.

There is no depth limit and no early-out, so for a CollectionView/WebView with a populated cell tree this walks the entire realized native subtree on every layout pass. Cache the resolved UIScrollView per host (weakly, as is already done for _delegatedNativeScrollView) and re-resolve only when the host's handler changes, and bound the recursion depth.

return visibleChild is null ? null : FindVerticalScrollContent(visibleChild);
}

static bool IsVerticallyScrollable(UIScrollView scrollView)

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 / CollectionView iOS — IsVerticallyScrollable will report false for a genuinely vertical scroller in two reachable cases:

  1. The UICollectionView branch only matches UICollectionViewFlowLayout. MAUI's active iOS CollectionView handler (Items2/) uses UICollectionViewCompositionalLayout, which is not a UICollectionViewFlowLayout, so any nested/native UICollectionView reached through FindNativeScrollView falls through to the heuristic below instead of being recognised.
  2. The fallback scrollView.AlwaysBounceVertical || (!AlwaysBounceHorizontal && ContentSize.Height > Bounds.Height) is evaluated during arrange. On the first arrange pass ContentSize is typically still 0 (content not yet realized/sized) and Bounds may also be 0, so 0 > 0 is false and AlwaysBounceVertical defaults to false — the scroll view is classified as non-vertical and delegation is skipped for that pass.

Concrete symptom: on first navigation to the page the large title does not collapse (or the top inset is not delegated), and the behavior only corrects itself once some later relayout happens after content size is known — a visible one-frame jump or a permanently wrong state if no further arrange occurs. Add an explicit UICollectionViewCompositionalLayout check (via Configuration.ScrollDirection), and re-evaluate delegation when the scroll view's ContentSize first becomes non-zero.


protected override void DisconnectHandler(LayoutView platformView)
{
if (VirtualView is ISafeAreaLayout safeAreaLayout)

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] Regression Prevention / Handler Disconnect — This is the only teardown path that releases delegated scroll-inset ownership when a Layout is torn down, and it has no test coverage. The PR's tests (Issue33037NonShell) are all iOS-26-gated UI tests that exercise the happy path — they scroll and assert the title collapses. None of them exercises the negative/release cases that this state machine depends on:

  • handler disconnect while an inset is applied (this line);
  • the scroll host being removed or hidden so candidateCount drops to 0, or a second candidate appearing so candidateCount > 1 → ResetNativeScrollInsetOwnership() (Layout.iOS.cs:60);
  • navigating away and back (Shell/NavigationPage remove and re-add platform views — DisconnectHandler runs, then the view is re-connected), where a missed reset leaves a doubled ContentInset.Top.

A passing green suite here does not prove the inset is ever released, because every current test asserts only that the inset was applied. Add at least one test that applies delegation, removes/replaces the scroll host (or pops and re-pushes the page), and asserts ContentInset.Top / the host's on-screen Y returns to its undelegated value.

@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

@jfversluis — new AI review results are available based on commit d65e6e8.

Gate Failed Confidence Low Platform iOS


🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix

Gate Result: ❌ FAILED

Platform: IOS · Base: inflight/current · Merge base: ac5c53f5

🩺 The PR's test does not compile — the build error is in one of the PR's own test files, which the gate never reverts, so it fails identically without and with the fix. This is NOT a pre-existing/environment failure — the PR must fix its test (e.g. an ambiguous using / type collision). Investigate the PR's test code.

/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(226,3): error CS1524: Expected catch or finally [/Users/cloudtest/vss/_work/1/s/src/Controls...

Test Without Fix (expect FAIL) With Fix (expect PASS)
🖥️ Issue33037NonShell Issue33037NonShell 🛠️ BUILD ERROR 🛠️ BUILD ERROR
🔴 Without fix — 🖥️ Issue33037NonShell: 🛠️ BUILD ERROR · 247s

Error-relevant lines (filtered from the build log):

/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(226,3): error CS1524: Expected catch or finally [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(230,3): error CS0106: The modifier 'public' is not valid for this item [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(245,4): error CS1524: Expected catch or finally [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(249,4): error CS0106: The modifier 'public' is not valid for this item [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(272,4): error CS0106: The modifier 'public' is not valid for this item [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(298,4): error CS0106: The modifier 'public' is not valid for this item [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(317,5): error CS1513: } expected [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(322,4): error CS1513: } expected [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
🟢 With fix — 🖥️ Issue33037NonShell: 🛠️ BUILD ERROR · 98s

Error-relevant lines (filtered from the build log):

/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(226,3): error CS1524: Expected catch or finally [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(230,3): error CS0106: The modifier 'public' is not valid for this item [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(245,4): error CS1524: Expected catch or finally [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(249,4): error CS0106: The modifier 'public' is not valid for this item [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(272,4): error CS0106: The modifier 'public' is not valid for this item [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(298,4): error CS0106: The modifier 'public' is not valid for this item [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(317,5): error CS1513: } expected [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]
/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(322,4): error CS1513: } expected [/Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.iOS.Tests/Controls.TestCases.iOS.Tests.csproj]

⚠️ Failure Details

  • 🛠️ Issue33037NonShell without fix: build failed before tests could run
    • /Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(226,3): error CS1524: Expected catch or finally [/Users/cloudtest/vss/_work/1/s/src/Controls...
  • 🛠️ Issue33037NonShell with fix: build failed (fix does not compile)
    • /Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs(226,3): error CS1524: Expected catch or finally [/Users/cloudtest/vss/_work/1/s/src/Controls...
📁 Fix files reverted (5 files)
  • src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs
  • src/Controls/src/Core/Layout/Layout.iOS.cs
  • src/Core/src/Handlers/Layout/LayoutHandler.iOS.cs
  • src/Core/src/Platform/iOS/MauiScrollView.cs
  • src/Core/src/Platform/iOS/MauiView.cs

New files (not reverted):

  • src/Core/src/Core/ISafeAreaLayout.cs

📋 Pre-Flight — Context & Validation

PR #37548 Pre-Flight

Context

  • Title: [iOS] Delegate large-title safe area to scroll content
  • Issue: #33037, iOS Large Title display disappears
  • Target: inflight/current
  • Head: jfversluis-fix-33037-follow-up (d65e6e865c8e295f40c082d4bf192d1dd681101b)
  • Platform: iOS 26+
  • Problem: In a non-Shell NavigationPage, a root MAUI Layout consumes the top container safe area and moves a nested native scroll host below the navigation bar. UIKit therefore cannot observe the expected edge-to-edge scroll geometry and collapses the bar while leaving the large title clipped instead of transitioning to the compact title.

Existing PR Approach

The PR removes the prior NavigationRenderer proxy-scroll-view implementation and introduces layout-owned safe-area delegation:

  1. MauiView supplies its full bounds and resolved safe-area insets through a new internal ISafeAreaLayout contract.
  2. Layout.iOS.cs performs normal safe-area arrangement, searches for exactly one eligible vertical scroll subtree, extends that host to the top edge, and transfers the displaced top gap into native content and indicator insets.
  3. MauiScrollView tracks delegated inset ownership, forces ContentInsetAdjustmentBehavior.Never while delegated, preserves the relative scroll position outside active gestures, and restores its previous state.
  4. LayoutHandler.iOS.cs resets delegated ownership on disconnect.
  5. Eligibility is limited to iOS 26+, implicit top Container safe area, a visible translucent large-title navigation bar, one unambiguous vertical scroll subtree, and compatible fixed-sibling stacking. Explicit safe-area ownership and ambiguous layouts retain existing behavior.

PR Diff

File Change
src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs Removes the 342-line proxy UIScrollView workaround and its lifecycle/KVO hooks.
src/Controls/src/Core/Layout/Layout.iOS.cs Adds candidate discovery, frame extension, inset transfer/reset, native scroll resolution, and ambiguity/overlay checks.
src/Controls/tests/TestCases.HostApp/Issues/Issue33037NonShell.cs Adds host scenarios for native and MAUI scroll controls plus eligibility safeguards.
src/Controls/tests/TestCases.HostApp/MauiProgram.cs Registers the custom native-scroll test handler.
src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33037NonShell.cs Adds iOS UI tests; the current file has malformed nested try/test blocks.
src/Core/src/Core/ISafeAreaLayout.cs Adds internal safe-area layout and iOS scroll-inset contracts.
src/Core/src/Handlers/Layout/LayoutHandler.iOS.cs Resets delegated safe-area state during disconnect.
src/Core/src/Platform/iOS/MauiScrollView.cs Implements delegated top-inset ownership and restoration.
src/Core/src/Platform/iOS/MauiView.cs Routes eligible safe-area arrangement through ISafeAreaLayout.

Gate Evidence

Gate verification already failed and must not be rerun. Both without-fix and with-fix runs stopped while compiling:

Issue33037NonShell.cs(226,3): error CS1524: Expected catch or finally
Issue33037NonShell.cs(230,3): error CS0106: The modifier 'public' is not valid for this item

The first missing finally begins after HiddenNavigationBarPreservesTopSafeArea; subsequent tests are nested inside its try block. The gate never reached runtime behavior.

The gate identified these production fix files:

  • src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs
  • src/Controls/src/Core/Layout/Layout.iOS.cs
  • src/Core/src/Core/ISafeAreaLayout.cs (new in the PR)
  • src/Core/src/Handlers/Layout/LayoutHandler.iOS.cs
  • src/Core/src/Platform/iOS/MauiScrollView.cs
  • src/Core/src/Platform/iOS/MauiView.cs

EstablishBrokenBaseline.ps1 can revert the five pre-existing files, but reports src/Core/src/Core/ISafeAreaLayout.cs as a PR-added file. Under the try-fix baseline-file boundary, a non-empty NewFiles array is an explicit blocker and must be recorded rather than bypassed.

Bounded Validation

Detected primary test:

pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform ios -TestFilter "Issue33037NonShell"

No additional mandatory regression command was supplied in the STEP 5a request. Do not run the gate again or any full suite. Each candidate is limited to one implementation/test pass and at most one focused correction/retest.


🔬 Code Review — Deep Analysis

Code Review — PR #37548

Verdict: NEEDS_CHANGES
Confidence: low

Independent Assessment

What this changes: The PR removes the NavigationRenderer proxy-scroll implementation and moves iOS 26 large-title handling into layout. MauiView passes full bounds plus resolved safe-area insets to Layout, which selects one vertical native scroll host, extends only its native frame under the navigation bar, transfers the displaced top gap to scroll-content and indicator insets, and restores those values when eligibility or handler lifetime changes.

Inferred motivation: UIKit only performs the native large-to-compact title transition when the active vertical scroll view remains edge-to-edge. The submitted approach tries to preserve MAUI's existing logical safe-area layout while exposing the required native geometry.

Approach assessment: The ownership direction addresses the demonstrated root cause and is more general than a MAUI control allow-list, but the current implementation is not safe to merge. It splits logical and native frame ownership, mutates UIKit's all-edge inset policy to solve a top-edge problem, and performs expensive discovery in the arrange hot path. The submitted UI-test source also does not compile, so none of the new regression scenarios executed in the trusted Gate.

Reconciliation with PR Narrative

The description accurately explains the intended geometry, eligibility boundary, and supported control families. It overstates verification for the submitted HEAD: the checked-in test file is structurally malformed, the trusted Gate stopped at compiler errors, and the claimed all-edge/live-inset preservation is not established when ContentInsetAdjustmentBehavior changes from UIKit-managed adjustment to Never.

Prior Review Reconciliation

Prior ❌ finding Status Evidence
Added UI tests do not compile because HiddenNavigationBarPreservesTopSafeArea has an unterminated try ❌ Unresolved Issue33037NonShell.cs:221-228 still transitions directly from the try block to another [Test]; Gate reports CS1524/CS0106.
Delegation forces ContentInsetAdjustmentBehavior.Never, losing UIKit-provided non-top safe areas ❌ Unresolved Both the MAUI and foreign-scroll paths preserve raw ContentInset.Left/Bottom/Right, but those values do not include side/bottom contributions previously supplied only through AdjustedContentInset.
Generic layout code writes behind MauiScrollView's own inset pipeline ✅ Fixed in current HEAD Current HEAD introduces ISafeAreaScrollView; MAUI scroll views now route through their own state machine. The foreign-scroll fallback remains separate and risky.
Native-only PlatformArrange desynchronizes the platform frame from IView.Frame ❌ Unresolved Layout.iOS.cs:25 performs normal managed arrange, then line 77 overwrites only the native frame.

Blast Radius Assessment

  • Runs for all instances: The new interface branch runs for every safe-area-adjusting MAUI Layout; large-title delegation is gated, but the bypass of AdjustForSafeArea is not.
  • Startup/page initialization: Yes. Candidate discovery and inset transfer run during initial page arrange, before native ContentSize is necessarily stable.
  • Static/shared state: No new static state. Ownership is retained per Layout/MauiScrollView.
  • Lifecycle: Reset is wired to layout ineligibility and LayoutHandler.DisconnectHandler, but release paths lack executable regression coverage.

CI Status

  • Trusted Gate: ❌ PR-caused compile failure; required UI-test validation never ran.
  • Current remote HEAD: maui-pr and Build Analysis are red; most legs pass, while MultiProject macOS and RunOniOS MauiRelease ARM64 report failure.
  • Classification: The trusted Gate failure is conclusively PR-caused. Remote failing legs are not sufficient to establish runtime correctness and remain unclassified here.
  • Action: No Gate rerun, per execution contract. Confidence is capped at low.

Findings

❌ Error — Keyboard auto-scroll follow-up is bypassed

MauiView.CrossPlatformArrange now returns from the ISafeAreaLayout branch without calling AdjustForSafeArea. That helper also maps KeyboardAutoManagerScroll.ShouldIgnoreSafeAreaAdjustment to ShouldScrollAgain; losing this side effect affects every safe-area root Layout, not only eligible iOS 26 large-title pages.

❌ Error — Delegation drops UIKit-managed side and bottom insets

Both delegation paths force ContentInsetAdjustmentBehavior.Never but transfer only the top gap. When UIKit had supplied left/right/bottom safe areas through AdjustedContentInset, preserving the raw ContentInset fields does not preserve those system contributions. Landscape notch and home-indicator avoidance can regress.

❌ Error — Submitted regression tests cannot compile

The missing finally after HiddenNavigationBarPreservesTopSafeArea nests subsequent test declarations in a try block. This directly caused the trusted Gate to fail before runtime validation.

❌ Error — MauiScrollView delegation transition consumes invalidation state

ApplyDelegatedTopInset and ResetDelegatedTopInset call UpdateContentInsetAdjustmentBehavior() but discard its transition result after latching _previousTopSafeAreaDelegation. The safe-area source and native geometry change at the same time; the state machine must explicitly invalidate its safe-area and measure caches rather than rely on callback timing.

⚠️ Warning — Managed and native frames intentionally diverge

The normal arrange records the safe-area-inset IView.Frame, then Handler.PlatformArrange extends only the native frame. This gives UIKit the desired geometry but leaves managed bounds, hit-test/gesture coordinates, and rendered bounds with different origins and heights. The architectural tradeoff needs explicit resolution or targeted proof.

⚠️ Warning — Eligibility and native discovery are unstable and expensive

The arrange path repeatedly scans managed siblings and recursively marshals native Subviews; generic vertical detection also depends on first-pass ContentSize and does not identify compositional collection layouts directly. This can switch delegation after initial layout and creates O(n²)/subtree work in a hot path.

⚠️ Warning — Runtime navigation-state changes have no explicit invalidation path

The deleted renderer reacted to PrefersLargeTitles, HasNavigationBar, LargeTitleDisplay, and content changes. The replacement re-evaluates only during arrange, so a runtime eligibility change can leave ownership stale until an unrelated layout occurs.

Failure-Mode Probing

  • Ordinary non-large-title pages: They skip top delegation, but safe-area root layouts still take the new interface branch and therefore still miss the keyboard hook.
  • Disconnect/reconnect: Teardown attempts to restore ownership, but the malformed tests provide no executable proof against doubled or stale insets.
  • Initial layout: Generic native scroll views can report empty bounds/content and be rejected, then become eligible on a later pass, producing a visible geometry change.
  • Landscape/home indicator: Switching adjustment behavior to Never removes system-managed non-top contributions unless they are transferred explicitly.
  • Repeated arrange/programmatic animation: Identical inset and offset values are rewritten without an idempotency guard; an animated programmatic scroll is neither dragging nor decelerating and can be snapped.

External Output Contract

Not applicable.

Trim/AOT Evidence Chain

Not applicable.

Verdict: NEEDS_CHANGES

The raw PR cannot be approved because its required tests do not compile and concrete safe-area/keyboard regressions remain. A consolidated reviewer candidate may repair the compile failure and the bounded production defects, but it must be treated as a changed candidate and validated once under the execution contract.


🛠️ Try-Fix — Analysis & Comparison

PR #37548 — Bounded Try-Fix Aggregate

Candidate 1 — Native UIKit Safe-Area Ownership

  • Model: claude-opus-5
  • Result: Blocked
  • Approach: For an eligible non-Shell NavigationPage, stop MauiView from consuming the implicit top Container safe area and preserve native UIScrollView.ContentInsetAdjustmentBehavior.Automatic. This would make the real scroll host edge-to-edge from the outset and let UIKit calculate AdjustedContentInset and drive the large-title transition, rather than compensating after MAUI layout with either a proxy scroll view or the PR's ISafeAreaLayout inset-transfer contract.
  • Mechanism-level distinction: The PR accepts an already-consumed safe area and repairs the child frame/insets downstream. Candidate 1 instead proposed preventing the initial managed top-inset consumption so no recursive candidate discovery, frame rewrite, manual inset transfer, or delegated state restoration would be required.
  • Files changed / diff: None; the captured diff is empty.
  • Validation: Not run. EstablishBrokenBaseline.ps1 rejected the pre-existing dirty worktree before creating .github/.baseline-state.json, so the skill's modification allow-list did not exist. The PR-added src/Core/src/Core/ISafeAreaLayout.cs would independently make NewFiles non-empty. Both conditions require a Blocked result before editing. Re-running the primary UI test on unchanged code would also only repeat the gate's known test-compilation failure.
  • Failure analysis: This is a deterministic restoration-boundary blocker, not evidence for or against the proposed mechanism. The malformed Issue33037NonShell.cs test remains an independent validation blocker.
  • Self-review: 0 findings; one inline self-review pass over an empty diff.
  • Artifacts: CustomAgentLogsTmp/PRState/37548/PRAgent/try-fix/attempt-1/
  • Narrative: CustomAgentLogsTmp/PRState/37548/PRAgent/try-fix-1/content.md

Candidate 2 — Explicit iOS 26 Top-Edge Scroll Association

  • Model: gpt-5.6-sol
  • Result: Blocked
  • Approach: Have NavigationRenderer resolve the real eligible descendant UIScrollView and explicitly register that instance as the page view controller's iOS 26 top-edge content scroll view. Refresh the association when the visible page or native host changes and clear it during disappearance/disposal, while leaving MAUI safe-area arrangement and native inset behavior unchanged.
  • Mechanism-level distinction: Unlike the deleted proxy, this identifies the real scroll source. Unlike the PR, it does not extend frames, transfer insets, force ContentInsetAdjustmentBehavior.Never, or add layout ownership state. Unlike candidate 1, it neither suppresses root safe-area consumption nor relies on automatic descendant discovery; it hypothesizes that failed UIKit scroll-host identification is the root cause and supplies that identity directly.
  • Files changed / diff: None; the captured diff is empty.
  • Validation: Not run. Baseline establishment again stopped before producing .github/.baseline-state.json; the known PR-added file would independently violate the skill's NewFiles boundary. The unchanged primary UI test also remains unable to compile.
  • Failure analysis: The restoration constraints prevented implementation, so the candidate remains an untested design rather than a validated fix.
  • Self-review: 0 findings over an empty diff.
  • Artifacts: CustomAgentLogsTmp/PRState/37548/PRAgent/try-fix/attempt-2/
  • Narrative: CustomAgentLogsTmp/PRState/37548/PRAgent/try-fix-2/content.md

Aggregate Status

The bounded two-candidate budget is exhausted. Both alternatives are mechanism-level designs only; neither could be implemented or tested because the mandatory baseline script rejected the pre-existing dirty worktree and created no restoration allow-list. The PR-added ISafeAreaLayout.cs and malformed Issue33037NonShell.cs test are independent blockers. No candidate source changes remain in the worktree.


📝 PR Finalize — Recommended Title & Description

Assessment: ✏️ Recommend updating — the title can name NavigationPage more precisely, and the current description incorrectly presents stale successful test/CI claims even though the submitted HEAD's added UI-test source does not compile.

Recommended title

[iOS] NavigationPage: Delegate large-title safe area to scroll content

Recommended description

## Description

Fixes the remaining non-Shell large-title transition reported in #33037 on iOS 26.

A root MAUI `Layout` currently applies its `Container` safe area by physically moving nested scroll content as the navigation bar changes height. UIKit requires the active vertical scroll host to remain edge-to-edge to transition from the leading large title to the centered compact title. The bar therefore collapsed while the approximately 42pt large title remained clipped instead of becoming the native approximately 22pt compact title.

This change introduces explicit safe-area layout ownership:

- `MauiView` supplies full bounds and resolved safe-area insets through an internal `ISafeAreaLayout` contract.
- `Layout` first performs its normal safe-area arrangement, then delegates the top safe-area gap to one unambiguous vertical native `UIScrollView` by extending its frame edge-to-edge and transferring the gap to UIKit content/indicator insets.
- Inset ownership is delta-based, preserves live side/bottom and keyboard-related values, preserves relative scroll position outside active gestures, and is restored during disconnect or when eligibility changes.
- Explicit `SafeAreaEdges` remains authoritative. Ambiguous layouts retain the existing behavior rather than guessing.
- The final boundary is native UIKit capability, not a MAUI control allow-list: custom and third-party controls backed by a vertical `UIScrollView`, `UITableView`, or `UICollectionView` participate without framework-specific dependencies. A custom MAUI `View` backed directly by `UITableView` covers this path.

The old renderer-level `SetContentScrollView` investigation has been removed; this fix uses standard UIKit frames and `UIScrollView` inset APIs only.

## Eligibility safeguards

Delegation requires iOS 26+, an implicit top `Container` safe area, a visible translucent navigation bar that prefers large titles, one exclusive vertical scroll subtree, and valid overlay stacking for fixed top siblings. Hidden, transparent, horizontal, explicitly safe-area-configured, or ambiguous candidates are rejected.

## Runtime validation

Manual validation was performed on iPhone 17 Pro / iOS 26.5:

- Modal nested `ListView`: leading large title collapses to a centered compact title and expands again on return to top.
- Direct/wrapped `ScrollView`, `ListView`, `TableView`, `CollectionView`, `CarouselView`, and `WebView` scenarios.
- Fixed-header and programmatic-scroll scenarios.
- Custom third-party-like MAUI `View` whose handler exposes a native `UITableView`.
- Scroll-host frame remains stable and edge-to-edge throughout the transition.
- Repeated close/reopen after scrolling does not crash.

### Original nested scenario

| Expanded | Collapsed |
|---|---|
| ![Expanded large title](https://github.com/user-attachments/assets/71a53518-906e-4555-91b0-8f9c0e985d51) | ![Centered compact title](https://github.com/user-attachments/assets/1189ae7f-468f-4c79-8370-f42521918f91) |

### Custom native-scroll control

| Expanded | Collapsed |
|---|---|
| ![Custom native UITableView with expanded title](https://github.com/user-attachments/assets/19555321-918e-4cdc-b96d-222d7fd1e927) | ![Custom native UITableView with compact title](https://github.com/user-attachments/assets/7f13ce3d-4977-4717-b5c1-2ae622f5bf48) |

## Automated test status

The submitted HEAD adds focused `Issue33037NonShell` iOS UI scenarios, but the test file currently has malformed nested `try` blocks and does not compile (`CS1524`/`CS0106`). The trusted Gate therefore did not reach runtime validation, and the current `maui-pr`/Build Analysis checks must not be described as passing.

> [!NOTE]
> This remains a draft architectural spike for maintainer review of the new safe-area ownership contract.

🏁 Report — Final Recommendation

⚠️ Final Recommendation: REQUEST CHANGES

Winner: pr-plus-reviewer

The reviewer-enhanced PR is the strongest candidate because it fixes the submitted test-compilation blocker and several concrete correctness/performance defects while retaining the only implemented mechanism. It is still not mergeable: its one permitted focused validation failed 11 of 20 tests, including direct large-title regression assertions.

Candidate comparison

Rank Candidate Implementation Validation Assessment
1 pr-plus-reviewer Submitted layout-delegation design plus one consolidated reviewer patch Failed: 9 passed, 11 failed Best evidence and clear improvement: the test project now builds, the keyboard hook is preserved, delegated inset transitions are idempotent/invalidated, and the managed eligibility scan is cheaper. It still fails modal ListView, candidate-selection, and programmatic WebView title-collapse assertions and leaves architectural findings unresolved.
2 pr Submitted layout-owned frame/inset delegation Gate failed before execution: malformed test source Cannot be approved. The regression suite does not compile, and expert review found concrete keyboard, lifecycle/state, frame-coherency, first-layout, and hot-path risks.
3 try-fix-1 Design only: stop consuming the implicit root top safe area and retain UIKit Automatic adjustment Blocked; not implemented or run Architecturally simpler and avoids manual inset ownership, but it has no diff or empirical evidence and could broaden edge-to-edge behavior beyond one unambiguous scroll host.
4 try-fix-2 Design only: explicitly associate the real descendant scroll view with the iOS 26 controller top edge Blocked; not implemented or run Narrower in theory, but no code or test evidence exists, and prior investigation already found that scroll-view association alone did not repair compact-title geometry while the host remained below the bar.

No candidate passed the required regression validation. The two try-fix-* entries are unimplemented hypotheses and therefore cannot outrank an executable candidate, while the raw PR is strictly dominated by pr-plus-reviewer: it contains the same underlying fix but cannot compile its own tests and lacks the reviewer corrections.

Expert-review comparison

The raw PR correctly identifies the root geometric requirement, but its implementation makes UIKit's frame edge-to-edge only after managed layout has recorded a different frame. It also replaces a reactive renderer lifecycle with arrange-time polling and performs managed/native subtree discovery on a layout hot path. The consolidated candidate addresses only findings that could be corrected safely in one bounded patch; it records the remaining uncertainty rather than claiming completion.

The failed focused run is decisive. In particular:

  • ModalListViewLargeTitleRemainsVisibleAfterScrollRoundTrip retained a 54pt title where the test requires the compact title to be under 30pt.
  • Candidate-selection and programmatic-WebView scenarios did not collapse their titles.
  • Several later scenarios timed out after navigation cleanup could no longer find expected elements, so their individual production-code attribution is uncertain.
  • A Grid visual baseline differed by 5.26%, which may be expected visual drift or a regression, but was not investigated under the no-iteration contract.

Required disposition

Keep the PR in draft/request-changes state. pr-plus-reviewer is the winner for comparison purposes, not a merge recommendation; the submitted PR must incorporate an improved fix and obtain passing focused regression evidence before approval.


🧭 Next Steps — reviewer changes required

The reviewer-enhanced candidate identified changes that are not yet in the submitted PR.

Why: The consolidated reviewer candidate fixes the submitted test-compilation blocker and several concrete keyboard, inset-state, and arrange-path defects, so it strictly improves the raw PR. It remains unfit to merge because the sole focused validation still failed 11 of 20 tests, while both try-fix alternatives were blocked design proposals with no implementation or test evidence.

Address the actionable findings in this review before merging.

@MauiBot MauiBot removed the s/agent-review-in-progress AI review is currently running for this PR label Aug 24, 2026
kubaflo pushed a commit that referenced this pull request Aug 25, 2026
…roll content (#36511)" (#37810)

## Description

This reverts #36511. The `Issue33037NonShell` UI tests added by that PR
are unstable on iOS 26.5, while the proxy-based implementation is being
reconsidered in #37548. Reverting avoids shipping the current
implementation while that work continues.

## Why revert

- Five scenarios fail in grouped iOS 26.5 runs despite passing
individually and on iOS 26.3:
  - `Issue33037FixedHeaderCollectionViewButton`
  - `Issue33037ShortFixedHeaderCollectionViewButton`
  - `Issue33037GridScrollViewButton`
  - `ProgrammaticScrollCollapsesLargeTitle`
  - `ProgrammaticWebViewScrollCollapsesLargeTitle`
- Restarting the app before each test makes all five pass, showing that
the failures are specific to shared test-session state and do not
reproduce when the scenarios run independently.
- `Issue33037NonShell_GridScrollView_BeforeScroll` also produces an
unstable first-paint visual difference.
- #37548 is investigating a replacement for the proxy mechanism and
remains in progress.

## Changes

- Restores `NavigationRenderer.cs` to its pre-#36511 behavior.
- Removes the `Issue33037NonShell` HostApp scenarios and UI tests.
- Removes the nine associated iOS 26 snapshot baselines.
@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 30, 2026
@MauiBot

MauiBot commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Merge Conflict Detected — This PR conflicts with its target branch inflight/current. Please rebase onto the target branch and resolve the conflicts.

@MauiBot MauiBot removed the s/agent-review-in-progress AI review is currently running for this PR label Aug 30, 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.

Could you please resolve the merge conflicts?

@kubaflo

kubaflo commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Looks like this PR doesn't work - could you create a new PR

@kubaflo kubaflo closed this Aug 30, 2026
@AlleSchonWeg

Copy link
Copy Markdown
Contributor

Hi @jfversluis ,
will you open a new PR? I'm ready to test so we can get a good result for maui devs.

kubaflo pushed a commit that referenced this pull request Sep 3, 2026
…roll content (#36511)" (#37810)

## Description

This reverts #36511. The `Issue33037NonShell` UI tests added by that PR
are unstable on iOS 26.5, while the proxy-based implementation is being
reconsidered in #37548. Reverting avoids shipping the current
implementation while that work continues.

## Why revert

- Five scenarios fail in grouped iOS 26.5 runs despite passing
individually and on iOS 26.3:
  - `Issue33037FixedHeaderCollectionViewButton`
  - `Issue33037ShortFixedHeaderCollectionViewButton`
  - `Issue33037GridScrollViewButton`
  - `ProgrammaticScrollCollapsesLargeTitle`
  - `ProgrammaticWebViewScrollCollapsesLargeTitle`
- Restarting the app before each test makes all five pass, showing that
the failures are specific to shared test-session state and do not
reproduce when the scenarios run independently.
- `Issue33037NonShell_GridScrollView_BeforeScroll` also produces an
unstable first-paint visual difference.
- #37548 is investigating a replacement for the proxy mechanism and
remains in progress.

## Changes

- Restores `NavigationRenderer.cs` to its pre-#36511 behavior.
- Removes the `Issue33037NonShell` HostApp scenarios and UI tests.
- Removes the nine associated iOS 26 snapshot baselines.
kubaflo pushed a commit that referenced this pull request Sep 4, 2026
…roll content (#36511)" (#37810)

## Description

This reverts #36511. The `Issue33037NonShell` UI tests added by that PR
are unstable on iOS 26.5, while the proxy-based implementation is being
reconsidered in #37548. Reverting avoids shipping the current
implementation while that work continues.

## Why revert

- Five scenarios fail in grouped iOS 26.5 runs despite passing
individually and on iOS 26.3:
  - `Issue33037FixedHeaderCollectionViewButton`
  - `Issue33037ShortFixedHeaderCollectionViewButton`
  - `Issue33037GridScrollViewButton`
  - `ProgrammaticScrollCollapsesLargeTitle`
  - `ProgrammaticWebViewScrollCollapsesLargeTitle`
- Restarting the app before each test makes all five pass, showing that
the failures are specific to shared test-session state and do not
reproduce when the scenarios run independently.
- `Issue33037NonShell_GridScrollView_BeforeScroll` also produces an
unstable first-paint visual difference.
- #37548 is investigating a replacement for the proxy mechanism and
remains in progress.

## Changes

- Restores `NavigationRenderer.cs` to its pre-#36511 behavior.
- Removes the `Issue33037NonShell` HostApp scenarios and UI tests.
- Removes the nine associated iOS 26 snapshot baselines.
kubaflo pushed a commit that referenced this pull request Sep 7, 2026
…roll content (#36511)" (#37810)

## Description

This reverts #36511. The `Issue33037NonShell` UI tests added by that PR
are unstable on iOS 26.5, while the proxy-based implementation is being
reconsidered in #37548. Reverting avoids shipping the current
implementation while that work continues.

## Why revert

- Five scenarios fail in grouped iOS 26.5 runs despite passing
individually and on iOS 26.3:
  - `Issue33037FixedHeaderCollectionViewButton`
  - `Issue33037ShortFixedHeaderCollectionViewButton`
  - `Issue33037GridScrollViewButton`
  - `ProgrammaticScrollCollapsesLargeTitle`
  - `ProgrammaticWebViewScrollCollapsesLargeTitle`
- Restarting the app before each test makes all five pass, showing that
the failures are specific to shared test-session state and do not
reproduce when the scenarios run independently.
- `Issue33037NonShell_GridScrollView_BeforeScroll` also produces an
unstable first-paint visual difference.
- #37548 is investigating a replacement for the proxy mechanism and
remains in progress.

## Changes

- Restores `NavigationRenderer.cs` to its pre-#36511 behavior.
- Removes the `Issue33037NonShell` HostApp scenarios and UI tests.
- Removes the nine associated iOS 26 snapshot baselines.
kubaflo pushed a commit that referenced this pull request Sep 8, 2026
…roll content (#36511)" (#37810)

## Description

This reverts #36511. The `Issue33037NonShell` UI tests added by that PR
are unstable on iOS 26.5, while the proxy-based implementation is being
reconsidered in #37548. Reverting avoids shipping the current
implementation while that work continues.

## Why revert

- Five scenarios fail in grouped iOS 26.5 runs despite passing
individually and on iOS 26.3:
  - `Issue33037FixedHeaderCollectionViewButton`
  - `Issue33037ShortFixedHeaderCollectionViewButton`
  - `Issue33037GridScrollViewButton`
  - `ProgrammaticScrollCollapsesLargeTitle`
  - `ProgrammaticWebViewScrollCollapsesLargeTitle`
- Restarting the app before each test makes all five pass, showing that
the failures are specific to shared test-session state and do not
reproduce when the scenarios run independently.
- `Issue33037NonShell_GridScrollView_BeforeScroll` also produces an
unstable first-paint visual difference.
- #37548 is investigating a replacement for the proxy mechanism and
remains in progress.

## Changes

- Restores `NavigationRenderer.cs` to its pre-#36511 behavior.
- Removes the `Issue33037NonShell` HostApp scenarios and UI tests.
- Removes the nine associated iOS 26 snapshot baselines.
kubaflo pushed a commit that referenced this pull request Sep 9, 2026
…roll content (#36511)" (#37810)

## Description

This reverts #36511. The `Issue33037NonShell` UI tests added by that PR
are unstable on iOS 26.5, while the proxy-based implementation is being
reconsidered in #37548. Reverting avoids shipping the current
implementation while that work continues.

## Why revert

- Five scenarios fail in grouped iOS 26.5 runs despite passing
individually and on iOS 26.3:
  - `Issue33037FixedHeaderCollectionViewButton`
  - `Issue33037ShortFixedHeaderCollectionViewButton`
  - `Issue33037GridScrollViewButton`
  - `ProgrammaticScrollCollapsesLargeTitle`
  - `ProgrammaticWebViewScrollCollapsesLargeTitle`
- Restarting the app before each test makes all five pass, showing that
the failures are specific to shared test-session state and do not
reproduce when the scenarios run independently.
- `Issue33037NonShell_GridScrollView_BeforeScroll` also produces an
unstable first-paint visual difference.
- #37548 is investigating a replacement for the proxy mechanism and
remains in progress.

## Changes

- Restores `NavigationRenderer.cs` to its pre-#36511 behavior.
- Removes the `Issue33037NonShell` HostApp scenarios and UI tests.
- Removes the nine associated iOS 26 snapshot baselines.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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-failed AI could not verify tests catch the bug 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.

iOS Large Title display disappears