[Net11] [iOS/MacCatalyst] Migrate FlyoutPage to handler architecture - #36676
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 36676Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 36676" |
|
Azure Pipelines: Successfully started running 1 pipeline(s). There may be pipelines that require an authorized user to comment /azp run to run. |
|
/azp run maui-pr-uitests , maui-pr-devicetests |
|
Azure Pipelines: Successfully started running 2 pipeline(s). |
|
/azp run maui-pr-uitests , maui-pr-devicetests |
|
Azure Pipelines: Successfully started running 2 pipeline(s). |
This comment has been minimized.
This comment has been minimized.
d698e7f to
15b831d
Compare
|
/azp run maui-pr-uitests , maui-pr-devicetests |
|
Azure Pipelines: Successfully started running 2 pipeline(s). |
|
/azp run maui-pr-uitests , maui-pr-devicetests |
|
Azure Pipelines: Successfully started running 2 pipeline(s). |
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
AI Review Summary
@Vignesh-SF3580 — new AI review results are available based on this last commit:
5f055eb.
🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix
Gate Result: ⚠️ SKIPPED
No tests were detected in this PR.
Recommendation: Add tests to verify the fix using the write-tests-agent.
📱 UI Tests — FlyoutPage,ViewBaseTests
Detected UI test categories: FlyoutPage,ViewBaseTests
❌ Deep UI tests — 178 passed, 1 failed across 2 categories on platform-pool agent (replaces in-process counts above).
🧪 UI Test Execution Results (deep, platform pool)
| Category | Tests | Snapshot diffs |
|---|---|---|
FlyoutPage |
66/67 (1 ❌) | — |
ViewBaseTests |
112/112 ✓ | — |
🔍 AI analysis of failures — PR-related vs unrelated
🔍 AI-generated triage (GitHub Copilot CLI) — a heuristic judgement of whether each deep UI test failure is connected to this PR's changes. Verify before relying on it.
Likely PR-related: one or more failures appear connected to this PR's changes.
- ✗ PR-related — iOS FlyoutPage programmatic open/orientation flow (~1 test):
Bugzilla31602Testtimes out before finding the side-menu opener in the FlyoutPage category, and this PR replaces the iOS FlyoutPage renderer with a new FlyoutViewHandler/FlyoutContainerManager path that directly controls flyout presentation, toolbar button creation, and rotation behavior.
Strongest signal: the run platform is iOS and the changed files are iOS/shared FlyoutPage/FlyoutView code, exactly matching the failing test's area and behavior.
❌ FlyoutPage — 1 failed test
Bugzilla31602Test
System.TimeoutException : Timed out waiting for element...
at UITest.Appium.HelperExtensions.Wait(Func`1 query, Func`2 satisfactory, String timeoutMessage, Nullable`1 timeout, Nullable`1 retryFrequency) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 2761
at UITest.Appium.HelperExtensions.WaitForAtLeastOne(Func`1 query, String timeoutMessage, Nullable`1 timeout, Nullable`1 retryFrequency) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 2788
at UITest.Appium.HelperExtensions.WaitForElement(IApp app, String marked, String timeoutMessage, Nullable`1 timeout, Nullable`1 retryFrequency, Nullable`1 postTimeout) in /_/src/TestUtils/src/UITest.Appium/HelperExtensions.cs:line 797
at Microsoft.Maui.TestCases.Tests.Issues.Bugzilla31602.Bugzilla31602Test() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Bugzilla/Bugzilla31602.cs:line 21
at System.RuntimeMethodHandle.InvokeMethod(ObjectHandleOnStack target, Void** arguments, ObjectHandleOnStack
...
📎 Download drop-deep-uitests artifact (TRX + snapshot diffs)
📋 Pre-Flight — Context & Validation
Issue: #33083 - Unified iOS FlyoutViewHandler
PR: #36676 - [Net11] [iOS/MacCatalyst] Migrate FlyoutPage to handler architecture
Platforms Affected: iOS, MacCatalyst
Files Changed: 14 implementation/API files, 0 test files
Key Findings
- The linked issue originally described a staged rollout for a unified iOS FlyoutViewHandler, including an AppContext/feature switch disabled by default before promoting the handler to the default path.
- The PR instead registers the new FlyoutViewHandler as the unconditional default for iOS/MacCatalyst FlyoutPage.
- No tests were added or detected by the gate; targeted affected UI category is
FlyoutPage. - Public discussion shows prior comments about missing coverage for the new default path and PublicAPI duplicate entries; code review found more concrete lifecycle/accessibility regressions.
Code Review Summary
Verdict: NEEDS_CHANGES
Confidence: low
Errors: 3 | Warnings: 0 | Suggestions: 0
Key code review findings:
- ✗
src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.iOS.cs:30-40—DisconnectHandlernulls_managerand_containerVC, butConnectHandlerdoes not recreate them, so reconnect leaves mapper updates inert. - ✗
src/Core/src/Platform/iOS/FlyoutContainerManager/FlyoutContainerManager.cs:295-307— when overlay-open state transitions into split mode withoutIsPresentedchanging, detail accessibility hidden state is not recomputed. - ✗
src/Controls/src/Core/FlyoutPage/FlyoutPage.iOS.cs:223-226— the replacement toolbar button appliesSemanticPropertiesonly and drops existingAutomationProperties.Name/HelpTextfallback behavior.
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #36676 | Unconditionally migrate iOS/MacCatalyst FlyoutPage to new handler architecture | 14 files | Original PR has concrete lifecycle/accessibility concerns |
🔬 Code Review — Deep Analysis
Code Review — PR #36676
Independent Assessment
What this changes: Replaces the default iOS/MacCatalyst FlyoutPage renderer with a new FlyoutViewHandler + UIKit FlyoutContainerManager, including layout, gesture, split/popover behavior, toolbar button, accessibility, safe-area, and lifecycle handling.
Inferred motivation: Align FlyoutPage with the handler architecture used by NavigationPage/TabbedPage, reduce renderer coupling, and improve multi-instance subscription behavior.
Reconciliation with PR Narrative
Author claims: The PR unconditionally migrates iOS/MacCatalyst FlyoutPage to a layered handler architecture and preserves renderer behavior.
Agreement/disagreement: The architecture matches the claim, but I found remaining lifecycle and accessibility parity regressions that make the unconditional default switch unsafe.
Prior Review Reconciliation
| Prior ❌ Error Finding | Source | Status | Evidence |
|---|---|---|---|
| Reconnect leaves handler inert | MauiBot / prior review | ❌ Unresolved | DisconnectHandler nulls _manager and _containerVC; ConnectHandler only assigns ViewController = _containerVC. |
| Accessibility hidden state stale after behavior transition | MauiBot / prior review | ❌ Unresolved | UpdateFlyoutBehavior relayouts on same presented state but does not call ToggleAccessibilityElementsHidden(). |
| Flyout button drops AutomationProperties fallback | MauiBot / prior review | ❌ Unresolved | New code only applies SemanticProperties; existing iOS toolbar helper also applies AutomationProperties.Name/HelpText. |
Duplicate ITab / TabBarPlacement PublicAPI entries |
prior review | 🔄 Obsolete / not PR-caused | The same duplicates exist in base ab5a4d2; not introduced by this PR. |
| MacCatalyst status-bar-style override lacks guard | prior review | ✅ Fixed | Current FlyoutContainerViewController.ChildViewControllerForStatusBarStyle() is guarded with #if !MACCATALYST. |
Blast Radius Assessment
- Runs for all instances: yes —
AppHostBuilderExtensions.csregisters the new handler unconditionally for iOS/MacCatalyst. - Startup impact: yes — every iOS/MacCatalyst
FlyoutPagenow uses this handler. - Static/shared state: yes —
FlyoutViewHandler.ControlsConfigurationis static, though populated from Controls startup mapping.
CI Status
- Required-check result: fail / undetermined.
gh pr checks --requiredwas unavailable due missing auth; public check-run API for head5f055ebshows failingmaui-pr,maui-pr-devicetests, andmaui-pr-uitestschecks. - Classification: undetermined from available public data.
- Action taken: invoked
azdo-build-investigator;ci-analysisskill was unavailable in this environment. Confidence capped low.
External Output Contract
| Consumer token/pattern | Producer location | Producer emission condition | Consumer assumption | Ordinary negative case | Downstream effect |
|---|---|---|---|---|---|
| N/A | N/A | This PR does not add regex/string classification of external tool output. | N/A | N/A | N/A |
Findings
❌ Error — Reconnecting a disconnected handler leaves FlyoutPage permanently inert
src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.iOS.cs:30-40
DisconnectHandler tears down and then sets _manager = null and _containerVC = null. A later reconnect of the same handler/platform view calls ConnectHandler, but that method only does:
ViewController = _containerVC;It does not recreate the manager/controller. After that, all mapper methods check h._manager is { } manager and become no-ops, so Flyout, Detail, IsPresented, gestures, and layout updates stop working after handler disconnect/reconnect.
❌ Error — Detail accessibility remains hidden when transitioning from overlay-open to split mode
src/Core/src/Platform/iOS/FlyoutContainerManager/FlyoutContainerManager.cs:295-307
When a FlyoutPage is open in overlay mode, ToggleAccessibilityElementsHidden() hides the detail pane. If the device rotates/resizes into split mode while _isPresented is already true, UpdateFlyoutBehavior() computes stateChanged == false and only calls LayoutPanes() / UpdateClickOffView(). It never calls ToggleAccessibilityElementsHidden(), so the detail pane can remain inaccessible even though split mode now shows both panes.
❌ Error — New flyout toolbar button drops existing AutomationProperties accessibility fallback
src/Controls/src/Core/FlyoutPage/FlyoutPage.iOS.cs:223-226
The replacement hamburger button logic only applies SemanticProperties. The existing iOS toolbar path also applies AutomationProperties.NameProperty and AutomationProperties.HelpTextProperty as label/hint fallbacks (NavigationViewHandlerToolbarHelper.cs:526-547). Apps relying on those existing accessibility properties lose VoiceOver label/hint behavior after this handler becomes the unconditional default.
Failure-Mode Probing
- Handler disconnect/reconnect: after disconnect,
_manageris cleared; subsequent mapper calls short-circuit, so the page does not recover. - Rotation from open flyout to split mode: relayout occurs, but accessibility hidden flags are not recomputed when presented state is unchanged.
- Null/default values: most mapper paths guard
VirtualViewand child VCs, but toolbar icon load still runs asynchronously against capturedtargetVC; the larger blocking issues above are concrete. - Multiple subscriptions: instance-scoped flyout subscription improves over the prior static weak reference, and disconnect unsubscribes.
Verdict: NEEDS_CHANGES
Confidence: low
Summary: The migration direction is reasonable, but the current code still has concrete lifecycle and accessibility regressions in the new unconditional iOS/MacCatalyst default handler. CI is also red/undetermined from available data, so this should not merge as-is.
🛠️ Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| 1 | try-fix-1 | Gate the new iOS/MacCatalyst FlyoutViewHandler behind RuntimeFeature.IsFlyoutViewHandlerEnabled defaulting to false, preserving legacy PhoneFlyoutPageRenderer as default while the new handler remains opt-in. |
✅ PASS | 2 files | Avoids known parity regressions in the unconditional default path; follows existing RuntimeFeature rollout pattern. |
| PR | PR #36676 | Unconditionally register the new FlyoutViewHandler for iOS/MacCatalyst FlyoutPage. | 14 files | Original PR remains exposed to lifecycle/accessibility regressions found in pre-flight code review. |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| claude-opus-4.6 | 1 | Yes | Conservative rollout/feature-gate candidate generated and tested successfully. |
Exhausted: No — stopped because candidate #1 passed the targeted iOS FlyoutPage regression test suite and is demonstrably safer than the PR's unconditional-default fix.
Selected Fix: Candidate #1 — It preserves existing default behavior while allowing opt-in validation of the new handler, directly addressing the blast-radius concern and avoiding the unresolved new-handler parity bugs.
📝 Recommended PR Title & Description
Assessment: ✏️ Recommend updating — the winning candidate changes the rollout model from unconditional default registration to an opt-in RuntimeFeature gate, so the current title and description are now stale.
Recommended title
[Net11] [iOS/MacCatalyst] FlyoutPage: Gate new handler architecture behind RuntimeFeature
Recommended description
## Description of Change
Adds the new iOS/MacCatalyst `FlyoutViewHandler` architecture for `FlyoutPage`, but keeps the legacy `PhoneFlyoutPageRenderer` as the default registration path unless `RuntimeFeature.IsFlyoutViewHandlerEnabled` is explicitly enabled.
This preserves the handler migration work for opt-in validation while avoiding default-path regressions found during review of the unconditional migration.
## Issues Fixed
Fixes #33083
> **Note**: The shared Core infrastructure (`FlyoutContainerManager`, `IFlyoutContainerDelegate`) is designed for potential reuse by future `IFlyoutView` consumers, but no other consumer is part of this PR.
## Motivation
The `PhoneFlyoutPageRenderer` is a single class that **IS** a `UIViewController`. It owns pan/tap gesture handling, split-vs-popover layout math, safe-area handling, shadow application, accessibility toggling, hamburger bar-button updates, and status-bar/home-indicator delegation — all in one file. This makes it hard to maintain, tightly coupled to Controls-specific types, inconsistent with the newer handler architecture, prone to retain-cycle cleanup risks, and not multi-instance safe because of legacy static flyout icon/title subscription state.
## What Changed
### New handler infrastructure
- `Platform/iOS/FlyoutContainerManager/FlyoutContainerManager.cs` — shared UIKit manager for pan gesture + velocity snap, tap-to-close overlay, animated iPhone/iPad layout, split/popover/locked behaviors, RTL support, safe-area handling, rotation/size-transition handling, and accessibility toggling.
- `Platform/iOS/FlyoutContainerManager/FlyoutContainerViewController.cs` — minimal `UIViewController` wrapper forwarding lifecycle/layout/transition events to the manager and routing status-bar/home-indicator queries to the active detail view controller.
- `Platform/iOS/FlyoutContainerManager/IFlyoutContainerDelegate.cs` — bridge between Core and the Controls handler configuration.
- `Handlers/FlyoutView/FlyoutViewHandler.iOS.cs` — iOS handler implementation for `CreatePlatformView`, lifecycle hookup, mapper methods, and delegate callbacks.
- `Handlers/FlyoutView/FlyoutViewHandler.cs` / `.Standard.cs` — opens `FlyoutViewHandler` mapper support beyond the prior platform gate and adds Controls configuration plumbing.
- `Controls/src/Core/FlyoutPage/FlyoutPage.iOS.cs` and mapper updates — connects `FlyoutPage`-specific behavior such as `IsPresented` updates, layout bounds, toolbar button updates, shadow, and flow direction to the Core handler infrastructure.
### Rollout model
- Adds `RuntimeFeature.IsFlyoutViewHandlerEnabled`, defaulting to `false`.
- Registers `FlyoutViewHandler` for iOS/MacCatalyst `FlyoutPage` only when that switch is enabled.
- Keeps `Handlers.Compatibility.PhoneFlyoutPageRenderer` as the default iOS/MacCatalyst `FlyoutPage` handler while the new implementation receives opt-in validation.
### Public API
Updates the relevant `PublicAPI.Unshipped.txt` files for the new Core handler/container types introduced by the migration.
## Platforms Tested
- [x] iOS — `pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform ios -TestFilter "Category=FlyoutPage"`
- [ ] MacCatalyst
🏁 Report — Final Recommendation
Comparative Report — PR #36676
Candidates compared
| Candidate | Approach | Regression/gate result | Expert review result | Rank |
|---|---|---|---|---|
try-fix-1 |
Keep the PR's new handler implementation available, but gate iOS/MacCatalyst FlyoutPage registration behind RuntimeFeature.IsFlyoutViewHandlerEnabled defaulting to false, preserving PhoneFlyoutPageRenderer by default. |
✅ PASS — pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform ios -TestFilter "Category=FlyoutPage" |
Avoids the known new-handler parity defects on the default path. | 1 |
pr-plus-reviewer |
Apply expert-review fixes to the PR: reconnect-safe manager/container lifecycle, accessibility recomputation on split/overlay behavior changes, and legacy AutomationProperties fallback for the hamburger button. | Addresses the concrete reviewer findings, but still makes the large new handler path unconditional. | 2 | |
pr |
Raw submitted PR: unconditionally registers FlyoutViewHandler for iOS/MacCatalyst FlyoutPage. |
❌ Two major defects plus one moderate accessibility/back-compat issue. | 3 |
Analysis
The raw PR is not the best candidate because expert review found concrete lifecycle and accessibility regressions in the new unconditional handler path. pr-plus-reviewer is a valid improvement over the raw PR because it directly addresses those defects, but it still ships the new iOS/MacCatalyst handler as the default for every FlyoutPage without recorded test coverage in this pipeline run.
try-fix-1 is the safest winning candidate. It preserves the migration work for opt-in validation while keeping the legacy renderer as the default path, which avoids the known parity regressions and is the only candidate with a recorded passing iOS FlyoutPage regression run. Per the ranking rule, candidates without passing regression coverage are ranked below the passing candidate.
Winner
Winner: try-fix-1
Rationale: try-fix-1 is the only candidate with a passing targeted iOS FlyoutPage regression result and it minimizes user-facing blast radius by preserving the existing renderer as the default. The raw PR has unresolved expert-review defects, and the reviewer-applied PR variant remains unvalidated as an unconditional replacement.
🧭 Next Steps — alternative fix proposed (try-fix-1)
Automated review — alternative fix proposed
The expert-reviewer evaluation compared the PR fix against automatically generated candidates and selected try-fix-1 as the strongest fix.
Why: try-fix-1 wins because it is the only candidate with a passing targeted iOS FlyoutPage regression run and it avoids exposing known new-handler parity defects by keeping the legacy renderer as the default. The raw PR has expert-review defects, and pr-plus-reviewer is improved but still unvalidated as an unconditional migration.
Please consider applying the candidate diff below (or use it as guidance). Once you push an update, this workflow will re-trigger and re-evaluate.
Candidate diff (try-fix-1)
diff --git a/src/Controls/src/Core/FlyoutPage/FlyoutPage.Mapper.cs b/src/Controls/src/Core/FlyoutPage/FlyoutPage.Mapper.cs
index 1637a8f9dc..4ac4d6915d 100644
--- a/src/Controls/src/Core/FlyoutPage/FlyoutPage.Mapper.cs
+++ b/src/Controls/src/Core/FlyoutPage/FlyoutPage.Mapper.cs
@@ -1,5 +1,8 @@
using System;
-using Microsoft.Maui.Controls.Compatibility;
+using Microsoft.Maui.Handlers;
+#if IOS || MACCATALYST
+using UIKit;
+#endif
namespace Microsoft.Maui.Controls
{
@@ -9,9 +12,22 @@ namespace Microsoft.Maui.Controls
internal new static void RemapForControls()
{
FlyoutViewHandler.Mapper.ReplaceMapping<IFlyoutView, IFlyoutViewHandler>(nameof(FlyoutLayoutBehavior), MapFlyoutLayoutBehavior);
-#if IOS
- FlyoutViewHandler.Mapper.ReplaceMapping<IFlyoutView, IFlyoutViewHandler>(nameof(PlatformConfiguration.iOSSpecific.Page.PrefersHomeIndicatorAutoHiddenProperty), MapPrefersHomeIndicatorAutoHiddenProperty);
- FlyoutViewHandler.Mapper.ReplaceMapping<IFlyoutView, IFlyoutViewHandler>(nameof(PlatformConfiguration.iOSSpecific.Page.PrefersStatusBarHiddenProperty), MapPrefersPrefersStatusBarHiddenProperty);
+#if IOS || MACCATALYST
+ // Fill configuration record (Core → Controls bridge)
+ FlyoutViewHandler.ControlsConfiguration = new(
+ OnPresentedChangedByGesture: FlyoutPage.OnPresentedChangedByGesture,
+ OnLayoutBoundsChanged: FlyoutPage.OnLayoutBoundsChanged,
+ OnLeftBarButtonNeedsUpdate: FlyoutPage.OnLeftBarButtonNeedsUpdate,
+ OnHandlerDisconnected: FlyoutPage.OnHandlerDisconnected
+ );
+
+ // iOS-specific property mappers
+ FlyoutViewHandler.Mapper.AppendToMapping(
+ PlatformConfiguration.iOSSpecific.FlyoutPage.ApplyShadowProperty.PropertyName,
+ MapApplyShadow);
+ FlyoutViewHandler.Mapper.AppendToMapping(nameof(IView.FlowDirection), MapFlowDirection);
+ FlyoutViewHandler.Mapper.ReplaceMapping<IFlyoutView, IFlyoutViewHandler>(PlatformConfiguration.iOSSpecific.Page.PrefersHomeIndicatorAutoHiddenProperty.PropertyName, MapPrefersHomeIndicatorAutoHiddenProperty);
+ FlyoutViewHandler.Mapper.ReplaceMapping<IFlyoutView, IFlyoutViewHandler>(PlatformConfiguration.iOSSpecific.Page.PrefersStatusBarHiddenProperty.PropertyName, MapPrefersPrefersStatusBarHiddenProperty);
#endif
#if WINDOWS
FlyoutViewHandler.Mapper.ReplaceMapping<IFlyoutView, IFlyoutViewHandler>(nameof(PlatformConfiguration.WindowsSpecific.FlyoutPage.CollapseStyleProperty), MapCollapseStyle);
@@ -23,15 +39,21 @@ namespace Microsoft.Maui.Controls
handler.UpdateValue(nameof(IFlyoutView.FlyoutBehavior));
}
-#if IOS
+#if IOS || MACCATALYST
internal static void MapPrefersHomeIndicatorAutoHiddenProperty(IFlyoutViewHandler handler, IFlyoutView view)
{
- handler.UpdateValue(nameof(PlatformConfiguration.iOSSpecific.Page.PrefersHomeIndicatorAutoHiddenProperty));
+ if (handler is IPlatformViewHandler { ViewController: { } vc })
+ {
+ vc.SetNeedsUpdateOfHomeIndicatorAutoHidden();
+ }
}
internal static void MapPrefersPrefersStatusBarHiddenProperty(IFlyoutViewHandler handler, IFlyoutView view)
{
- handler.UpdateValue(nameof(PlatformConfiguration.iOSSpecific.Page.PrefersStatusBarHiddenProperty));
+ if (handler is IPlatformViewHandler { ViewController: { } vc })
+ {
+ vc.SetNeedsStatusBarAppearanceUpdate();
+ }
}
#endif
diff --git a/src/Controls/src/Core/FlyoutPage/FlyoutPage.iOS.cs b/src/Controls/src/Core/FlyoutPage/FlyoutPage.iOS.cs
new file mode 100644
index 0000000000..bf6554a02d
--- /dev/null
+++ b/src/Controls/src/Core/FlyoutPage/FlyoutPage.iOS.cs
@@ -0,0 +1,267 @@
+using System;
+using System.ComponentModel;
+using System.Linq;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Graphics.Platform;
+using Microsoft.Maui.Handlers;
+using Microsoft.Maui.Platform;
+using UIKit;
+
+namespace Microsoft.Maui.Controls
+{
+ public partial class FlyoutPage
+ {
+ // Track the flyout page this specific FlyoutPage instance is subscribed to
+ // for icon/title property changes. Instance-scoped (not static) so multiple
+ // FlyoutPage instances (multi-window, modal FlyoutPage, etc.) don't clobber
+ // each other's subscriptions.
+ WeakReference<Page>? _subscribedFlyout;
+
+ // Cached delegate instance so we can unsubscribe the exact same handler
+ // we subscribed with.
+ PropertyChangedEventHandler? _flyoutPropertyChangedHandler;
+
+
+ internal static void OnPresentedChangedByGesture(IFlyoutView view, bool isPresented)
+ {
+ if (view is FlyoutPage fp)
+ {
+ // Guard: during rotation, ShouldShowSplitMode may still return true while
+ // orientation hasn't settled. Writing false triggers InvalidOperationException
+ // in OnIsPresentedPropertyChanging validation.
+ if (!isPresented && ((IFlyoutPageController)fp).ShouldShowSplitMode)
+ {
+ return;
+ }
+
+ fp.IsPresented = isPresented;
+ }
+ else
+ {
+ view.IsPresented = isPresented;
+ }
+ }
+
+ internal static void OnLayoutBoundsChanged(IFlyoutView view, Rect flyoutBounds, Rect detailBounds)
+ {
+ if (view is IFlyoutPageController controller)
+ {
+ controller.FlyoutBounds = flyoutBounds;
+ controller.DetailBounds = detailBounds;
+ }
+ }
+
+ internal static void OnLeftBarButtonNeedsUpdate(IFlyoutView view)
+ {
+ if (view is not FlyoutPage fp)
+ {
+ return;
+ }
+
+ fp.SubscribeToFlyoutPropertyChanges();
+
+ if (fp.Detail?.Handler is not IPlatformViewHandler detailHandler)
+ {
+ return;
+ }
+
+ var detailVC = detailHandler.ViewController;
+ if (detailVC is null)
+ {
+ return;
+ }
+
+ // If detail VC is a UINavigationController, use its root VC
+ var targetVC = detailVC is UINavigationController nav
+ ? nav.ViewControllers?.FirstOrDefault() ?? detailVC
+ : detailVC;
+
+ UpdateFlyoutLeftBarButton(targetVC, fp);
+ }
+
+ /// <summary>
+ /// Called when this FlyoutPage's handler is disconnected, so its flyout
+ /// icon/title subscription doesn't outlive the handler.
+ /// </summary>
+ internal static void OnHandlerDisconnected(IFlyoutView view)
+ {
+ if (view is FlyoutPage fp)
+ {
+ fp.UnsubscribeFlyoutPropertyChanges();
+ }
+ }
+
+ void SubscribeToFlyoutPropertyChanges()
+ {
+ var flyout = Flyout;
+ if (flyout is null)
+ {
+ return;
+ }
+
+ // Unsubscribe from this instance's previous flyout if it changed
+ if (_subscribedFlyout is not null && _subscribedFlyout.TryGetTarget(out var oldFlyout))
+ {
+ if (ReferenceEquals(oldFlyout, flyout))
+ {
+ return; // Already subscribed to this flyout
+ }
+
+ if (_flyoutPropertyChangedHandler is not null)
+ {
+ oldFlyout.PropertyChanged -= _flyoutPropertyChangedHandler;
+ }
+ }
+
+ _flyoutPropertyChangedHandler = OnFlyoutPagePropertyChanged;
+ flyout.PropertyChanged += _flyoutPropertyChangedHandler;
+ _subscribedFlyout = new WeakReference<Page>(flyout);
+ }
+
+ /// <summary>
+ /// Unsubscribes from the currently-tracked flyout's property changes.
+ /// Called when this FlyoutPage's handler is disconnected.
+ /// </summary>
+ void UnsubscribeFlyoutPropertyChanges()
+ {
+ if (_subscribedFlyout is not null &&
+ _subscribedFlyout.TryGetTarget(out var flyout) &&
+ _flyoutPropertyChangedHandler is not null)
+ {
+ flyout.PropertyChanged -= _flyoutPropertyChangedHandler;
+ }
+
+ _flyoutPropertyChangedHandler = null;
+ _subscribedFlyout = null;
+ }
+
+ void OnFlyoutPagePropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == Page.IconImageSourceProperty.PropertyName ||
+ e.PropertyName == Page.TitleProperty.PropertyName)
+ {
+ if (sender is Page flyoutPage && flyoutPage.Parent is FlyoutPage fp)
+ {
+ OnLeftBarButtonNeedsUpdate(fp);
+ }
+ }
+ }
+
+ static void UpdateFlyoutLeftBarButton(UIViewController targetVC, FlyoutPage flyoutPage)
+ {
+ if (!flyoutPage.ShouldShowToolbarButton())
+ {
+ targetVC.NavigationItem.LeftBarButtonItem = null;
+ return;
+ }
+
+ var mauiContext = flyoutPage.FindMauiContext();
+ if (mauiContext is null)
+ {
+ return;
+ }
+
+ // Weak reference prevents a pending async callback from keeping
+ // the page alive after the handler is disconnected (memory leak fix).
+ var weakPage = new WeakReference<FlyoutPage>(flyoutPage);
+
+ EventHandler onItemTapped = (sender, e) =>
+ {
+ if (weakPage.TryGetTarget(out var p))
+ {
+ p.IsPresented = !p.IsPresented;
+ }
+ };
+
+ flyoutPage.Flyout.IconImageSource.LoadImage(mauiContext, result =>
+ {
+ if (!weakPage.TryGetTarget(out var fp))
+ {
+ return;
+ }
+
+ var icon = result?.Value;
+
+ if (icon is not null)
+ {
+ // Scale icon to fit nav bar (max 44pt height)
+ var originalSize = icon.Size;
+ if (originalSize.Height > 44)
+ {
+ if (fp.Flyout.IconImageSource is not FontImageSource fontImageSource ||
+ !fontImageSource.IsSet(FontImageSource.SizeProperty))
+ {
+ icon = icon.ResizeImageSource(originalSize.Width, 44f, originalSize);
+ }
+ }
+
+ try
+ {
+ targetVC.NavigationItem.LeftBarButtonItem =
+ new UIBarButtonItem(icon, UIBarButtonItemStyle.Plain, onItemTapped);
+ }
+ catch (Exception)
+ {
+ // UIBarButtonItem creation can throw
+ }
+ }
+
+ if (icon is null || targetVC.NavigationItem.LeftBarButtonItem is null)
+ {
+ // Fallback: use Flyout.Title as text button
+ targetVC.NavigationItem.LeftBarButtonItem =
+ new UIBarButtonItem(fp.Flyout?.Title ?? string.Empty, UIBarButtonItemStyle.Plain, onItemTapped);
+ }
+
+ // Set AutomationId and VoiceOver label/hint on the hamburger button.
+ if (!string.IsNullOrEmpty(fp.AutomationId))
+ {
+ targetVC.NavigationItem.LeftBarButtonItem.AccessibilityIdentifier = $"btn_{fp.AutomationId}";
+ }
+
+ // Apply FlyoutPage's SemanticProperties (Description/Hint), if set.
+ var semantics = SemanticProperties.UpdateSemantics(fp, null);
+ if (semantics is not null)
+ {
+ targetVC.NavigationItem.LeftBarButtonItem.UpdateSemantics(semantics);
+ }
+ });
+ }
+
+
+ internal static void MapApplyShadow(IFlyoutViewHandler handler, IFlyoutView view)
+ {
+ if (handler is FlyoutViewHandler h && h._manager is { } manager && view is BindableObject bo)
+ {
+ var applyShadow = PlatformConfiguration.iOSSpecific.FlyoutPage.GetApplyShadow(bo);
+ manager.UpdateApplyShadow(applyShadow);
+ }
+ }
+
+ internal static void MapFlowDirection(IFlyoutViewHandler handler, IFlyoutView view)
+ {
+ if (handler is FlyoutViewHandler h && h._manager is { } manager && view is IView v)
+ {
+ // Use the effective/inherited flow direction rather than the raw
+ // FlowDirection. A FlyoutPage left at the default MatchParent should
+ // follow an RTL app/window, not be treated as LTR.
+ var flowDirection = (view as IVisualElementController)?.EffectiveFlowDirection.ToFlowDirection()
+ ?? v.FlowDirection;
+ manager.UpdateFlowDirection(flowDirection);
+
+ // NavigationPage isn't auto-walked by Core's FlowDirection propagation, so
+ // manually re-trigger it on each page in the navigation stack.
+ if (view is FlyoutPage fp && fp.Detail is NavigationPage detailNavPage)
+ {
+ foreach (var page in detailNavPage.Navigation.NavigationStack)
+ {
+ if (page?.Handler is IElementHandler pageHandler)
+ {
+ pageHandler.UpdateValue(nameof(IView.FlowDirection));
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs b/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs
index 94afc97e6c..b1f519518c 100644
--- a/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs
+++ b/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs
@@ -205,7 +205,14 @@ public static partial class AppHostBuilderExtensions
#if IOS || MACCATALYST
handlersCollection.AddHandler<NavigationPage, NavigationViewHandler>();
handlersCollection.AddHandler<TabbedPage, TabbedViewHandler>();
- handlersCollection.AddHandler(typeof(FlyoutPage), typeof(Handlers.Compatibility.PhoneFlyoutPageRenderer));
+ if (RuntimeFeature.IsFlyoutViewHandlerEnabled)
+ {
+ handlersCollection.AddHandler<FlyoutPage, FlyoutViewHandler>();
+ }
+ else
+ {
+ handlersCollection.AddHandler(typeof(FlyoutPage), typeof(Handlers.Compatibility.PhoneFlyoutPageRenderer));
+ }
#endif
#if ANDROID || IOS || MACCATALYST || TIZEN
diff --git a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Standard.cs b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Standard.cs
index 270e36f350..354c9f2878 100644
--- a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Standard.cs
+++ b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Standard.cs
@@ -4,9 +4,13 @@ namespace Microsoft.Maui.Handlers
{
public partial class FlyoutViewHandler : ViewHandler<IFlyoutView, object>
{
- protected override object CreatePlatformView()
- {
- throw new System.NotImplementedException();
- }
+ protected override object CreatePlatformView() => throw new NotImplementedException();
+
+ public static void MapFlyout(IFlyoutViewHandler handler, IFlyoutView flyoutView) { }
+ public static void MapDetail(IFlyoutViewHandler handler, IFlyoutView flyoutView) { }
+ public static void MapIsPresented(IFlyoutViewHandler handler, IFlyoutView flyoutView) { }
+ public static void MapFlyoutBehavior(IFlyoutViewHandler handler, IFlyoutView flyoutView) { }
+ public static void MapFlyoutWidth(IFlyoutViewHandler handler, IFlyoutView flyoutView) { }
+ public static void MapIsGestureEnabled(IFlyoutViewHandler handler, IFlyoutView flyoutView) { }
}
}
diff --git a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.cs b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.cs
index ec3b42944b..bc56fd80ca 100644
--- a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.cs
+++ b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.cs
@@ -20,19 +20,17 @@ namespace Microsoft.Maui.Handlers
// So we have a separate mapper for them.
private static readonly IPropertyMapper<IFlyoutView, IFlyoutViewHandler> FlyoutLayoutMapper = new PropertyMapper<IFlyoutView, IFlyoutViewHandler>()
{
-#if ANDROID || WINDOWS || TIZEN
[nameof(IFlyoutView.Flyout)] = MapFlyout,
[nameof(IFlyoutView.Detail)] = MapDetail,
-#endif
};
public static IPropertyMapper<IFlyoutView, IFlyoutViewHandler> Mapper = new PropertyMapper<IFlyoutView, IFlyoutViewHandler>(ViewHandler.ViewMapper, FlyoutLayoutMapper)
{
-#if ANDROID || WINDOWS || TIZEN
[nameof(IFlyoutView.IsPresented)] = MapIsPresented,
[nameof(IFlyoutView.FlyoutBehavior)] = MapFlyoutBehavior,
[nameof(IFlyoutView.FlyoutWidth)] = MapFlyoutWidth,
[nameof(IFlyoutView.IsGestureEnabled)] = MapIsGestureEnabled,
+#if ANDROID || WINDOWS || TIZEN
[nameof(IToolbarElement.Toolbar)] = MapToolbar,
#endif
};
@@ -58,5 +56,20 @@ namespace Microsoft.Maui.Handlers
IFlyoutView IFlyoutViewHandler.VirtualView => VirtualView;
PlatformView IFlyoutViewHandler.PlatformView => PlatformView;
+
+#if IOS || MACCATALYST
+ /// <summary>
+ /// Configuration record filled by Controls layer via RemapForControls().
+ /// Core handler calls these when gestures/layout change — Controls writes back to FlyoutPage.
+ /// </summary>
+ internal sealed record FlyoutViewHandlerControlsConfiguration(
+ Action<IFlyoutView, bool> OnPresentedChangedByGesture,
+ Action<IFlyoutView, Graphics.Rect, Graphics.Rect> OnLayoutBoundsChanged,
+ Action<IFlyoutView> OnLeftBarButtonNeedsUpdate,
+ Action<IFlyoutView> OnHandlerDisconnected
+ );
+
+ internal static FlyoutViewHandlerControlsConfiguration? ControlsConfiguration { get; set; }
+#endif
}
}
diff --git a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.iOS.cs b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.iOS.cs
index 5b0247ce72..0725e2dbf2 100644
--- a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.iOS.cs
+++ b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.iOS.cs
@@ -1,12 +1,148 @@
-using System;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Platform;
+using UIKit;
namespace Microsoft.Maui.Handlers
{
- public partial class FlyoutViewHandler : ViewHandler<IFlyoutView, UIKit.UIView>
+ public partial class FlyoutViewHandler : ViewHandler<IFlyoutView, UIView>, IFlyoutContainerDelegate
{
- protected override UIKit.UIView CreatePlatformView()
+ internal FlyoutContainerManager? _manager;
+ FlyoutContainerViewController? _containerVC;
+
+ protected override UIView CreatePlatformView()
+ {
+ _manager = new FlyoutContainerManager(this);
+ _containerVC = new FlyoutContainerViewController(_manager);
+
+ // Force view load so SetupContainerViews is called
+ _containerVC.LoadViewIfNeeded();
+ return _containerVC.View!;
+ }
+
+ protected override void ConnectHandler(UIView platformView)
+ {
+ base.ConnectHandler(platformView);
+
+ // Set the ViewController so this handler participates in the VC hierarchy
+ ViewController = _containerVC;
+ }
+
+ protected override void DisconnectHandler(UIView platformView)
+ {
+ // TearDown() before OnHandlerDisconnected so the unsubscribe below runs last and sticks.
+ _manager?.TearDown();
+ _manager = null;
+ _containerVC = null;
+ ViewController = null;
+
+ if (VirtualView is not null)
+ {
+ ControlsConfiguration?.OnHandlerDisconnected(VirtualView);
+ }
+
+ base.DisconnectHandler(platformView);
+ }
+
+
+ public static void MapFlyout(IFlyoutViewHandler handler, IFlyoutView flyoutView)
+ {
+ if (handler is FlyoutViewHandler h && h._manager is { } manager)
+ {
+ var flyoutVC = flyoutView.Flyout?.ToUIViewController(handler.MauiContext!);
+ manager.SetFlyoutViewController(flyoutVC);
+ }
+ }
+
+ public static void MapDetail(IFlyoutViewHandler handler, IFlyoutView flyoutView)
+ {
+ if (handler is FlyoutViewHandler h && h._manager is { } manager)
+ {
+ var detailVC = flyoutView.Detail?.ToUIViewController(handler.MauiContext!);
+ manager.SetDetailViewController(detailVC);
+ }
+ }
+
+ public static void MapIsPresented(IFlyoutViewHandler handler, IFlyoutView flyoutView)
+ {
+ if (handler is FlyoutViewHandler h && h._manager is { } manager)
+ {
+ manager.UpdateIsPresented(flyoutView.IsPresented, animated: true);
+ }
+ }
+
+ public static void MapFlyoutBehavior(IFlyoutViewHandler handler, IFlyoutView flyoutView)
+ {
+ if (handler is FlyoutViewHandler h && h._manager is { } manager)
+ {
+ manager.UpdateFlyoutBehavior(flyoutView.FlyoutBehavior);
+ }
+ }
+
+ public static void MapFlyoutWidth(IFlyoutViewHandler handler, IFlyoutView flyoutView)
+ {
+ if (handler is FlyoutViewHandler h && h._manager is { } manager)
+ {
+ manager.UpdateFlyoutWidth(flyoutView.FlyoutWidth);
+ }
+ }
+
+ public static void MapIsGestureEnabled(IFlyoutViewHandler handler, IFlyoutView flyoutView)
+ {
+ if (handler is FlyoutViewHandler h && h._manager is { } manager)
+ {
+ manager.UpdateIsGestureEnabled(flyoutView.IsGestureEnabled);
+ }
+ }
+
+
+ void IFlyoutContainerDelegate.OnPresentedChangedByGesture(bool isPresented)
+ {
+ if (VirtualView is null)
+ {
+ return;
+ }
+
+ ControlsConfiguration?.OnPresentedChangedByGesture(VirtualView, isPresented);
+ }
+
+ void IFlyoutContainerDelegate.OnLayoutBoundsChanged(Rect flyoutBounds, Rect detailBounds)
+ {
+ if (VirtualView is null)
+ {
+ return;
+ }
+
+ ControlsConfiguration?.OnLayoutBoundsChanged(VirtualView, flyoutBounds, detailBounds);
+ }
+
+ void IFlyoutContainerDelegate.OnLeftBarButtonNeedsUpdate()
+ {
+ if (VirtualView is null)
+ {
+ return;
+ }
+
+ ControlsConfiguration?.OnLeftBarButtonNeedsUpdate(VirtualView);
+ }
+
+ void IFlyoutContainerDelegate.OnViewDidAppear()
+ {
+ // Lifecycle: page appeared — let framework handle Appearing event
+ }
+
+ void IFlyoutContainerDelegate.OnViewWillDisappear()
+ {
+ // Lifecycle: page disappearing — let framework handle Disappearing event
+ }
+
+ bool IFlyoutContainerDelegate.GetCurrentIsPresented()
+ {
+ return VirtualView?.IsPresented ?? false;
+ }
+
+ bool IFlyoutContainerDelegate.GetIgnoreSafeArea()
{
- throw new System.NotImplementedException();
+ return VirtualView is ISafeAreaView sav && sav.IgnoreSafeArea;
}
}
}
diff --git a/src/Core/src/Platform/iOS/FlyoutContainerManager/FlyoutContainerManager.cs b/src/Core/src/Platform/iOS/FlyoutContainerManager/FlyoutContainerManager.cs
new file mode 100644
index 0000000000..0840702b1c
--- /dev/null
+++ b/src/Core/src/Platform/iOS/FlyoutContainerManager/FlyoutContainerManager.cs
@@ -0,0 +1,1001 @@
+using System;
+using CoreGraphics;
+using Microsoft.Maui.Graphics;
+using UIKit;
+using PointF = CoreGraphics.CGPoint;
+
+namespace Microsoft.Maui.Platform;
+
+/// <summary>
+/// Manages flyout/detail split layout with pan gesture, tap-to-close, and animated show/hide.
+/// Pure UIKit — no Controls references.
+/// Communicates state changes back through <see cref="IFlyoutContainerDelegate"/>.
+/// </summary>
+internal class FlyoutContainerManager
+{
+
+ readonly WeakReference<IFlyoutContainerDelegate> _delegateRef;
+ WeakReference<UIViewController>? _parentVCRef;
+
+ // Container views (plain UIViews that hold child VC views)
+ UIView? _flyoutContainerView;
+ UIView? _detailContainerView;
+ UIView? _clickOffView;
+
+ // Gesture recognizers
+ UIPanGestureRecognizer? _panGesture;
+ UITapGestureRecognizer? _tapGesture;
+
+ // Child VCs (managed via parent VC's containment API)
+ UIViewController? _flyoutVC;
+ UIViewController? _detailVC;
+
+ bool _isPresented;
+ bool _isGestureEnabled = true;
+ bool _applyShadow;
+ bool _initialLayoutFinished;
+
+ FlyoutBehavior _flyoutBehavior = FlyoutBehavior.Flyout;
+ FlowDirection _flowDirection = FlowDirection.MatchParent;
+ double _flyoutWidth = -1; // -1 means platform default
+
+
+ internal FlyoutContainerManager(IFlyoutContainerDelegate containerDelegate)
+ {
+ _delegateRef = new WeakReference<IFlyoutContainerDelegate>(containerDelegate);
+ }
+
+
+ /// <summary>
+ /// On iPad, the flyout overlaps the detail (slides over from left).
+ /// On iPhone, the detail slides right to reveal the flyout behind it.
+ /// </summary>
+ static bool FlyoutOverlapsDetailsInPopoverMode =>
+ UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Pad;
+
+ bool IsRTL => _flowDirection == FlowDirection.RightToLeft;
+
+ UIView? ParentView => _parentVCRef is not null && _parentVCRef.TryGetTarget(out var vc) ? vc.View : null;
+
+ /// <summary>
+ /// The currently-hosted Detail child VC. Used by <see cref="FlyoutContainerViewController"/>
+ /// to route status-bar/home-indicator delegate queries explicitly to Detail, matching the
+ /// legacy renderer's behavior (it never asked the Flyout VC for these).
+ /// </summary>
+ internal UIViewController? ActiveDetailViewController => _detailVC;
+
+ bool ShouldShowSplitMode
+ {
+ get
+ {
+ if (!FlyoutOverlapsDetailsInPopoverMode)
+ {
+ return false; // iPhone never splits
+ }
+
+ // FlyoutBehavior is already resolved by Controls: Locked = split, Flyout = not-split.
+ // Trust it — don't recompute from raw bounds,
+ // or Popover/SplitOnPortrait will split incorrectly.
+ return _flyoutBehavior == FlyoutBehavior.Locked;
+ }
+ }
+
+
+ /// <summary>
+ /// Called from the container VC's ViewDidLoad. Sets up the view hierarchy
+ /// and stores the parent VC reference for containment API calls.
+ /// </summary>
+ internal void SetupContainerViews(UIViewController parentVC)
+ {
+ _parentVCRef = new WeakReference<UIViewController>(parentVC);
+
+ var parentView = parentVC.View!;
+ _flyoutContainerView = new UIView { ClipsToBounds = true };
+ _detailContainerView = new UIView { BackgroundColor = UIColor.Black, ClipsToBounds = true };
+ _clickOffView = new UIView { BackgroundColor = new UIColor(0, 0, 0, 0) };
+
+ PackContainers(parentView);
+ SetupTapGesture();
+ UpdatePanGesture();
+ }
+
+ /// <summary>
+ /// Called from handler when parent VC's view lays out subviews.
+ /// </summary>
+ internal void OnParentViewDidLayoutSubviews()
+ {
+ LayoutPanes(animated: false);
+
+ if (!_initialLayoutFinished)
+ {
+ _initialLayoutFinished = true;
+
+ // Read IsPresented from virtual view at layout time, as the value
+ // may differ from _isPresented due to FlyoutPage internal validation.
+ bool isPresented = _isPresented;
+ if (_delegateRef.TryGetTarget(out var del))
+ {
+ isPresented = del.GetCurrentIsPresented();
+ }
+
+ if (ShouldShowSplitMode)
+ {
+ SetPresented(true, animated: false, notifyDelegate: false);
+ }
+ else
+ {
+ SetPresented(isPresented, animated: false, notifyDelegate: false);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Called from handler when parent VC transitions size (rotation, multitasking).
+ /// </summary>
+ internal void OnParentViewWillTransitionToSize(CGSize toSize)
+ {
+
+ if (!OperatingSystem.IsMacCatalyst())
+ {
+ bool shouldSplit = ShouldShowSplitMode;
+ if (FlyoutOverlapsDetailsInPopoverMode)
+ {
+ // notifyDelegate: false — rotation is platform-initiated.
+ // Writing back IsPresented during rotation can throw InvalidOperationException
+ // when ShouldShowSplitMode is still in transition.
+ SetPresented(shouldSplit, animated: true, notifyDelegate: false);
+ }
+ else if (!shouldSplit && _isPresented)
+ {
+ // iPhone rotation: notify delegate so virtual view stays in sync.
+ SetPresented(false, animated: true, notifyDelegate: true);
+ }
+ }
+
+ NotifyLeftBarButtonNeedsUpdate();
+ }
+
+ /// <summary>
+ /// Called from container VC's ViewDidAppear.
+ /// </summary>
+ internal void OnViewDidAppear()
+ {
+ if (_delegateRef.TryGetTarget(out var del))
+ {
+ del.OnViewDidAppear();
+ }
+ }
+
+ /// <summary>
+ /// Called from container VC's ViewWillDisappear.
+ /// </summary>
+ internal void OnViewWillDisappear()
+ {
+ if (_delegateRef.TryGetTarget(out var del))
+ {
+ del.OnViewWillDisappear();
+ }
+ }
+
+
+ internal void SetFlyoutViewController(UIViewController? flyoutVC)
+ {
+ if (_parentVCRef is null || !_parentVCRef.TryGetTarget(out var parentVC))
+ {
+ return;
+ }
+
+ if (_flyoutVC is not null)
+ {
+ _flyoutVC.WillMoveToParentViewController(null);
+ _flyoutVC.View?.RemoveFromSuperview();
+ _flyoutVC.RemoveFromParentViewController();
+ }
+
+ _flyoutVC = flyoutVC;
+
+ if (_flyoutVC is not null && _flyoutContainerView is not null)
+ {
+ parentVC.AddChildViewController(_flyoutVC);
+ if (_flyoutVC.View is not null)
+ {
+ _flyoutContainerView.AddSubview(_flyoutVC.View);
+ _flyoutVC.View.Frame = _flyoutContainerView.Bounds;
+ }
+ _flyoutVC.DidMoveToParentViewController(parentVC);
+ }
+
+ NotifyLeftBarButtonNeedsUpdate();
+ }
+
+ internal void SetDetailViewController(UIViewController? detailVC)
+ {
+ if (_parentVCRef is null || !_parentVCRef.TryGetTarget(out var parentVC))
+ {
+ return;
+ }
+
+ if (_detailVC is not null)
+ {
+ _detailVC.WillMoveToParentViewController(null);
+ _detailVC.View?.RemoveFromSuperview();
+ _detailVC.RemoveFromParentViewController();
+ }
+
+ _detailVC = detailVC;
+
+ if (_detailVC is not null && _detailContainerView is not null)
+ {
+ parentVC.AddChildViewController(_detailVC);
+ if (_detailVC.View is not null)
+ {
+ _detailContainerView.AddSubview(_detailVC.View);
+ _detailVC.View.Frame = _detailContainerView.Bounds;
+ }
+ _detailVC.DidMoveToParentViewController(parentVC);
+ }
+
+ // Detail drives status bar/home-indicator preferences, so invalidate both on swap.
+ parentVC.SetNeedsStatusBarAppearanceUpdate();
+ parentVC.SetNeedsUpdateOfHomeIndicatorAutoHidden();
+
+ // A newly-attached Detail VC (and its UINavigationBar, if any) needs the
+ // current flow direction applied — it won't have picked it up otherwise.
+ ApplySemanticContentAttribute();
+
+ ToggleAccessibilityElementsHidden();
+ NotifyLeftBarButtonNeedsUpdate();
+ }
+
+
+ internal void UpdateIsPresented(bool isPresented, bool animated)
+ {
+ if (_isPresented == isPresented)
+ {
+ UpdateClickOffView();
+ return;
+ }
+
+ // Cannot open when Disabled
+ if (isPresented && _flyoutBehavior == FlyoutBehavior.Disabled)
+ {
+ return;
+ }
+
+ // Cannot close when Locked (Split) or in split mode
+ if (!isPresented && (_flyoutBehavior == FlyoutBehavior.Locked || ShouldShowSplitMode))
+ {
+ return;
+ }
+
+ SetPresented(isPresented, animated, notifyDelegate: false);
+ }
+
+ internal void UpdateFlyoutBehavior(FlyoutBehavior behavior)
+ {
+ var previousBehavior = _flyoutBehavior;
+ _flyoutBehavior = behavior;
+
+ // Before initial layout, just store the behavior.
+ if (!_initialLayoutFinished)
+ {
+ return;
+ }
+
+ bool shouldPresent = ShouldShowSplitMode;
+ if (behavior == FlyoutBehavior.Flyout || behavior == FlyoutBehavior.Disabled)
+ {
+ shouldPresent = false;
+ }
+ else if (behavior == FlyoutBehavior.Locked)
+ {
+ shouldPresent = true; // Locked = always presented (even on iPhone)
+ }
+
+ bool stateChanged = shouldPresent != _isPresented;
+
+ if (stateChanged)
+ {
+ // Notify delegate so VirtualView.IsPresented and IsPresentedChanged stay in sync.
+ // Safe because the mapper fires after ShouldShowSplitMode has settled;
+ // the guard in OnPresentedChangedByGesture handles any remaining edge cases.
+ SetPresented(shouldPresent, animated: true, notifyDelegate: true);
+ }
+ else
+ {
+ LayoutPanes(animated: true);
+ UpdateClickOffView();
+ }
+
+ // Only update the bar button when behavior or presented state actually changed.
+ // Skipping redundant calls prevents excessive ShouldShowToolbarButton invocations
+ // when the same behavior is re-applied (e.g., repeated DisplayInfoChanged events).
+ if (previousBehavior != behavior || stateChanged)
+ {
+ NotifyLeftBarButtonNeedsUpdate();
+ }
+ }
+
+ internal void UpdateFlyoutWidth(double width)
+ {
+ _flyoutWidth = width;
+ if (_initialLayoutFinished)
+ {
+ LayoutPanes(animated: false);
+ }
+ }
+
+ internal void UpdateIsGestureEnabled(bool enabled)
+ {
+ _isGestureEnabled = enabled;
+ UpdatePanGesture();
+ }
+
+ internal void UpdateFlowDirection(FlowDirection direction)
+ {
+ bool directionChanged = _flowDirection != direction;
+ _flowDirection = direction;
+ ApplySemanticContentAttribute();
+
+ if (_initialLayoutFinished)
+ {
+ LayoutPanes(animated: false);
+
+ // Recreate the hamburger bar button on real flow-direction changes so it
+ // renders correctly. Initial setup is notified by SetDetailViewController /
+ // SetFlyoutViewController.
+ if (directionChanged)
+ {
+ NotifyLeftBarButtonNeedsUpdate();
+ }
+ }
+ }
+
+ /// <summary>
+ /// Mirrors the Detail's UINavigationController view and NavigationBar for RTL/LTR,
+ /// since neither inherits SemanticContentAttribute from its parent view.
+ /// </summary>
+ void ApplySemanticContentAttribute()
+ {
+ if (_detailVC is UINavigationController navController)
+ {
+ var semanticAttr = IsRTL
+ ? UISemanticContentAttribute.ForceRightToLeft
+ : UISemanticContentAttribute.ForceLeftToRight;
+
+ if (navController.View is not null)
+ {
+ navController.View.SemanticContentAttribute = semanticAttr;
+ }
+ navController.NavigationBar.SemanticContentAttribute = semanticAttr;
+ }
+ }
+
+ internal void UpdateApplyShadow(bool applyShadow)
+ {
+ _applyShadow = applyShadow;
+ }
+
+
+ internal void TearDown()
+ {
+ if (_tapGesture is not null)
+ {
+ _clickOffView?.RemoveGestureRecognizer(_tapGesture);
+ _tapGesture.Dispose();
+ _tapGesture = null;
+ }
+
+ if (_panGesture is not null)
+ {
+ ParentView?.RemoveGestureRecognizer(_panGesture);
+ _panGesture.Dispose();
+ _panGesture = null;
+ }
+
+ _clickOffView?.RemoveFromSuperview();
+ _clickOffView?.Dispose();
+ _clickOffView = null;
+
+ // Remove child VCs via containment API
+ SetFlyoutViewController(null);
+ SetDetailViewController(null);
+
+ _flyoutContainerView?.RemoveFromSuperview();
+ _flyoutContainerView?.Dispose();
+ _flyoutContainerView = null;
+
+ _detailContainerView?.RemoveFromSuperview();
+ _detailContainerView?.Dispose();
+ _detailContainerView = null;
+ }
+
+
+ void LayoutPanes(bool animated)
+ {
+ var parentView = ParentView;
+ if (parentView is null || _flyoutContainerView is null || _detailContainerView is null)
+ {
+ return;
+ }
+
+ var frame = parentView.Bounds;
+
+ // Apply safe area insets when the page has opted in (IgnoreSafeArea = false).
+ // By default on iOS, IgnoreSafeArea = true so this is skipped.
+ bool ignoreSafeArea = _delegateRef.TryGetTarget(out var safeAreaDel) && safeAreaDel.GetIgnoreSafeArea();
+ if (OperatingSystem.IsIOSVersionAtLeast(11) && !ignoreSafeArea)
+ {
+ var safeAreaTop = parentView.SafeAreaInsets.Top;
+ if (safeAreaTop > 0)
+ {
+ frame.Y = safeAreaTop;
+ frame.Height -= safeAreaTop;
+ }
+ }
+
+ var flyoutFrame = frame;
+ nfloat opacity = 1;
+
+ // Calculate flyout width
+ if (FlyoutOverlapsDetailsInPopoverMode)
+ {
+ flyoutFrame.Width = GetFlyoutWidth(frame, forOverlap: true);
+ }
+ else
+ {
+ flyoutFrame.Width = GetFlyoutWidth(frame, forOverlap: false);
+ }
+
+ // RTL: flyout on right side (phone mode only)
+ if (IsRTL && !FlyoutOverlapsDetailsInPopoverMode)
+ {
+ flyoutFrame.X = (int)(frame.Width - flyoutFrame.Width);
+ }
+
+ // Calculate detail frame
+ var detailFrame = frame;
+ if (_isPresented)
+ {
+ if (!FlyoutOverlapsDetailsInPopoverMode || ShouldShowSplitMode)
+ {
+ if (IsRTL && ShouldShowSplitMode)
+ {
+ detailFrame.X = 0;
+ }
+ else
+ {
+ detailFrame.X += flyoutFrame.Width;
+ }
+ }
+
+ if (FlyoutOverlapsDetailsInPopoverMode && ShouldShowSplitMode)
+ {
+ detailFrame.Width -= flyoutFrame.Width;
+ }
+
+ if (_applyShadow)
+ {
+ opacity = 0.5f;
+ }
+ }
+
+ // RTL detail offset (phone mode)
+ if (IsRTL && !FlyoutOverlapsDetailsInPopoverMode)
+ {
+ detailFrame.X = detailFrame.X * -1;
+ }
+
+ // Animate or set detail frame
+ var detailChildView = _detailVC?.View;
+ if (animated && !FlyoutOverlapsDetailsInPopoverMode)
+ {
+ UIView.Animate(0.250, 0, UIViewAnimationOptions.CurveEaseOut, () =>
+ {
+ _detailContainerView.Frame = detailFrame;
+ if (detailChildView is not null)
+ {
+ detailChildView.Layer.Opacity = (float)opacity;
+ }
+ }, () => { });
+ }
+ else
+ {
+ _detailContainerView.Frame = detailFrame;
+ if (detailChildView is not null)
+ {
+ detailChildView.Layer.Opacity = (float)opacity;
+ }
+ }
+
+ // Calculate flyout frame for overlap mode (iPad popover)
+ if (FlyoutOverlapsDetailsInPopoverMode)
+ {
+ if (!_isPresented)
+ {
+ if (!IsRTL)
+ {
+ flyoutFrame.X -= flyoutFrame.Width;
+ }
+ else
+ {
+ flyoutFrame.X = frame.Width;
+ }
+ }
+ else if (IsRTL)
+ {
+ if (ShouldShowSplitMode)
+ {
+ flyoutFrame.X = detailFrame.Width;
+ }
+ else
+ {
+ flyoutFrame.X = frame.Width - flyoutFrame.Width;
+ }
+ }
+ }
+
+ // Animate or set flyout frame
+ if (animated && FlyoutOverlapsDetailsInPopoverMode)
+ {
+ UIView.Animate(0.250, 0, UIViewAnimationOptions.CurveEaseOut, () =>
+ {
+ _flyoutContainerView.Frame = flyoutFrame;
+ if (detailChildView is not null)
+ {
+ detailChildView.Layer.Opacity = (float)opacity;
+ }
+ }, () => { });
+ }
+ else
+ {
+ _flyoutContainerView.Frame = flyoutFrame;
+ }
+
+ // Resize child VC views to fill containers
+ ResizeChildToContainer(_flyoutVC, _flyoutContainerView);
+ ResizeChildToContainer(_detailVC, _detailContainerView);
+
+ // Notify delegate of bounds
+ NotifyLayoutBoundsChanged(flyoutFrame, detailFrame, frame);
+
+ if (_isPresented)
+ {
+ UpdateClickOffViewFrame();
+ }
+ }
+
+ static void ResizeChildToContainer(UIViewController? childVC, UIView containerView)
+ {
+ if (childVC?.View is not null)
+ {
+ childVC.View.Frame = containerView.Bounds;
+ }
+ }
+
+ nfloat GetFlyoutWidth(CGRect containerFrame, bool forOverlap)
+ {
+ if (_flyoutWidth > 0)
+ {
+ return (nfloat)_flyoutWidth;
+ }
+
+ if (forOverlap)
+ {
+ return 320;
+ }
+
+ // Phone default: 80% of the shorter dimension, truncated to int.
+ return (nfloat)(int)(Math.Min(containerFrame.Width, containerFrame.Height) * 0.8);
+ }
+
+
+ void SetPresented(bool value, bool animated, bool notifyDelegate)
+ {
+ if (_isPresented == value && _initialLayoutFinished)
+ {
+ UpdateClickOffView();
+ return;
+ }
+
+ _isPresented = value;
+ LayoutPanes(animated);
+ UpdateClickOffView();
+ ToggleAccessibilityElementsHidden();
+
+ if (notifyDelegate)
+ {
+ if (_delegateRef.TryGetTarget(out var del))
+ {
+ del.OnPresentedChangedByGesture(value);
+ }
+ }
+ }
+
+
+ void UpdateClickOffView()
+ {
+ if (_clickOffView is null)
+ {
+ return;
+ }
+
+ if (FlyoutOverlapsDetailsInPopoverMode && ShouldShowSplitMode)
+ {
+ RemoveClickOffView();
+ return;
+ }
+
+ if (_isPresented)
+ {
+ AddClickOffView();
+ }
+ else
+ {
+ RemoveClickOffView();
+ }
+ }
+
+ void AddClickOffView()
+ {
+ var parentView = ParentView;
+ if (_clickOffView is null || parentView is null)
+ {
+ return;
+ }
+
+ if (_clickOffView.Superview == parentView)
+ {
+ return;
+ }
+
+ parentView.AddSubview(_clickOffView);
+ UpdateClickOffViewFrame();
+ }
+
+ void UpdateClickOffViewFrame()
+ {
+ if (_clickOffView is null || _flyoutContainerView is null || _detailContainerView is null)
+ {
+ return;
+ }
+
+ if (FlyoutOverlapsDetailsInPopoverMode)
+ {
+ var detailsFrame = _detailContainerView.Frame;
+ var flyoutWidth = _flyoutContainerView.Frame.Width;
+ var clickOffX = flyoutWidth;
+
+ if (IsRTL)
+ {
+ clickOffX = 0;
+ }
+
+ _clickOffView.Frame = new CGRect(
+ clickOffX,
+ detailsFrame.Y,
+ detailsFrame.Width - flyoutWidth,
+ detailsFrame.Height);
+ }
+ else
+ {
+ _clickOffView.Frame = _detailContainerView.Frame;
+ }
+ }
+
+ void RemoveClickOffView()
+ {
+ _clickOffView?.RemoveFromSuperview();
+ }
+
+
+ void PackContainers(UIView parentView)
+ {
+ if (_flyoutContainerView is null || _detailContainerView is null)
+ {
+ return;
+ }
+
+ if (!FlyoutOverlapsDetailsInPopoverMode)
+ {
+ // Phone: flyout behind, detail on top
+ parentView.AddSubview(_flyoutContainerView);
+ parentView.AddSubview(_detailContainerView);
+ }
+ else
+ {
+ // iPad: detail behind, flyout on top
+ parentView.AddSubview(_detailContainerView);
+ parentView.AddSubview(_flyoutContainerView);
+ }
+ }
+
+
+ void SetupTapGesture()
+ {
+ if (_clickOffView is null)
+ {
+ return;
+ }
+
+ _tapGesture = new UITapGestureRecognizer(() =>
+ {
+ SetPresented(false, animated: true, notifyDelegate: true);
+ });
+
+ if (FlyoutOverlapsDetailsInPopoverMode)
+ {
+ _tapGesture.ShouldReceiveTouch = (_, _) =>
+ !ShouldShowSplitMode && _isPresented;
+ }
+
+ _clickOffView.AddGestureRecognizer(_tapGesture);
+ }
+
+
+ void UpdatePanGesture()
+ {
+ var parentView = ParentView;
+ if (parentView is null)
+ {
+ return;
+ }
+
+ if (!_isGestureEnabled)
+ {
+ if (_panGesture is not null)
+ {
+ parentView.RemoveGestureRecognizer(_panGesture);
+ }
+ return;
+ }
+
+ if (_panGesture is not null)
+ {
+ parentView.AddGestureRecognizer(_panGesture);
+ return;
+ }
+
+ var center = new PointF();
+ _panGesture = new UIPanGestureRecognizer(g =>
+ {
+ int directionModifier = IsRTL ? -1 : 1;
+
+ switch (g.State)
+ {
+ case UIGestureRecognizerState.Began:
+ center = g.LocationInView(g.View);
+ break;
+
+ case UIGestureRecognizerState.Changed:
+ HandlePanChanged(g, center, directionModifier);
+ break;
+
+ case UIGestureRecognizerState.Ended:
+ HandlePanEnded(directionModifier);
+ break;
+ }
+ });
+
+ _panGesture.CancelsTouchesInView = false;
+ _panGesture.ShouldReceiveTouch = (_, t) =>
+ !(t.View is UISlider) &&
+ !IsSwipeView(t.View) &&
+ !ShouldShowSplitMode &&
+ _flyoutBehavior != FlyoutBehavior.Disabled;
+ _panGesture.MaximumNumberOfTouches = 2;
+
+ parentView.AddGestureRecognizer(_panGesture);
+ }
+
+ void HandlePanChanged(UIPanGestureRecognizer g, PointF center, int directionModifier)
+ {
+ if (_flyoutContainerView is null || _detailContainerView is null)
+ {
+ return;
+ }
+
+ var currentPosition = g.LocationInView(g.View);
+ var motion = (currentPosition.X - center.X) * directionModifier;
+
+ if (!FlyoutOverlapsDetailsInPopoverMode)
+ {
+ // Phone mode: move detail view
+ var targetFrame = _detailContainerView.Frame;
+ var flyoutWidth = _flyoutContainerView.Frame.Width;
+
+ if (_isPresented)
+ {
+ targetFrame.X = (nfloat)Math.Max(0, flyoutWidth + Math.Min(0, motion));
+ }
+ else
+ {
+ targetFrame.X = (nfloat)Math.Min(flyoutWidth, Math.Max(0, motion));
+ }
+
+ targetFrame.X = targetFrame.X * directionModifier;
+ ApplyShadowDuringGesture(targetFrame);
+ _detailContainerView.Frame = targetFrame;
+ }
+ else
+ {
+ // iPad popover mode: move flyout view
+ var targetFrame = _flyoutContainerView.Frame;
+ var flyoutWidth = _flyoutContainerView.Frame.Width;
+
+ if (_isPresented)
+ {
+ targetFrame.X = (nfloat)Math.Max(-flyoutWidth, Math.Min(0, motion));
+ }
+ else
+ {
+ targetFrame.X = (nfloat)Math.Min(0, Math.Max(0, motion) - flyoutWidth);
+ }
+
+ if (IsRTL)
+ {
+ var containerWidth = ParentView!.Bounds.Width;
+ targetFrame.X = (nfloat)(containerWidth - (flyoutWidth + targetFrame.X));
+ }
+
+ ApplyShadowDuringGesture(targetFrame);
+ _flyoutContainerView.Frame = targetFrame;
+ }
+ }
+
+ void HandlePanEnded(int directionModifier)
+ {
+ if (_flyoutContainerView is null || _detailContainerView is null)
+ {
+ return;
+ }
+
+ if (!FlyoutOverlapsDetailsInPopoverMode)
+ {
+ var detailFrame = _detailContainerView.Frame;
+ var flyoutWidth = _flyoutContainerView.Frame.Width;
+
+ if (_isPresented)
+ {
+ if (detailFrame.X * directionModifier < flyoutWidth * 0.75)
+ {
+ SetPresented(false, animated: true, notifyDelegate: true);
+ }
+ else
+ {
+ LayoutPanes(animated: true);
+ }
+ }
+ else
+ {
+ if (detailFrame.X * directionModifier > flyoutWidth * 0.25)
+ {
+ SetPresented(true, animated: true, notifyDelegate: true);
+ }
+ else
+ {
+ LayoutPanes(animated: true);
+ }
+ }
+ }
+ else
+ {
+ var flyoutFrame = _flyoutContainerView.Frame;
+ var flyoutOffsetX = flyoutFrame.X + flyoutFrame.Width;
+
+ if (IsRTL)
+ {
+ flyoutOffsetX = (nfloat)(ParentView!.Bounds.Width - flyoutFrame.X);
+ }
+
+ var flyoutWidth = flyoutFrame.Width;
+
+ if (_isPresented)
+ {
+ if (flyoutOffsetX < flyoutWidth * 0.75)
+ {
+ SetPresented(false, animated: true, notifyDelegate: true);
+ }
+ else
+ {
+ LayoutPanes(animated: true);
+ }
+ }
+ else
+ {
+ if (flyoutOffsetX > flyoutWidth * 0.25)
+ {
+ SetPresented(true, animated: true, notifyDelegate: true);
+ }
+ else
+ {
+ LayoutPanes(animated: true);
+ }
+ }
+ }
+ }
+
+ void ApplyShadowDuringGesture(CGRect targetFrame)
+ {
+ if (!_applyShadow || _flyoutContainerView is null)
+ {
+ return;
+ }
+
+ var detailChildView = _detailVC?.View;
+ if (detailChildView is null)
+ {
+ return;
+ }
+
+ var flyoutWidth = _flyoutContainerView.Frame.Width;
+ nfloat openProgress;
+
+ if (!FlyoutOverlapsDetailsInPopoverMode)
+ {
+ openProgress = !IsRTL
+ ? targetFrame.X / flyoutWidth
+ : (nfloat)((ParentView!.Bounds.Width - targetFrame.GetMaxX()) / flyoutWidth);
+ }
+ else
+ {
+ openProgress = !IsRTL
+ ? (targetFrame.X + flyoutWidth) / flyoutWidth
+ : (nfloat)((ParentView!.Bounds.Width - targetFrame.X) / flyoutWidth);
+ }
+
+ var opacity = (float)(0.5 + (0.5 * (1 - openProgress)));
+ detailChildView.Layer.Opacity = opacity;
+ }
+
+
+ void ToggleAccessibilityElementsHidden()
+ {
+ if (_flyoutContainerView is not null)
+ {
+ _flyoutContainerView.AccessibilityElementsHidden = !_isPresented;
+ }
+
+ if (_detailContainerView is not null)
+ {
+ // Only hide Detail when the Flyout is actually covering it, not in split mode.
+ _detailContainerView.AccessibilityElementsHidden = _isPresented && !ShouldShowSplitMode;
+ }
+ }
+
+
+ static bool IsSwipeView(UIView? view)
+ {
+ if (view is null)
+ {
+ return false;
+ }
+
+ if (view.Superview is MauiSwipeView)
+ {
+ return true;
+ }
+
+ return IsSwipeView(view.Superview);
+ }
+
+ void NotifyLayoutBoundsChanged(CGRect flyoutFrame, CGRect detailFrame, CGRect containerFrame)
+ {
+ if (!_delegateRef.TryGetTarget(out var del))
+ {
+ return;
+ }
+
+ var flyoutBounds = new Rect(flyoutFrame.X, 0, flyoutFrame.Width, flyoutFrame.Height);
+ var detailBounds = new Rect(detailFrame.X, 0, containerFrame.Width, containerFrame.Height);
+ del.OnLayoutBoundsChanged(flyoutBounds, detailBounds);
+ }
+
+ void NotifyLeftBarButtonNeedsUpdate()
+ {
+ if (_delegateRef.TryGetTarget(out var del))
+ {
+ del.OnLeftBarButtonNeedsUpdate();
+ }
+ }
+}
diff --git a/src/Core/src/Platform/iOS/FlyoutContainerManager/FlyoutContainerViewController.cs b/src/Core/src/Platform/iOS/FlyoutContainerManager/FlyoutContainerViewController.cs
new file mode 100644
index 0000000000..c8329c9382
--- /dev/null
+++ b/src/Core/src/Platform/iOS/FlyoutContainerManager/FlyoutContainerViewController.cs
@@ -0,0 +1,96 @@
+using System;
+using CoreGraphics;
+using UIKit;
+
+namespace Microsoft.Maui.Platform;
+
+/// <summary>
+/// A minimal UIViewController that forwards lifecycle calls to <see cref="FlyoutContainerManager"/>.
+/// The handler sets this as its ViewController so UIKit lifecycle events reach the manager.
+/// </summary>
+internal class FlyoutContainerViewController : UIViewController
+{
+ readonly WeakReference<FlyoutContainerManager> _managerRef;
+
+ internal FlyoutContainerViewController(FlyoutContainerManager manager)
+ {
+ _managerRef = new WeakReference<FlyoutContainerManager>(manager);
+ }
+
+ public override void ViewDidLoad()
+ {
+ base.ViewDidLoad();
+
+ // Set background so status bar area doesn't show black/clear behind the safe area offset
+ View!.BackgroundColor = UIColor.SystemBackground;
+
+ if (_managerRef.TryGetTarget(out var manager))
+ {
+ manager.SetupContainerViews(this);
+ }
+ }
+
+ public override void ViewDidAppear(bool animated)
+ {
+ base.ViewDidAppear(animated);
+
+ if (_managerRef.TryGetTarget(out var manager))
+ {
+ manager.OnViewDidAppear();
+ }
+ }
+
+ public override void ViewWillDisappear(bool animated)
+ {
+ base.ViewWillDisappear(animated);
+
+ if (_managerRef.TryGetTarget(out var manager))
+ {
+ manager.OnViewWillDisappear();
+ }
+ }
+
+ public override void ViewDidLayoutSubviews()
+ {
+ base.ViewDidLayoutSubviews();
+
+ if (_managerRef.TryGetTarget(out var manager))
+ {
+ manager.OnParentViewDidLayoutSubviews();
+ }
+ }
+
+ public override void ViewWillTransitionToSize(CGSize toSize, IUIViewControllerTransitionCoordinator coordinator)
+ {
+ base.ViewWillTransitionToSize(toSize, coordinator);
+
+ if (_managerRef.TryGetTarget(out var manager))
+ {
+ manager.OnParentViewWillTransitionToSize(toSize);
+ }
+ }
+
+ public override UIViewController? ChildViewControllerForStatusBarHidden()
+ {
+ return GetActiveDetailViewController() ?? base.ChildViewControllerForStatusBarHidden();
+ }
+
+#if !MACCATALYST
+ public override UIViewController? ChildViewControllerForStatusBarStyle()
+ {
+ return GetActiveDetailViewController() ?? base.ChildViewControllerForStatusBarStyle();
+ }
+#endif
+
+ public override UIViewController? ChildViewControllerForHomeIndicatorAutoHidden
+ {
+ get => GetActiveDetailViewController() ?? base.ChildViewControllerForHomeIndicatorAutoHidden;
+ }
+
+ // Always defer to the Detail VC, matching the legacy renderer — never the Flyout VC,
+ // and never just "whichever child was added last" (that can be the Flyout VC if it's
+ // re-added after Detail, silently breaking the visible page's status-bar/home-indicator
+ // preferences).
+ UIViewController? GetActiveDetailViewController() =>
+ _managerRef.TryGetTarget(out var manager) ? manager.ActiveDetailViewController : null;
+}
diff --git a/src/Core/src/Platform/iOS/FlyoutContainerManager/IFlyoutContainerDelegate.cs b/src/Core/src/Platform/iOS/FlyoutContainerManager/IFlyoutContainerDelegate.cs
new file mode 100644
index 0000000000..5b3f6c1fe8
--- /dev/null
+++ b/src/Core/src/Platform/iOS/FlyoutContainerManager/IFlyoutContainerDelegate.cs
@@ -0,0 +1,53 @@
+using Microsoft.Maui.Graphics;
+
+namespace Microsoft.Maui.Platform;
+
+/// <summary>
+/// Callback interface for <see cref="FlyoutContainerManager"/> to communicate
+/// state changes back to the handler layer without referencing Controls types.
+/// </summary>
+internal interface IFlyoutContainerDelegate
+{
+ /// <summary>
+ /// Called when a user gesture (pan or tap-to-close) changes the presented state.
+ /// The handler should write this back to the virtual view's IsPresented property.
+ /// </summary>
+ void OnPresentedChangedByGesture(bool isPresented);
+
+ /// <summary>
+ /// Called after layout completes, providing the computed bounds of each pane.
+ /// The handler should write these back to the virtual view for measure/arrange.
+ /// </summary>
+ void OnLayoutBoundsChanged(Rect flyoutBounds, Rect detailBounds);
+
+ /// <summary>
+ /// Called when the detail content changes or split mode toggles,
+ /// indicating the hamburger bar button item needs updating.
+ /// </summary>
+ void OnLeftBarButtonNeedsUpdate();
+
+ /// <summary>
+ /// Called when the container VC's view has appeared (ViewDidAppear).
+ /// Currently unused — the framework handles Appearing automatically.
+ /// </summary>
+ void OnViewDidAppear();
+
+ /// <summary>
+ /// Called when the container VC's view is about to disappear (ViewWillDisappear).
+ /// Currently unused — the framework handles Disappearing automatically.
+ /// </summary>
+ void OnViewWillDisappear();
+
+ /// <summary>
+ /// Returns the current IsPresented value from the virtual view.
+ /// Used during initial layout to read the developer's intended value.
+ /// </summary>
+ bool GetCurrentIsPresented();
+
+ /// <summary>
+ /// Returns whether the virtual view has opted out of safe area insets
+ /// (<see cref="ISafeAreaView.IgnoreSafeArea"/>). Used during layout to decide
+ /// whether the flyout/detail container frame should be inset from the safe area.
+ /// </summary>
+ bool GetIgnoreSafeArea();
+}
diff --git a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
index 31a694f9d1..ac8aa3da86 100644
--- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
@@ -51,3 +51,11 @@ static Microsoft.Maui.Handlers.SearchBarHandler.MapCursorPosition(Microsoft.Maui
static Microsoft.Maui.Handlers.SearchBarHandler.MapSelectionLength(Microsoft.Maui.Handlers.ISearchBarHandler! handler, Microsoft.Maui.ISearchBar! searchBar) -> void
static Microsoft.Maui.Platform.CollectionViewExtensions.UpdateIsEnabled(this UIKit.UICollectionView! collectionView, Microsoft.Maui.IView! view) -> void
static Microsoft.Maui.Platform.ButtonExtensions.UpdateBackground(this UIKit.UIButton! platformButton, Microsoft.Maui.Graphics.Paint? paint) -> void
+override Microsoft.Maui.Handlers.FlyoutViewHandler.ConnectHandler(UIKit.UIView! platformView) -> void
+override Microsoft.Maui.Handlers.FlyoutViewHandler.DisconnectHandler(UIKit.UIView! platformView) -> void
+static Microsoft.Maui.Handlers.FlyoutViewHandler.MapDetail(Microsoft.Maui.Handlers.IFlyoutViewHandler! handler, Microsoft.Maui.IFlyoutView! flyoutView) -> void
+static Microsoft.Maui.Handlers.FlyoutViewHandler.MapFlyout(Microsoft.Maui.Handlers.IFlyoutViewHandler! handler, Microsoft.Maui.IFlyoutView! flyoutView) -> void
+static Microsoft.Maui.Handlers.FlyoutViewHandler.MapFlyoutBehavior(Microsoft.Maui.Handlers.IFlyoutViewHandler! handler, Microsoft.Maui.IFlyoutView! flyoutView) -> void
+static Microsoft.Maui.Handlers.FlyoutViewHandler.MapFlyoutWidth(Microsoft.Maui.Handlers.IFlyoutViewHandler! handler, Microsoft.Maui.IFlyoutView! flyoutView) -> void
+static Microsoft.Maui.Handlers.FlyoutViewHandler.MapIsGestureEnabled(Microsoft.Maui.Handlers.IFlyoutViewHandler! handler, Microsoft.Maui.IFlyoutView! flyoutView) -> void
+static Microsoft.Maui.Handlers.FlyoutViewHandler.MapIsPresented(Microsoft.Maui.Handlers.IFlyoutViewHandler! handler, Microsoft.Maui.IFlyoutView! flyoutView) -> void
diff --git a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
index d4fec52c7e..962e1c576d 100644
--- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
+++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
@@ -48,3 +48,11 @@ static Microsoft.Maui.Handlers.SearchBarHandler.MapCursorPosition(Microsoft.Maui
static Microsoft.Maui.Handlers.SearchBarHandler.MapSelectionLength(Microsoft.Maui.Handlers.ISearchBarHandler! handler, Microsoft.Maui.ISearchBar! searchBar) -> void
static Microsoft.Maui.Platform.CollectionViewExtensions.UpdateIsEnabled(this UIKit.UICollectionView! collectionView, Microsoft.Maui.IView! view) -> void
static Microsoft.Maui.Platform.ButtonExtensions.UpdateBackground(this UIKit.UIButton! platformButton, Microsoft.Maui.Graphics.Paint? paint) -> void
+override Microsoft.Maui.Handler
... [truncated]The diff was truncated to fit GitHub's review body limit.
kubaflo
left a comment
There was a problem hiding this comment.
Could you please check the failed tests and the ai's suggestions?
5f055eb to
06b5f09
Compare
@kubaflo It looks like the Android, iOS, and Mac Catalyst UI test pipelines did not run for this PR in the latest CI due to a build error. Only the Windows pipeline ran, so I couldn't validate the reported failures in CI. I'll check the failures again in the next CI run. The gate phase also reported that Bugzilla31602Test failed with a timeout exception. I verified it locally, and the test passed on both iOS 18 and iOS 26. |
|
/azp run maui-pr-uitests , maui-pr-devicetests |
The AI summary did not report any new suggestions. It repeated the same suggestions from the previous summary, and none of the reported concerns are valid. |
|
Azure Pipelines: Successfully started running 2 pipeline(s). |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/Core/src/Platform/iOS/FlyoutContainerManager/FlyoutContainerManager.cs:95
- The detail container view is created with a hard-coded black background. This differs from the legacy renderer (which used an opaque light background) and can cause visible black flashes/incorrect background in light mode when the Detail view is transparent or during transitions. Prefer using a system background color to match the container VC and support light/dark mode correctly.
var parentView = parentVC.View!;
_flyoutContainerView = new UIView { ClipsToBounds = true };
_detailContainerView = new UIView { BackgroundColor = UIColor.Black, ClipsToBounds = true };
_clickOffView = new UIView { BackgroundColor = new UIColor(0, 0, 0, 0) };
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Description of Change
Replaces the monolithic
PhoneFlyoutPageRenderer(~850 lines, single class that IS aUIViewController) with a layeredFlyoutViewHandlerarchitecture for iOS and MacCatalyst. The handler is registered as the unconditional default — no feature flag or opt-in required.Issues Fixed
Fixes #33083
Motivation
The
PhoneFlyoutPageRendereris a single class that IS aUIViewController. It owns pan/tap gesture handling, split-vs-popover layout math, safe-area handling, shadow application, accessibility toggling, hamburger bar-button updates, and status-bar/home-indicator delegation — all in one file. This makes it:FlyoutPage(a Controls type) directly from platform code, and calls a static method onNavigationRenderer(SetFlyoutLeftBarButton) to update the hamburger icon — a cross-renderer dependencyNavigationPage(PR-36109) andTabbedPage(PR-36507) have already moved to handlers on iOS;FlyoutPagewas the remaining holdoutUIViewControllerand holds strong references to child controllers/gesture recognizers, requiring careful manual cleanup (MEM0002 risk)static WeakReferencefor the flyout icon/title subscription, so a secondFlyoutPageinstance can silently clobber the first's subscriptionWhat Changed
New Files (Core layer —
src/Core/)Platform/iOS/FlyoutContainerManager/FlyoutContainerManager.csNSObjectPlatform/iOS/FlyoutContainerManager/FlyoutContainerViewController.csUIViewControllerwrapper: forwardsViewDidLoad/ViewDidAppear/ViewWillDisappear/ViewDidLayoutSubviews/ViewWillTransitionToSizeto the manager; routes status-bar/home-indicator queries explicitly to the Detail VC viaActiveDetailViewControllerPlatform/iOS/FlyoutContainerManager/IFlyoutContainerDelegate.csOnPresentedChangedByGesture,OnLayoutBoundsChanged,OnLeftBarButtonNeedsUpdate,OnViewDidAppear,OnViewWillDisappear,GetCurrentIsPresented,GetIgnoreSafeAreaHandlers/FlyoutView/FlyoutViewHandler.iOS.csCreatePlatformView/ConnectHandler/DisconnectHandler, mapper method implementations (MapFlyout,MapDetail,MapIsPresented,MapFlyoutBehavior,MapFlyoutWidth,MapIsGestureEnabled),IFlyoutContainerDelegateimplementationModified Files (Core layer)
Handlers/FlyoutView/FlyoutViewHandler.csFlyoutLayoutMapper/Mappergate from#if ANDROID || WINDOWS || TIZENto unconditional (all platforms); addedinternal sealed record FlyoutViewHandlerControlsConfiguration(4-member:OnPresentedChangedByGesture,OnLayoutBoundsChanged,OnLeftBarButtonNeedsUpdate,OnHandlerDisconnected) + staticControlsConfigurationproperty, gated#if IOS || MACCATALYSTHandlers/FlyoutView/FlyoutViewHandler.Standard.csMapFlyout/MapDetail/MapIsPresented/MapFlyoutBehavior/MapFlyoutWidth/MapIsGestureEnabledstubs for non-iOS/Android/Windows/Tizen TFMsNew Files (Controls layer —
src/Controls/)Core/FlyoutPage/FlyoutPage.iOS.csOnPresentedChangedByGesture/OnLayoutBoundsChanged/OnLeftBarButtonNeedsUpdate/OnHandlerDisconnectedstatic callbacks; instance-scopedSubscribeToFlyoutPropertyChanges()/UnsubscribeFlyoutPropertyChanges()for the flyout icon/title (fixes the old renderer's static-subscription multi-instance bug);UpdateFlyoutLeftBarButton()— fully self-contained hamburger-button logic (icon load, resize-to-44pt, title fallback,AutomationId/SemanticProperties);MapApplyShadow/MapFlowDirectionmapper implementationsModified Files (Controls layer)
Core/FlyoutPage/FlyoutPage.Mapper.cs#if IOSto#if IOS || MACCATALYST; setsFlyoutViewHandler.ControlsConfigurationinRemapForControls(); appendsMapApplyShadow/MapFlowDirectionto the mapper;MapPrefersHomeIndicatorAutoHiddenProperty/MapPrefersPrefersStatusBarHiddenPropertychanged from recursive-lookinghandler.UpdateValue(...)calls to directvc.SetNeedsUpdateOfHomeIndicatorAutoHidden()/vc.SetNeedsStatusBarAppearanceUpdate()Core/Hosting/AppHostBuilderExtensions.cshandlersCollection.AddHandler(typeof(FlyoutPage), typeof(Handlers.Compatibility.PhoneFlyoutPageRenderer))→handlersCollection.AddHandler<FlyoutPage, FlyoutViewHandler>()for iOS/MacCatalyst (unconditional)PublicAPI Surface
Added to
PublicAPI.Unshipped.txt(net,net-ios,net-maccatalyst,netstandard,netstandard2.0):Architecture Overview
Handler Hierarchy
Key Design Decisions
FlyoutContainerManagerknows only UIKit (UIView,UIViewController, gestures, frames) — it has no reference toIFlyoutView,FlyoutPage, or any MAUI type.FlyoutViewHandleris the middleman that knowsIFlyoutViewonly, notFlyoutPage. Controls-specific behavior (hamburger button, icon loading,FlyoutPagewrite-back) lives entirely in the Controls layerFlyoutViewHandlerControlsConfigurationsealed recordOnPresentedChangedByGesture,OnLayoutBoundsChanged,OnLeftBarButtonNeedsUpdate,OnHandlerDisconnected) fills the Core ↔ Controls gap without a circular assembly reference — Controls sets it once inRemapForControls()FlyoutContainerManageris a plain C# class, notNSObjectUIViewControllersubclass; only holdsWeakReference<IFlyoutContainerDelegate>andWeakReference<UIViewController>FlyoutContainerViewControlleras a thin wrapperUIViewControlleris still required so UIKit lifecycle events (ViewDidLoad, rotation, layout) reach the manager — but it only holds aWeakReference<FlyoutContainerManager>, so it can't leak the manager eitherActiveDetailViewControllerfor status bar/home indicatorWeakReference<Page>? _subscribedFlyoutstatic WeakReference, so a secondFlyoutPageinstance (multi-window, modal FlyoutPage) would clobber the first instance's icon/title subscriptionnotifyDelegateparameter onSetPresentednotifyDelegate: false, avoidsInvalidOperationExceptionwhileShouldShowSplitModeis still settling) from user/behavior-initiated changes (gesture,FlyoutBehaviorchange —notifyDelegate: true, keepsIFlyoutView.IsPresented/IsPresentedChangedin sync)AppHostBuilderExtensions.cs.PhoneFlyoutPageRendererstill exists in the Compatibility layer for manual opt-out if neededIFlyoutContainerDelegateInterface (7 methods)OnPresentedChangedByGesture(bool isPresented)IFlyoutView.IsPresentedOnLayoutBoundsChanged(Rect flyoutBounds, Rect detailBounds)OnLeftBarButtonNeedsUpdate()OnViewDidAppear()Appearingautomatically)OnViewWillDisappear()Disappearingautomatically)GetCurrentIsPresented()IsPresented— read at first-layout time, afterFlyoutPage's internal validation has settledGetIgnoreSafeArea()ISafeAreaView.IgnoreSafeArea)Key Behaviors
min(width, height)unlessFlyoutWidthis setFlyoutBehavior.Locked) shows both panes side by sideIsGestureEnabledtoggles the pan recognizerFlowDirectionmapped from the effective/inherited flow direction (not the rawFlowDirection), flips Flyout anchor edge and pan direction modifierApplyShadow(iOS platform-specific) dims the Detail pane's opacity while the Flyout is open/draggingIgnoreSafeAreaisfalse(default on iOS istrue, matching legacy behavior)ActiveDetailViewController), invalidated viaSetNeedsStatusBarAppearanceUpdate()/SetNeedsUpdateOfHomeIndicatorAutoHidden()on Detail swapShouldShowSplitModewithout notifying the virtual view; iPhone closes the Flyout and notifies the virtual view if it was open and shouldn't splitUIColor.SystemBackgroundinstead of a hardcoded colorDisconnectHandlercalls_manager.TearDown()(gestures, child VCs, container views) before notifying Controls viaControlsConfiguration.OnHandlerDisconnected, which unsubscribes the per-instance flyout icon/titlePropertyChangedhandlerFeature Parity
Full parity with
PhoneFlyoutPageRendererincluding:FlyoutBehavior(Flyout/Locked/Disabled/Popover/SplitOnLandscape/SplitOnPortrait)IsGestureEnabledApplyShadow(iOS platform-specific)FlowDirectionAccessibilityElementsHiddentoggled on Flyout/Detail containers)Improvements Over the Renderer (not just parity)
FlyoutWidth_flyoutWidthpropertystatic WeakReference— 2ndFlyoutPageclobbers 1st's subscriptionWeakReference<Page>?— isolated per instanceUIViewControllersubclass — MEM0002 retain-cycle riskWeakReferenceonlyNavigationRenderer.SetFlyoutLeftBarButton()FlyoutPage.iOS.csActiveDetailViewController, always Detailnav.TopViewController— follows a pushed child pagenav.ViewControllers?.FirstOrDefault()— always the root VCOnElementPropertyChangedif/else chain checking 20+ namesInvalidOperationExceptionmid-rotationnotifyDelegate: falseon the iPad rotation branch avoids the raceLockedbehavior on iPhoneshouldPresent = trueunconditionally forLockedHandler as Default
The handler is registered as the unconditional default in
AppHostBuilderExtensions.cs— no feature flag or opt-in required.What Changes By Default
FlyoutPageusesFlyoutViewHandlerinstead ofPhoneFlyoutPageRendereron iOS/MacCatalystFlyoutPage.iOS.cs— no more cross-renderer static call intoNavigationRendererPropertyChangedsubscriptions are per-FlyoutPage-instance instead of static/globalHow to Opt Out
If an app needs to fall back to the renderer, register it manually in
MauiProgram.cs:PhoneFlyoutPageRendererremains in the Compatibility layer (src/Controls/src/Core/Compatibility/Handlers/FlyoutPage/iOS/PhoneFlyoutPageRenderer.cs) and continues to work.Migration Guidance for Custom Renderer Users
If you subclassed
PhoneFlyoutPageRendereror accessed its internals, this section maps the old patterns to the new handler architecture.Old → New Mapping
PhoneFlyoutPageRenderer(IS aUIViewController)FlyoutViewHandler(HAS aUIViewControllerviaFlyoutContainerManager/FlyoutContainerViewController)ViewHandler<IFlyoutView, UIView>, not a VCViewDidLayoutSubviewsFlyoutContainerViewController.ViewDidLayoutSubviews→manager.OnParentViewDidLayoutSubviews()ViewWillTransitionToSizeFlyoutContainerViewController.ViewWillTransitionToSize→manager.OnParentViewWillTransitionToSize(toSize)PropertyChangedswitch onFlyoutPageMapFlyout,MapDetail,MapIsPresented, etc.)SetValueFromRenderer(IsPresentedProperty, value)fp.IsPresented = isPresentedif (_isPresented == isPresented) return;) prevents the write-back feedback loop — no special "came from renderer" flag neededNavigationRenderer.SetFlyoutLeftBarButton(vc, fp)FlyoutPage.UpdateFlyoutLeftBarButton()ChildViewControllerForStatusBarHiddendefault (last child)FlyoutContainerViewController.GetActiveDetailViewController()Adapter Pattern for Custom Renderers
If you must preserve custom renderer logic, keep using the renderer:
Testing
The existing FlyoutPage device tests continue to run against PhoneFlyoutPageRenderer, so they validate the renderer rather than the new FlyoutViewHandler. As part of this PR, the device tests were not migrated to target the handler. They will be updated once the testing approach introduced in PRs 36109 and 36507 is finalized.