Skip to content

[net11.0] Add public Shell flyout item template contract for external backends - #37862

Open
Redth wants to merge 3 commits into
net11.0from
redth-shell-flyout-template-api
Open

[net11.0] Add public Shell flyout item template contract for external backends#37862
Redth wants to merge 3 commits into
net11.0from
redth-shell-flyout-template-api

Conversation

@Redth

@Redth Redth commented Aug 26, 2026

Copy link
Copy Markdown
Member

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

Out-of-tree Shell backends need to decide whether a flyout item uses an application-supplied DataTemplate or the backend's own platform-native presentation. That decision requires resolving the object that actually carries the template, which is not always the flyout item itself:

  • A MenuItem added to Shell.Items is wrapped in the internal MenuShellItem. When Shell.MenuItemTemplate is set on the MenuItem, the wrapper never has the property set — the template lives on MenuShellItem.MenuItem.
  • A MenuItem in ShellContent.MenuItems appears in the flyout grouping directly, while the template may be set on its parent ShellContent.

Both branches lived in the internal Shell.GetBindableObjectWithFlyoutItemTemplate. An external backend can only inspect the flyout item it is handed, so it concludes no template is set and incorrectly falls back to its platform default. This is a concrete blocker for the Tizen backend now maintained out-of-tree (Redth/Maui.Tizen, Wave C).

New API

One result-oriented, nullable-annotated method:

public static DataTemplate? ResolveFlyoutItemTemplate(Shell? shell, BindableObject flyoutItem)

Returns the final application-defined template, or null meaning use your own platform-native default. MenuShellItem and the paired-object lookup stay internal, and IShellController is unchanged (no breaking interface addition).

Precedence matches the built-in backends exactly: a template set on the item wins over one set on the Shell; MenuItemTemplateProperty is used for menu items and ItemTemplateProperty for everything else.

View CreateFlyoutItemView(Shell shell, BindableObject flyoutItem)
{
    var template = Shell.ResolveFlyoutItemTemplate(shell, flyoutItem);

    if (template is null)
        return CreatePlatformDefaultFlyoutItemView(flyoutItem);

    var view = (View)template.SelectDataTemplate(flyoutItem, shell).CreateContent();
    view.BindingContext = flyoutItem;
    return view;
}

Binding the created content to flyoutItem is what all three built-in backends do — iOS UIContainerCell.BindingContext = context, Android View.BindingContext = value, Windows _content.BindingContext = bo.

Null semantics

An item that explicitly sets the template property wins even when the value is null, so an app can opt a single item out of a Shell-level template. A null or null-bound value is reported as "no template" rather than producing a true + null mismatch or an NRE in platform code.

IShellController.GetFlyoutItemDataTemplate is now ResolveFlyoutItemTemplate(this, bo) ?? BaseShellItem.CreateDefaultFlyoutItemCell(bo), so the in-box path never hands null to platform code. Previously an explicitly-null template returned null here, which produced a blank Windows item and an NRE on Tizen.

Dogfooding

The Windows backend (ShellFlyoutItemView) now goes through the public API and falls back to the default cell, preserving behavior. The in-box Tizen adaptor resolves and null-checks in one step. BaseShellItem.CreateDefaultFlyoutItemCell continues to use the internal helper for its style-class source.

Changes since the first revision

Addressing review feedback, the original three methods (GetFlyoutItemTemplateProperty, GetFlyoutItemTemplateSource, IsFlyoutItemTemplateSet) were removed in favor of the single method above, because:

  1. Get* on Shell reads as an attached-property accessor, which these were not.
  2. Exposing the property / source / is-set trio froze the internal lookup levels as public contract.
  3. The documented binding context was wrong — it pointed callers at the template source (the parent ShellContent for ShellContent.MenuItems) instead of the flyout item.

Issues Fixed

Unblocks external Shell backends (Tizen) from replicating in-box flyout item template selection.

Tests

ShellExternalFlyoutBackendTests (25 tests) drives a FakeExternalShellFlyoutBackend restricted to public API only, mirroring an out-of-tree adaptor:

  • Template resolution — no template → null; Shell-level ItemTemplate/MenuItemTemplate; item-level template winning over Shell-level; MenuItemTemplate set on the MenuItem and found through the MenuShellItem wrapper; set on the wrapper and found through the bare MenuItem; set on the parent ShellContent and found through the MenuItem
  • BindingText and Command bound through ShellContent.MenuItems, Text/Command bound through Shell-level MenuItemTemplate, Title bound for shell items, and flyout selection actually invoking the MenuItem.Command
  • Null handling — explicit-null item template opting out of the Shell template (item and menu item), null-bound template, and a test asserting the in-box path still yields a non-null template
  • SelectorDataTemplateSelector returned as-is and resolved by the caller
  • Headers/footersFlyoutHeader, FlyoutFooter, FlyoutHeaderTemplate, FlyoutFooterTemplate, and headers not appearing as flyout items
  • Argument/ownership semantics — omitting the Shell resolves it from the item, unparented items, null argument validation

Results:

Suite Result
Controls.Core.UnitTests 6232 passed, 0 failed (30 skipped)
Controls.Xaml.UnitTests 2115 passed, 0 failed (8 skipped)
Controls.Core net11.0 + netstandard2.0 build clean, PublicAPI analyzer clean

net11.0-windows* and net11.0-tizen* cannot be compiled on this macOS box (MakePri.exe / unrecognized tizen platform identifier), so those two backend edits are review-validated only; CI covers them.

… backends

Out-of-tree Shell backends (for example the Tizen backend that now lives
outside this repository) have to decide whether a flyout item uses an
application supplied `DataTemplate` or the backend's own platform default
view. That decision requires resolving the object that actually carries the
template, which is not always the flyout item itself: menu items are backed
by a pair of objects (the internal `MenuShellItem` wrapper and the public
`MenuItem`) and the template can be set on either one.

The logic lived in the internal `Shell.GetBindableObjectWithFlyoutItemTemplate`,
so external backends could not reproduce it and menu items incorrectly fell
back to the platform default template.

Expose the minimal contract publicly on `Shell`:

* `GetFlyoutItemTemplateProperty` - the `BindableProperty` used for an item.
* `GetFlyoutItemTemplateSource` - the object that carries the template, the
  style class, and the binding context for an item.
* `IsFlyoutItemTemplateSet` - whether `GetFlyoutItemDataTemplate` returns an
  application supplied template or falls back to the default flyout cell.

`MenuShellItem` stays internal. All existing call sites (Shell template
resolution, default flyout cell style classes, and the in-box Tizen flyout
adaptor) now go through the public helpers, so flyout item, menu item, and
header template selection, binding contexts, and commands are unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings August 26, 2026 23:39
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 23:39 — with GitHub Actions Inactive
@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 -- 37862

Or

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

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

@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 23:39 — with GitHub Actions Inactive
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 23:40 — with GitHub Actions Inactive
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 23:43 — with GitHub Actions Inactive
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 23:44 — with GitHub Actions Inactive
@github-actions github-actions Bot added the area-controls-shell Shell Navigation, Routes, Tabs, Flyout label Aug 26, 2026
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 23:44 — with GitHub Actions Inactive
Redth pushed a commit to Redth/Maui.Tizen that referenced this pull request Aug 26, 2026
…l one

The ShellTemplateResolver expiry test watched for
Shell.GetBindableObjectWithFlyoutItemTemplate becoming public. Upstream is not
planning to publish that name: dotnet/maui#37862 proposes a different shape -
IsFlyoutItemTemplateSet, GetFlyoutItemTemplateSource and
GetFlyoutItemTemplateProperty, used with the already-public
IShellController.GetFlyoutItemDataTemplate.

As written the test could never have fired, so the adapter would have quietly
become permanent - exactly the rot these tests exist to prevent. It now watches
the three proposed members and names the follow-up work when they land.

#37862 is OPEN, so nothing adopts it here. ShellTemplateResolver's
implementation is untouched and stays provisional until the API merges and
ships in a referenced package. Only the detector, the request ledger and the
wave doc change.

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

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

This PR adds a small set of new public Shell helper APIs that let out-of-tree Shell flyout backends resolve flyout item templates (and their template “source” object) the same way in-box backends do, especially for the MenuItem/MenuShellItem wrapper case.

Changes:

  • Add public static Shell helpers: GetFlyoutItemTemplateProperty, GetFlyoutItemTemplateSource, and IsFlyoutItemTemplateSet.
  • Update in-box call sites (Shell template resolution, default flyout cell style-class sourcing, and Tizen flyout item template selection) to route through the new helpers.
  • Add ShellExternalFlyoutBackendTests to validate the contract from the perspective of an external backend.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/Controls/tests/Core.UnitTests/ShellExternalFlyoutBackendTests.cs Adds unit tests that simulate an external Shell flyout backend using the new public helper contract.
src/Controls/src/Core/Shell/Shell.cs Introduces the new public helper APIs and switches internal template resolution to use them.
src/Controls/src/Core/Shell/BaseShellItem.cs Uses the new template-source helper to select the style-class source for the default flyout cell.
src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt Records the new public APIs for netstandard PublicAPI validation.
src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt Records the new public APIs for net PublicAPI validation.
src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt Records the new public APIs for net-windows PublicAPI validation.
src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt Records the new public APIs for net-tizen PublicAPI validation.
src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt Records the new public APIs for net-maccatalyst PublicAPI validation.
src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt Records the new public APIs for net-ios PublicAPI validation.
src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt Records the new public APIs for net-android PublicAPI validation.
src/Controls/src/Core/Handlers/Shell/Tizen/ShellFlyoutItemAdaptor.cs Updates Tizen flyout template selection to rely on Shell.IsFlyoutItemTemplateSet.

Comment thread src/Controls/src/Core/Shell/Shell.cs Outdated
Comment on lines +734 to +749
/// <summary>
/// Gets the <see cref="BindableProperty"/> that Shell uses to look up the flyout <see cref="DataTemplate"/>
/// for <paramref name="flyoutItem"/>.
/// </summary>
/// <param name="flyoutItem">A flyout item produced by <see cref="IShellController.GenerateFlyoutGrouping"/>.</param>
/// <returns>
/// <see cref="MenuItemTemplateProperty"/> for menu items, otherwise <see cref="ItemTemplateProperty"/>.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="flyoutItem"/> is <see langword="null"/>.</exception>
public static BindableProperty GetFlyoutItemTemplateProperty(BindableObject flyoutItem)
{
if (flyoutItem is null)
throw new ArgumentNullException(nameof(flyoutItem));

return flyoutItem is IMenuItemController ? MenuItemTemplateProperty : ItemTemplateProperty;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch on the original revision — that concern is resolved by the redesign in 37f4a9c, which restored the helper rather than removing it.

Shell.GetBindableObjectWithFlyoutItemTemplate still exists with the identical signature, just internal and now documented:

internal static BindableObject GetBindableObjectWithFlyoutItemTemplate(BindableObject bo)

So both shipped binary profiles still resolve it — verified against the artifacts that Microsoft.Maui.Controls.targets actually imports:

$ strings maui.aotprofile    | grep -c GetBindableObjectWithFlyoutItemTemplate
1
$ strings maui-sc.aotprofile | grep -c GetBindableObjectWithFlyoutItemTemplate
1

IShellController.GetFlyoutItemDataTemplate is also unchanged in signature, so its profile entry (maui.aotprofile.txt:2056, maui-sc.aotprofile.txt:2575) stays valid too.

Two notes for the record:

  1. The .txt files are human-readable dumps, not build inputs — Controls.Build.Tasks.csproj packs nuget\** with Exclude="nuget\**\*.aotprofile.txt", and Microsoft.Maui.Controls.targets imports the binary maui.aotprofile / maui-sc.aotprofile. Hand-editing the .txt would have no effect on profiled AOT.
  2. The new ResolveFlyoutItemTemplate is not in the profiles, which is expected: the profiles are regenerated from a recorded run via src/ProfiledAot, not maintained by hand. It's a small non-virtual static on a path that is already only hit while building flyout items.

Redth pushed a commit to Redth/Maui.Tizen that referenced this pull request Aug 26, 2026
…name

The proposed API has changed shape twice while this adapter sat here: the
internal GetBindableObjectWithFlyoutItemTemplate, then a three-method contract,
and now a single resolve-style call. Each time the expiry test named members
explicitly it silently stopped detecting anything - worse than having no test,
because a green build then implies the adapter is still needed.

The detector now matches the concept: any new public Shell member about a
flyout item template. It is covered by table-driven tests proving it fires for
the resolve-style shape, the three-method shape and plausible alternatives,
while ignoring pre-existing members - so it is verified to trigger rather than
merely assumed to.

dotnet/maui#37862 remains OPEN and under design. No shape is adopted;
ShellTemplateResolver's implementation is untouched.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replaces the three algorithm-piece methods with one nullable-annotated,
result-oriented entry point:

    public static DataTemplate? ResolveFlyoutItemTemplate(Shell? shell, BindableObject flyoutItem)

It returns the final application-defined template, or null meaning "use your
own platform-native default". Rationale for the change:

* `Get*` on Shell reads as an attached-property accessor, which these were not.
* Exposing the property/source/is-set trio froze the internal lookup levels as
  public contract.
* The documented binding context was wrong. The built-in Android, iOS, and
  Windows backends all bind created content to the flyout item itself
  (`View.BindingContext = context` / `_content.BindingContext = bo`), not to the
  object that carried the template, which for `ShellContent.MenuItems` is the
  parent `ShellContent`.

`MenuShellItem` and the paired-object lookup stay internal.

Null handling is explicit: an item that sets the template property wins even
when the value is null (so an app can opt one item out of a Shell level
template), and a null or null-bound value is reported as "no template" rather
than producing a true/null mismatch or an NRE in platform code.
`IShellController.GetFlyoutItemDataTemplate` now composes the new method with
the default flyout cell, so the in-box path never hands null to platform code.

The Windows backend is dogfooded through the public API, and the in-box Tizen
adaptor now resolves and null-checks in one step.

Tests grow to 23 and add binding coverage: Text/Command bound through
`ShellContent.MenuItems` and through Shell-level `MenuItemTemplate`, flyout
selection invoking the command, item vs Shell template precedence, explicit
null, null-bound, `DataTemplateSelector`, headers/footers and their templates,
Shell resolution when the argument is omitted, unparented items, and argument
validation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 27, 2026 00:02
Adds the remaining flyout item shape an external backend can be handed: the
bare MenuItem whose template is set on its (internal) MenuShellItem wrapper,
reachable publicly as MenuItem.Parent, plus the no-template fallback for the
same shape.

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

Redth commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Redesigned — ready for a fresh review

The three-step API from the first revision is gone. Head is now 6cc7f668f0. Point-by-point against the review feedback:

Single nullable result-oriented API. One method, algorithm details internal:

public static DataTemplate? ResolveFlyoutItemTemplate(Shell? shell, BindableObject flyoutItem)

null means "use your platform default". MenuShellItem, the template-owner lookup (GetBindableObjectWithFlyoutItemTemplate), and the property selection are all internal.

Public Get*(BindableObject) helpers removed. GetFlyoutItemTemplateProperty, GetFlyoutItemTemplateSource, and IsFlyoutItemTemplateSet are deleted — they collided with Shell's attached-property accessor convention and froze the internal lookup levels as public contract.

Bind to the flyout item, not the template source. Confirmed correct: for ShellContent.MenuItems the owner is the parent ShellContent, so binding there breaks Text/Icon/Command. The docs and example now bind to flyoutItem, matching all three in-box backends — iOS UIContainerCell.BindingContext = context, Android View.BindingContext = value, Windows _content.BindingContext = bo. There are tests that fail if this regresses.

Explicit/bound null is "no application template". An item that sets the property wins even when the value is null (so an app can opt one item out of a Shell-level template), and null or null-bound resolves to null rather than a set/null mismatch. IShellController.GetFlyoutItemDataTemplate is now ResolveFlyoutItemTemplate(this, bo) ?? BaseShellItem.CreateDefaultFlyoutItemCell(bo), so the in-box path can no longer hand null to platform code — previously that blanked the Windows item and threw on Tizen.

No IShellController change, MenuShellItem stays internal, and the style-class source in BaseShellItem.CreateDefaultFlyoutItemCell still uses the internal helper.

Dogfooded on Windows. ShellFlyoutItemView resolves through the public API and falls back to the default cell; binding semantics (_content.BindingContext = bo) unchanged. The in-box Tizen adaptor collapses to a resolve + null-check.

Nullable annotations + PublicAPI. Method is in a #nullable enable region; PublicAPI.Unshipped.txt updated across all 7 TFM folders:

static Microsoft.Maui.Controls.Shell.ResolveFlyoutItemTemplate(Microsoft.Maui.Controls.Shell? shell, Microsoft.Maui.Controls.BindableObject! flyoutItem) -> Microsoft.Maui.Controls.DataTemplate?

Tests — 25, public API only. ShellExternalFlyoutBackendTests drives a FakeExternalShellFlyoutBackend that cannot touch internals: ShellItem; bare MenuItem and the MenuShellItem wrapper (both directions — template on the MenuItem, and on the wrapper via MenuItem.Parent); ShellContent.MenuItems parent template with real Text/Command bindings plus flyout selection invoking the command; explicit null and null-bound; DataTemplateSelector returned and resolved by the caller; shell/item precedence; headers, footers and their templates; and platform-default fallback for every shape.

Suite Result
Controls.Core.UnitTests 6232 passed / 0 failed
Controls.Xaml.UnitTests 2115 passed / 0 failed
Controls.Core net11.0 + netstandard2.0 clean, PublicAPI analyzer clean

net11.0-windows* and net11.0-tizen* can't be compiled on macOS (MakePri.exe is an x86 Windows binary; the SDK doesn't recognize the tizen platform identifier), so those two backend edits rely on CI.

The AOT-profile comment is answered inline — the helper was restored with an identical signature, so the shipped binary profiles still resolve it.

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 12 out of 12 changed files in this pull request and generated 1 comment.

Comment on lines +786 to +788
// An explicitly set template wins even when its value is null, which lets an application opt a single item
// out of a Shell level template. A null value is reported as "no template" so callers fall back safely.
BindableObject templateSource = GetBindableObjectWithFlyoutItemTemplate(flyoutItem);
Copilot AI review requested due to automatic review settings August 27, 2026 00:13
Redth pushed a commit to Redth/Maui.Tizen that referenced this pull request Aug 27, 2026
…tion bug

Introduces ShellFlyoutTemplateResolution.ResolveFlyoutItemTemplate, whose
signature is deliberately identical to the redesigned upstream API in
dotnet/maui#37862 so adoption becomes a one-line body swap. Nothing is bound to
the open PR.

Building the seam surfaced a real bug. The call sites passed a PRE-RESOLVED
template owner to IShellController.GetFlyoutItemDataTemplate, but that method
re-derives the owner itself and picks the BindableProperty from its argument's
own type. Handing it the owner therefore selected ItemTemplateProperty where
MenuItemTemplateProperty was authored, silently dropping MenuItemTemplate for
flyout menu items - and also skipped the internal MenuShellItem branch that only
that method can reach. The raw item is now passed, which is both correct and
what upstream does.

The seam also returns DataTemplate? rather than a template-or-default, because
GetFlyoutItemDataTemplate never returns null; calling it unguarded would have
replaced Tizen's own flyout item view with MAUI's generic cell for every app
that never authored a template.

Records Core's published shapes (163677d) including the TizenNaviPage rename and
the ITizenToolbarContainer push contract, and notes that TizenShellView's
pull-based toolbar wiring must be reworked on rebase since SetToolbar disposes
the toolbar it replaces.

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

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 12 out of 12 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/Controls/src/Core/Shell/Shell.cs:788

  • ResolveFlyoutItemTemplate checks GetBindableObjectWithFlyoutItemTemplate(flyoutItem) first. For a MenuItem in ShellContent.MenuItems, GetBindableObjectWithFlyoutItemTemplate returns the parent ShellContent when it has MenuItemTemplateProperty set, which means a template explicitly set on the MenuItem itself (including an explicit null to opt out) will be ignored. If the flyout item has the template property set, it should win before consulting the paired-object lookup.
			BindableProperty bp = flyoutItem is IMenuItemController ? MenuItemTemplateProperty : ItemTemplateProperty;

			// An explicitly set template wins even when its value is null, which lets an application opt a single item
			// out of a Shell level template. A null value is reported as "no template" so callers fall back safely.
			BindableObject templateSource = GetBindableObjectWithFlyoutItemTemplate(flyoutItem);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-controls-shell Shell Navigation, Routes, Tabs, Flyout

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants