Skip to content

[Net11] [iOS/MacCatalyst] Migrate FlyoutPage to handler architecture - #36676

Merged
kubaflo merged 8 commits into
net11.0from
Net11.0-iOS-FlyoutView-Handler
Aug 4, 2026
Merged

[Net11] [iOS/MacCatalyst] Migrate FlyoutPage to handler architecture#36676
kubaflo merged 8 commits into
net11.0from
Net11.0-iOS-FlyoutView-Handler

Conversation

@Vignesh-SF3580

@Vignesh-SF3580 Vignesh-SF3580 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

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 a UIViewController) with a layered FlyoutViewHandler architecture for iOS and MacCatalyst. The handler is registered as the unconditional default — no feature flag or opt-in required.

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 — layout math, gesture handling, and Controls-specific bar-button logic are all interleaved in a single class
  • Tightly coupled — the renderer references FlyoutPage (a Controls type) directly from platform code, and calls a static method on NavigationRenderer (SetFlyoutLeftBarButton) to update the hamburger icon — a cross-renderer dependency
  • Inconsistent with handler architectureNavigationPage (PR-36109) and TabbedPage (PR-36507) have already moved to handlers on iOS; FlyoutPage was the remaining holdout
  • Prone to retain cycles — the renderer both IS the UIKit UIViewController and holds strong references to child controllers/gesture recognizers, requiring careful manual cleanup (MEM0002 risk)
  • Not multi-instance safe — uses a static WeakReference for the flyout icon/title subscription, so a second FlyoutPage instance can silently clobber the first's subscription

What Changed

New Files (Core layer — src/Core/)

File Purpose
Platform/iOS/FlyoutContainerManager/FlyoutContainerManager.cs Shared UIKit manager (952 lines): 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, accessibility toggling. Plain C# class — not an NSObject
Platform/iOS/FlyoutContainerManager/FlyoutContainerViewController.cs Minimal UIViewController wrapper: forwards ViewDidLoad/ViewDidAppear/ViewWillDisappear/ViewDidLayoutSubviews/ViewWillTransitionToSize to the manager; routes status-bar/home-indicator queries explicitly to the Detail VC via ActiveDetailViewController
Platform/iOS/FlyoutContainerManager/IFlyoutContainerDelegate.cs 7-method interface bridging Core ↔ handler: OnPresentedChangedByGesture, OnLayoutBoundsChanged, OnLeftBarButtonNeedsUpdate, OnViewDidAppear, OnViewWillDisappear, GetCurrentIsPresented, GetIgnoreSafeArea
Handlers/FlyoutView/FlyoutViewHandler.iOS.cs iOS handler body (148 lines): CreatePlatformView/ConnectHandler/DisconnectHandler, mapper method implementations (MapFlyout, MapDetail, MapIsPresented, MapFlyoutBehavior, MapFlyoutWidth, MapIsGestureEnabled), IFlyoutContainerDelegate implementation

Modified Files (Core layer)

File Change
Handlers/FlyoutView/FlyoutViewHandler.cs Opened the FlyoutLayoutMapper/Mapper gate from #if ANDROID || WINDOWS || TIZEN to unconditional (all platforms); added internal sealed record FlyoutViewHandlerControlsConfiguration (4-member: OnPresentedChangedByGesture, OnLayoutBoundsChanged, OnLeftBarButtonNeedsUpdate, OnHandlerDisconnected) + static ControlsConfiguration property, gated #if IOS || MACCATALYST
Handlers/FlyoutView/FlyoutViewHandler.Standard.cs Added no-op MapFlyout/MapDetail/MapIsPresented/MapFlyoutBehavior/MapFlyoutWidth/MapIsGestureEnabled stubs for non-iOS/Android/Windows/Tizen TFMs

New Files (Controls layer — src/Controls/)

File Purpose
Core/FlyoutPage/FlyoutPage.iOS.cs Controls-side bridge (242 lines): OnPresentedChangedByGesture/OnLayoutBoundsChanged/OnLeftBarButtonNeedsUpdate/OnHandlerDisconnected static callbacks; instance-scoped SubscribeToFlyoutPropertyChanges()/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/MapFlowDirection mapper implementations

Modified Files (Controls layer)

File Change
Core/FlyoutPage/FlyoutPage.Mapper.cs Gate widened from #if IOS to #if IOS || MACCATALYST; sets FlyoutViewHandler.ControlsConfiguration in RemapForControls(); appends MapApplyShadow/MapFlowDirection to the mapper; MapPrefersHomeIndicatorAutoHiddenProperty/MapPrefersPrefersStatusBarHiddenProperty changed from recursive-looking handler.UpdateValue(...) calls to direct vc.SetNeedsUpdateOfHomeIndicatorAutoHidden()/vc.SetNeedsStatusBarAppearanceUpdate()
Core/Hosting/AppHostBuilderExtensions.cs handlersCollection.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):

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

Architecture Overview

Handler Hierarchy

┌──────────────────────────────────────────────────────────┐
│                    CONTROLS LAYER                        │
│  FlyoutPage.Mapper.cs           → mappers + config setup │
│  FlyoutPage.iOS.cs              → mapper implementations │
│                                    + hamburger button    │
├──────────────────────────────────────────────────────────┤
│                     CORE LAYER (HANDLER)                 │
│  FlyoutViewHandler.iOS.cs       → mapper methods,        │
│                                    delegate impl         │
├──────────────────────────────────────────────────────────┤
│                     CORE LAYER (PLATFORM)                │
│  FlyoutContainerManager.cs      → gestures, layout,      │
│                                    rotation, animation   │
│  FlyoutContainerViewController  → lifecycle forwarding   │
│  IFlyoutContainerDelegate.cs    → 7-method interface     │
└──────────────────────────────────────────────────────────┘

Key Design Decisions

Decision Rationale
Three-layer split (Controls / Core Handler / Core Platform) FlyoutContainerManager knows only UIKit (UIView, UIViewController, gestures, frames) — it has no reference to IFlyoutView, FlyoutPage, or any MAUI type. FlyoutViewHandler is the middleman that knows IFlyoutView only, not FlyoutPage. Controls-specific behavior (hamburger button, icon loading, FlyoutPage write-back) lives entirely in the Controls layer
FlyoutViewHandlerControlsConfiguration sealed record 4-member record (OnPresentedChangedByGesture, OnLayoutBoundsChanged, OnLeftBarButtonNeedsUpdate, OnHandlerDisconnected) fills the Core ↔ Controls gap without a circular assembly reference — Controls sets it once in RemapForControls()
FlyoutContainerManager is a plain C# class, not NSObject Eliminates the MEM0002 retain-cycle risk the old renderer had as a UIViewController subclass; only holds WeakReference<IFlyoutContainerDelegate> and WeakReference<UIViewController>
FlyoutContainerViewController as a thin wrapper A minimal UIViewController is still required so UIKit lifecycle events (ViewDidLoad, rotation, layout) reach the manager — but it only holds a WeakReference<FlyoutContainerManager>, so it can't leak the manager either
Explicit ActiveDetailViewController for status bar/home indicator UIKit's default "whichever child VC was added last" is unsafe — if the Flyout VC is re-added after Detail, the wrong VC's preferences would apply. The container VC always asks the manager for the tracked Detail VC specifically, matching the legacy renderer's exact behavior
Instance-scoped WeakReference<Page>? _subscribedFlyout Fixes a real renderer bug: the old renderer used a static WeakReference, so a second FlyoutPage instance (multi-window, modal FlyoutPage) would clobber the first instance's icon/title subscription
notifyDelegate parameter on SetPresented Distinguishes platform-initiated state changes (rotation on iPad — notifyDelegate: false, avoids InvalidOperationException while ShouldShowSplitMode is still settling) from user/behavior-initiated changes (gesture, FlyoutBehavior change — notifyDelegate: true, keeps IFlyoutView.IsPresented/IsPresentedChanged in sync)
Handler is unconditional default No feature flag. Registered in AppHostBuilderExtensions.cs. PhoneFlyoutPageRenderer still exists in the Compatibility layer for manual opt-out if needed

IFlyoutContainerDelegate Interface (7 methods)

Method Purpose
OnPresentedChangedByGesture(bool isPresented) User pan/tap changed the presented state — handler writes it back to IFlyoutView.IsPresented
OnLayoutBoundsChanged(Rect flyoutBounds, Rect detailBounds) Layout completed — handler writes computed bounds back to the virtual view for measure/arrange
OnLeftBarButtonNeedsUpdate() Detail changed or split mode toggled — hamburger bar button needs updating
OnViewDidAppear() Container VC's view appeared (currently unused — framework handles Appearing automatically)
OnViewWillDisappear() Container VC's view about to disappear (currently unused — framework handles Disappearing automatically)
GetCurrentIsPresented() Returns the virtual view's current IsPresented — read at first-layout time, after FlyoutPage's internal validation has settled
GetIgnoreSafeArea() Returns whether the virtual view opted out of safe-area insets (ISafeAreaView.IgnoreSafeArea)

Key Behaviors

Behavior Implementation
iPhone layout Detail slides right to reveal the Flyout behind it; Flyout width = 80% of min(width, height) unless FlyoutWidth is set
iPad layout Flyout overlaps Detail (slides over from the left) in Popover mode; Split mode (FlyoutBehavior.Locked) shows both panes side by side
Gestures Pan gesture with 25%/75% velocity-snap thresholds; tap-to-close on a transparent click-off overlay; IsGestureEnabled toggles the pan recognizer
RTL FlowDirection mapped from the effective/inherited flow direction (not the raw FlowDirection), flips Flyout anchor edge and pan direction modifier
Shadow ApplyShadow (iOS platform-specific) dims the Detail pane's opacity while the Flyout is open/dragging
Safe area Applied only when IgnoreSafeArea is false (default on iOS is true, matching legacy behavior)
Status bar / Home indicator Always delegated to the tracked Detail VC (ActiveDetailViewController), invalidated via SetNeedsStatusBarAppearanceUpdate()/SetNeedsUpdateOfHomeIndicatorAutoHidden() on Detail swap
Rotation Skipped entirely on MacCatalyst (free window resizing); iPad recomputes ShouldShowSplitMode without notifying the virtual view; iPhone closes the Flyout and notifies the virtual view if it was open and shouldn't split
Dark mode Container view background uses UIColor.SystemBackground instead of a hardcoded color
Cleanup DisconnectHandler calls _manager.TearDown() (gestures, child VCs, container views) before notifying Controls via ControlsConfiguration.OnHandlerDisconnected, which unsubscribes the per-instance flyout icon/title PropertyChanged handler

Feature Parity

Full parity with PhoneFlyoutPageRenderer including:

  • Flyout open/close (programmatic + gesture)
  • Pan gesture with velocity-based snap
  • Tap-to-close overlay
  • FlyoutBehavior (Flyout / Locked / Disabled / Popover / SplitOnLandscape / SplitOnPortrait)
  • IsGestureEnabled
  • ApplyShadow (iOS platform-specific)
  • RTL / FlowDirection
  • Safe-area handling (notch devices)
  • Left bar button (hamburger icon or title-text fallback)
  • iPad split mode (landscape/portrait per behavior)
  • Status bar style forwarding (via containment)
  • Home indicator forwarding (via containment)
  • Rotation/size-transition handling
  • Accessibility (AccessibilityElementsHidden toggled on Flyout/Detail containers)

Improvements Over the Renderer (not just parity)

# Area Renderer Handler
1 Custom FlyoutWidth Not supported Supported via _flyoutWidth property
2 Multi-instance safety static WeakReference — 2nd FlyoutPage clobbers 1st's subscription Instance-scoped WeakReference<Page>? — isolated per instance
3 Memory safety UIViewController subclass — MEM0002 retain-cycle risk Plain C# class + WeakReference only
4 Hamburger button coupling Static call to NavigationRenderer.SetFlyoutLeftBarButton() Fully self-contained in FlyoutPage.iOS.cs
5 Status bar/home indicator routing UIKit "last child added" default — could return the wrong VC Explicit ActiveDetailViewController, always Detail
6 Nav bar button target nav.TopViewController — follows a pushed child page nav.ViewControllers?.FirstOrDefault() — always the root VC
7 Property dispatch OnElementPropertyChanged if/else chain checking 20+ names Mapper routes each property directly, no wasted calls
8 Rotation write-back Could throw InvalidOperationException mid-rotation notifyDelegate: false on the iPad rotation branch avoids the race
9 Locked behavior on iPhone Complex conditional could fail to present shouldPresent = true unconditionally for Locked

Handler as Default

The handler is registered as the unconditional default in AppHostBuilderExtensions.cs — no feature flag or opt-in required.

What Changes By Default

  • FlyoutPage uses FlyoutViewHandler instead of PhoneFlyoutPageRenderer on iOS/MacCatalyst
  • Hamburger bar-button logic is now self-contained in FlyoutPage.iOS.cs — no more cross-renderer static call into NavigationRenderer
  • Status bar/home indicator preferences are always explicitly sourced from the Detail VC
  • Flyout icon/title PropertyChanged subscriptions are per-FlyoutPage-instance instead of static/global

How to Opt Out

If an app needs to fall back to the renderer, register it manually in MauiProgram.cs:

builder.ConfigureMauiHandlers(handlers =>
{
    handlers.AddHandler<FlyoutPage, PhoneFlyoutPageRenderer>();
});

PhoneFlyoutPageRenderer remains 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 PhoneFlyoutPageRenderer or accessed its internals, this section maps the old patterns to the new handler architecture.

Old → New Mapping

Old (Renderer) New (Handler) Notes
PhoneFlyoutPageRenderer (IS a UIViewController) FlyoutViewHandler (HAS a UIViewController via FlyoutContainerManager/FlyoutContainerViewController) Handler is a ViewHandler<IFlyoutView, UIView>, not a VC
Override ViewDidLayoutSubviews FlyoutContainerViewController.ViewDidLayoutSubviewsmanager.OnParentViewDidLayoutSubviews() Forwarded through the manager, not overridable directly
Override ViewWillTransitionToSize FlyoutContainerViewController.ViewWillTransitionToSizemanager.OnParentViewWillTransitionToSize(toSize) Same pattern
PropertyChanged switch on FlyoutPage Mapper methods (MapFlyout, MapDetail, MapIsPresented, etc.) Declarative — one method per property
SetValueFromRenderer(IsPresentedProperty, value) Direct fp.IsPresented = isPresented The mapper's own guard (if (_isPresented == isPresented) return;) prevents the write-back feedback loop — no special "came from renderer" flag needed
NavigationRenderer.SetFlyoutLeftBarButton(vc, fp) FlyoutPage.UpdateFlyoutLeftBarButton() Fully self-contained, no cross-renderer static call
ChildViewControllerForStatusBarHidden default (last child) FlyoutContainerViewController.GetActiveDetailViewController() Explicitly always the Detail VC

Adapter Pattern for Custom Renderers

If you must preserve custom renderer logic, keep using the renderer:

// Keep using PhoneFlyoutPageRenderer with your customizations
builder.ConfigureMauiHandlers(handlers =>
{
    handlers.AddHandler<FlyoutPage, MyCustomFlyoutPageRenderer>();
});

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.

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

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

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

Or

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

@azure-pipelines

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

@dotnet-policy-service dotnet-policy-service Bot added the partner/syncfusion Issues / PR's with Syncfusion collaboration label Jul 20, 2026
@Vignesh-SF3580 Vignesh-SF3580 added the community ✨ Community Contribution label Jul 20, 2026
@sheiksyedm

Copy link
Copy Markdown
Contributor

/azp run maui-pr-uitests , maui-pr-devicetests

@azure-pipelines

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

@vishnumenon2684

Copy link
Copy Markdown
Contributor

/azp run maui-pr-uitests , maui-pr-devicetests

@azure-pipelines

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

@MauiBot

This comment has been minimized.

@MauiBot MauiBot added the s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) label Jul 26, 2026
MauiBot

This comment was marked as outdated.

@Vignesh-SF3580
Vignesh-SF3580 force-pushed the Net11.0-iOS-FlyoutView-Handler branch from d698e7f to 15b831d Compare July 27, 2026 10:27
@vishnumenon2684

Copy link
Copy Markdown
Contributor

/azp run maui-pr-uitests , maui-pr-devicetests

@azure-pipelines

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

@vishnumenon2684

Copy link
Copy Markdown
Contributor

/azp run maui-pr-uitests , maui-pr-devicetests

@azure-pipelines

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

@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 3, 2026
@MauiBot MauiBot added s/agent-fix-win AI found a better alternative fix than the PR and removed s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates labels Aug 3, 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.

AI Review Summary

@Vignesh-SF3580 — new AI review results are available based on this last commit: 5f055eb.

Gate No Tests Confidence Low Platform iOS


🗂️ 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): Bugzilla31602Test times 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-40DisconnectHandler nulls _manager and _containerVC, but ConnectHandler does 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 without IsPresented changing, detail accessibility hidden state is not recomputed.
  • src/Controls/src/Core/FlyoutPage/FlyoutPage.iOS.cs:223-226 — the replacement toolbar button applies SemanticProperties only and drops existing AutomationProperties.Name/HelpText fallback behavior.

Fix Candidates

# Source Approach Test Result Files Changed Notes
PR PR #36676 Unconditionally migrate iOS/MacCatalyst FlyoutPage to new handler architecture ⚠️ SKIPPED (Gate; no tests detected) 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.cs registers the new handler unconditionally for iOS/MacCatalyst.
  • Startup impact: yes — every iOS/MacCatalyst FlyoutPage now uses this handler.
  • Static/shared state: yes — FlyoutViewHandler.ControlsConfiguration is static, though populated from Controls startup mapping.

CI Status

  • Required-check result: fail / undetermined. gh pr checks --required was unavailable due missing auth; public check-run API for head 5f055eb shows failing maui-pr, maui-pr-devicetests, and maui-pr-uitests checks.
  • Classification: undetermined from available public data.
  • Action taken: invoked azdo-build-investigator; ci-analysis skill 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, _manager is 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 VirtualView and child VCs, but toolbar icon load still runs asynchronously against captured targetVC; 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. ⚠️ SKIPPED (Gate; no tests detected) 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. ⚠️ Not validated in recorded gate; PR gate was skipped/no tests detected. 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. ⚠️ SKIPPED — no tests detected in PR gate. ❌ 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.

@MauiBot MauiBot removed the s/agent-review-in-progress AI review is currently running for this PR label Aug 3, 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 check the failed tests and the ai's suggestions?

Copilot AI review requested due to automatic review settings August 4, 2026 06:29
@Vignesh-SF3580
Vignesh-SF3580 force-pushed the Net11.0-iOS-FlyoutView-Handler branch from 5f055eb to 06b5f09 Compare August 4, 2026 06:29
@Vignesh-SF3580

Copy link
Copy Markdown
Contributor Author

Could you please check the failed tests and the ai's suggestions?

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

@sheiksyedm

Copy link
Copy Markdown
Contributor

/azp run maui-pr-uitests , maui-pr-devicetests

@Vignesh-SF3580

Copy link
Copy Markdown
Contributor Author

AI Review Summary

@Vignesh-SF3580 — new AI review results are available based on this last commit: 5f055eb.

Gate No Tests Confidence Low Platform iOS

🗂️ 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): Bugzilla31602Test times 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-40DisconnectHandler nulls _manager and _containerVC, but ConnectHandler does 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 without IsPresented changing, detail accessibility hidden state is not recomputed.
  • src/Controls/src/Core/FlyoutPage/FlyoutPage.iOS.cs:223-226 — the replacement toolbar button applies SemanticProperties only and drops existing AutomationProperties.Name/HelpText fallback behavior.

Fix Candidates

Source Approach Test Result Files Changed Notes

PR PR #36676 Unconditionally migrate iOS/MacCatalyst FlyoutPage to new handler architecture ⚠️ SKIPPED (Gate; no tests detected) 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.cs registers the new handler unconditionally for iOS/MacCatalyst.
  • Startup impact: yes — every iOS/MacCatalyst FlyoutPage now uses this handler.
  • Static/shared state: yes — FlyoutViewHandler.ControlsConfiguration is static, though populated from Controls startup mapping.

CI Status

  • Required-check result: fail / undetermined. gh pr checks --required was unavailable due missing auth; public check-run API for head 5f055eb shows failing maui-pr, maui-pr-devicetests, and maui-pr-uitests checks.
  • Classification: undetermined from available public data.
  • Action taken: invoked azdo-build-investigator; ci-analysis skill 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, _manager is 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 VirtualView and child VCs, but toolbar icon load still runs asynchronously against captured targetVC; 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. ⚠️ SKIPPED (Gate; no tests detected) 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. ⚠️ Not validated in recorded gate; PR gate was skipped/no tests detected. 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. ⚠️ SKIPPED — no tests detected in PR gate. ❌ 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)

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.
1. Reconnect leaves handler inactive
This is not reproducible under the normal handler lifecycle. When the handler reconnects, CreatePlatformView() recreates _manager and _containerVC before ConnectHandler() is called. The reported inactive handler scenario cannot occur, so no fix is needed.
2. Accessibility hidden state after FlyoutBehavior changes
This behavior already exists in the legacy renderer and is not introduced by this PR. Since the goal of this PR is to maintain parity with the renderer, no change was made.
3. Flyout button AutomationProperties fallback
The new handler uses SemanticProperties, which is the recommended API. AutomationProperties.Name and HelpText are obsolete, so this is an intentional choice rather than a bug.
4. Bugzilla31602Test UI test failure
Bugzilla31602Test passed locally on both iOS 18 and iOS 26. Also, the Android, iOS, and Mac Catalyst CI pipelines did not run for this commit, so the reported failure could not be validated in CI.

@azure-pipelines

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

Copilot AI 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.

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

Comment thread src/Controls/src/Core/FlyoutPage/FlyoutPage.iOS.cs
@kubaflo
kubaflo merged commit b6bcbff into net11.0 Aug 4, 2026
134 of 143 checks passed
@kubaflo
kubaflo deleted the Net11.0-iOS-FlyoutView-Handler branch August 4, 2026 12:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-controls-flyoutpage FlyoutPage community ✨ Community Contribution partner/syncfusion Issues / PR's with Syncfusion collaboration platform/ios platform/macos macOS / Mac Catalyst 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-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.

7 participants