Skip to content

[net11.0] Expose toolbar drawer-toggle visibility on IToolbar - #37863

Open
Redth wants to merge 2 commits into
net11.0from
redth-supreme-fortnight
Open

[net11.0] Expose toolbar drawer-toggle visibility on IToolbar#37863
Redth wants to merge 2 commits into
net11.0from
redth-supreme-fortnight

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

External platform backends implement toolbar handlers against Microsoft.Maui.IToolbar. That contract exposed BackButtonVisible, IsVisible and Title, but nothing about the drawer (flyout / "hamburger") toggle — so a backend could not render or update the drawer affordance without forking the state into a private side table.

There were two gaps:

  1. No read contract. DrawerToggleVisible only existed on the Controls-level Microsoft.Maui.Controls.Toolbar type, not on the contract handlers are handed.
  2. No change notification. ShellToolbar and NavigationPageToolbar assign the _drawerToggleVisible backing field directly, so neither PropertyChanged nor Handler.UpdateValue ever fired. Built-in platforms only refreshed the drawer icon as a side effect of a BackButtonVisible change, so a drawer-only transition was silently dropped.

Gap 2 is a latent framework bug, not just an extensibility problem. Cases that did not update before this PR:

  • Shell.FlyoutBehavior toggling between Flyout and Disabled/Locked with the navigation stack unchanged.
  • FlyoutPage.FlyoutLayoutBehavior changing between Split and Popover on a tablet.
  • Tizen's ShellView assigning Toolbar.DrawerToggleVisible — the setter called Handler.UpdateValue("DrawerToggleVisible") against a mapper with no such key.

API: optional capability interface

/// Provides the visibility of the drawer (flyout) toggle affordance for an IToolbar.
public interface IToolbarDrawerToggleVisible
{
    bool DrawerToggleVisible { get; }
}

Microsoft.Maui.Controls.Toolbar implements it. External backends consume it by pattern matching:

bool drawerToggleVisible = toolbar is IToolbarDrawerToggleVisible { DrawerToggleVisible: true };

Why not a member on IToolbar. An earlier revision of this PR added DrawerToggleVisible to IToolbar as a default interface member. That is a source break on netstandard2.0, where DIMs are unsupported and the member is therefore abstract — every existing external IToolbar implementer fails with CS0535. A capability interface is purely additive on every TFM instead. This mirrors ISwipeItemMenuItemIconColor, whose doc comment cites exactly this rationale.

The interface is intentionally not marked [EditorBrowsable(Never)] (unlike ISwipeItemMenuItemIconColor, which is a stopgap): external backend authors are the intended consumer and need to discover it.

It is read-only because the value is computed and owned by the cross-platform layer. Controls.Toolbar keeps its shipped settable property, but the framework owns the value for the Shell and NavigationPage toolbars.

External-implementer compile matrix

A type implementing only the pre-existing IToolbar members, compiled against Microsoft.Maui:

Design netstandard2.0 net11.0
Previous (DIM on IToolbar) CS0535: 'LegacyExternalToolbar' does not implement interface member 'IToolbar.DrawerToggleVisible' ✅ builds
This PR (capability interface) builds builds

Reproduced locally by building src/Core/src/Core.csproj for each TFM and compiling a standalone library that references the resulting Microsoft.Maui.dll and declares:

public class LegacyExternalToolbar : IToolbar
{
    public bool BackButtonVisible { get; set; }
    public bool IsVisible { get; set; }
    public string Title => string.Empty;
    public IElement Parent => null;
    public IElementHandler Handler { get; set; }
}

IToolbar's member set is unchanged on every TFM, so there is nothing left to break; the PublicAPI diff contains no Microsoft.Maui.IToolbar.* additions.

Reliable notification

  • New private protected Toolbar.NotifyPropertyChanged(string) helper (SetProperty routes through it).
  • ShellToolbar.ApplyChanges and NavigationPageToolbar.UpdateBackButton compute the drawer value into a local, still assign the backing field before BackButtonVisible notifies, and raise the DrawerToggleVisible notification after it. This preserves the existing ordering contract that Android's animated back/drawer handling depends on.
  • Suppressed when unchanged, so the common push/pop path does no extra platform work.

Back button precedence — not mutual exclusion

An earlier revision of this PR claimed the two are mutually exclusive. That was wrong and has been removed from the docs and tests. On Windows ShellToolbar derives the drawer toggle purely from FlyoutBehavior while BackButtonVisible is independent, so both can be true at once.

What the framework guarantees is precedence (they share one navigation slot, back wins at render time) and ordering (the drawer value is settled before BackButtonVisible notifies). The test is renamed DrawerToggleValueIsCurrentWhenBackButtonMapperRuns to state the real invariant.

Ownership fix — Tizen

ShellView.UpdateDrawerToggleVisible() did:

Element!.Toolbar.DrawerToggleVisible = Element!.Toolbar.DrawerToggleVisible && Element.FlyoutBehavior == FlyoutBehavior.Flyout;

A platform backend writing a framework-computed value, contradicting the documented ownership. It was also:

  • redundantShellToolbar already folds FlyoutBehavior into the value;
  • latchingX = X && cond can never restore true;
  • subtly wrong — it compared the raw Shell.FlyoutBehavior bindable property rather than the effective behavior ShellToolbar uses.

The write is removed; the method is renamed RefreshNavigationSlot() and now only re-renders. The manual _toolbar?.UpdateBackButton(...) refresh is deliberately retained, because _toolbar is injected via SetToolbar(MauiToolbar) and I could not verify locally that it is the same instance the ToolbarHandler drives.

Ordering fix — Android

ShellToolbarTracker.ApplyToolbarChanges forwarded DrawerToggleVisible before BackButtonVisible. That was inert before this PR (no mapping existed), but with a drawer mapping registered it would map the shared navigation slot against a stale back-button value. Reordered so BackButtonVisible is assigned first.

Built-in platform consumption

Platform Consumes it? Where
Android Yes ToolbarExtensions.UpdateBackButton picks a DrawerArrowDrawable (Progress = 0) when back is hidden and the drawer toggle is visible. Shell additionally drives an ActionBarDrawerToggle from ShellToolbarTracker.
Tizen Yes ToolbarExtensions.UpdateBackButton installs a menu button; UpdateTitleIcon avoids clearing the icon while it is visible.
Windows No Flyout toggle is rendered by NavigationView.
iOS / MacCatalyst No Flyout toggle is owned by the flyout/navigation controller.

The mapping is registered for Android and Tizen only. Both it and the BackButtonVisible mapping call UpdateBackButton, so a change flipping both runs it twice — intentional and idempotent.

Tests

src/Controls/tests/Core.UnitTests/ToolbarDrawerToggleTests.cs (11 tests):

  • External fake backendExternalToolbarBackendHandler : ElementHandler<IToolbar, object>, written against IToolbar only, never referencing Controls.Toolbar. It reads drawer state solely through the capability-interface pattern match, so anything it can do an external assembly can do.
  • LegacyExternalToolbar — implements only the pre-existing IToolbar members, deliberately not the capability interface: compiles and reports no drawer toggle.
  • CapableExternalToolbar — opts in and reports its value.
  • Shell FlyoutBehavior transitions notify; Disabled → Locked does not (no spurious updates).
  • Back-vs-drawer precedence across push/pop, and drawer value already settled when the back-button mapper runs.
  • NavigationPage inside FlyoutPage: push/pop plus FlyoutLayoutBehavior.Split → Popover.
  • Multi-window: two Shells keep independent state and notifications.

Four tests fail without the notification fix, verified by reverting ShellToolbar.cs + NavigationPageToolbar.cs:

DrawerToggleStateIsTrackedPerWindow [FAIL]
ExternalBackendIsNotifiedForNavigationPageInsideFlyoutPage [FAIL]
DrawerToggleValueIsCurrentWhenBackButtonMapperRuns [FAIL]
ExternalBackendIsNotifiedWhenFlyoutBehaviorChanges [FAIL]

Validation

  • Controls.Core.UnitTests: 6218 passed / 0 failed / 30 skipped.
  • Core.csproj builds clean for netstandard2.0 and net11.0; Controls.Core.csproj builds clean for net11.0-android37.0 (mapper + PublicAPI analyzers).
  • External-implementer compile matrix above.
  • ⚠️ Not verified locally: the Tizen TFM (NETSDK1139 — workload not installed on this machine). The Tizen edits are a method rename with all three references updated plus removal of one assignment, and the Toolbar.Tizen.cs mapper mirrors the Android one that does compile — but CI must confirm the Tizen leg.

Docs

docs/design/ToolbarDrawerToggle.md — why a separate interface, ownership table, precedence-not-exclusion, the ordering guarantee (including the ApplyToolbarChanges assignment-order requirement), a copy-pasteable external handler, and the built-in consumption table.

Issues Fixed

Unblocks external MAUI platform backends that currently need a private ConditionalWeakTable adapter to track drawer toggle state.

External platform backends implement toolbar handlers against
`Microsoft.Maui.IToolbar`, which exposed `BackButtonVisible`, `IsVisible`
and `Title` but not the drawer (flyout / "hamburger") toggle state. That
made it impossible to render or update the drawer affordance without
forking the state into a private side table.

There were two gaps:

1. No read contract. `DrawerToggleVisible` only existed on the
   Controls-level `Microsoft.Maui.Controls.Toolbar` type, not on the
   `IToolbar` contract handlers receive.
2. No change notification. `ShellToolbar` and `NavigationPageToolbar`
   assigned the `_drawerToggleVisible` backing field directly, so neither
   `PropertyChanged` nor `Handler.UpdateValue` ever fired. Built-in
   platforms only refreshed the drawer icon as a side effect of a
   `BackButtonVisible` change, so a drawer-only transition (for example a
   `Shell.FlyoutBehavior` or `FlyoutPage.FlyoutLayoutBehavior` change) was
   silently dropped.

Changes:

- Add `bool IToolbar.DrawerToggleVisible { get; }` as a default interface
  member returning `false`, keeping existing implementations source and
  binary compatible. `Toolbar` already declares a public virtual
  get/set property, so it implements the new member implicitly with no
  binary change. netstandard2.0 declares it without a default, matching
  the existing `IRefreshView.IsRefreshEnabled` pattern.
- Raise the missing notification from `ShellToolbar` and
  `NavigationPageToolbar` via a new `Toolbar.NotifyPropertyChanged`
  helper. The backing field is still assigned before `BackButtonVisible`
  notifies and the drawer notification is raised after it, so back-button
  precedence in the shared navigation slot is preserved and backends never
  observe an inconsistent intermediate state.
- Register a `DrawerToggleVisible` mapping for Android and Tizen, the two
  built-in platforms that render the drawer icon in the navigation slot.

Adds `ToolbarDrawerToggleTests` covering a fake external backend that only
consumes `IToolbar`, Shell and NavigationPage/FlyoutPage visibility
transitions, back-vs-drawer precedence, no-op suppression, and per-window
isolation. Four of the new tests fail without the notification fix.

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:42
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 23:42 — 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 -- 37863

Or

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

@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 23:42 — with GitHub Actions Inactive
@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.

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 extends the core Microsoft.Maui.IToolbar contract to expose drawer/flyout toggle visibility (DrawerToggleVisible) for external platform backends, and updates Controls toolbars to reliably notify handler mappers when the computed drawer-toggle state changes (e.g., flyout behavior/layout transitions that previously didn’t trigger updates).

Changes:

  • Added IToolbar.DrawerToggleVisible (with a DIM fallback for non-NETSTANDARD2_0) and updated Core PublicAPI baselines accordingly.
  • Ensured ShellToolbar and NavigationPageToolbar notify DrawerToggleVisible changes (via a new Toolbar.NotifyPropertyChanged helper to preserve ordering).
  • Added unit tests covering external-backend mapping/ordering semantics, plus a design doc describing ownership and notification guarantees.

Reviewed changes

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

Show a summary per file
File Description
src/Core/src/Core/IToolbar.cs Adds DrawerToggleVisible to the handler contract (DIM where supported).
src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt Public API baseline update for the new IToolbar.DrawerToggleVisible getter.
src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt Public API baseline update for the new IToolbar.DrawerToggleVisible getter.
src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt Public API baseline update for the new IToolbar.DrawerToggleVisible getter.
src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt Public API baseline update for the new IToolbar.DrawerToggleVisible getter.
src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt Public API baseline update for the new IToolbar.DrawerToggleVisible getter.
src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt Public API baseline update for the new IToolbar.DrawerToggleVisible getter.
src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt Public API baseline update for the new IToolbar.DrawerToggleVisible getter.
src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt Public API baseline update for the new IToolbar.DrawerToggleVisible getter.
src/Controls/src/Core/Toolbar/Toolbar.cs Adds NotifyPropertyChanged helper so derived toolbars can preserve update ordering while still notifying handler/subscribers.
src/Controls/src/Core/ShellToolbar.cs Computes drawer-toggle visibility and explicitly notifies DrawerToggleVisible when it changes.
src/Controls/src/Core/NavigationPage/NavigationPageToolbar.cs Computes drawer-toggle visibility and explicitly notifies DrawerToggleVisible when it changes.
src/Controls/src/Core/Toolbar/Toolbar.Mapper.cs Registers a mapper entry for IToolbar.DrawerToggleVisible (Android/Tizen) to re-evaluate the shared navigation slot.
src/Controls/src/Core/Toolbar/Toolbar.Android.cs Adds MapDrawerToggleVisible mapping to UpdateBackButton on Android.
src/Controls/src/Core/Toolbar/Toolbar.Tizen.cs Adds MapDrawerToggleVisible mapping to UpdateBackButton on Tizen.
src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt Public API baseline update for the new Android Toolbar.MapDrawerToggleVisible methods.
src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt Public API baseline update for the new Tizen Toolbar.MapDrawerToggleVisible methods.
src/Controls/tests/Core.UnitTests/ToolbarDrawerToggleTests.cs Adds unit tests validating contract exposure + external-backend notifications and ordering invariants.
docs/design/ToolbarDrawerToggle.md Documents ownership, precedence, and notification behavior for DrawerToggleVisible.

Comment thread src/Core/src/Core/IToolbar.cs Outdated
Comment on lines +28 to +31
/// <para>Changes to this value raise a handler update for the <c>DrawerToggleVisible</c> property name, so
/// platform backends can map it just like any other toolbar property.</para>
/// <para>The default implementation returns <see langword="false"/> so that existing
/// <see cref="IToolbar"/> implementations remain source and binary compatible.</para>
Addresses review feedback on the previous commit.

The previous approach added `DrawerToggleVisible` to `IToolbar` as a
default interface member. That is a source break on netstandard2.0, where
default interface members are unsupported and the member is therefore
abstract, so any existing external `IToolbar` implementer fails with
CS0535. The `IRefreshView.IsRefreshEnabled` precedent does not justify
introducing a new break.

Replaced with `Microsoft.Maui.IToolbarDrawerToggleVisible`, an optional
read-only capability interface implemented by `Microsoft.Maui.Controls.Toolbar`
and consumed via pattern matching:

    toolbar is IToolbarDrawerToggleVisible { DrawerToggleVisible: true }

`IToolbar` gains no members on any target framework, so the change is
purely additive everywhere. This mirrors `ISwipeItemMenuItemIconColor`,
which was added alongside `ISwipeItemMenuItem` for the same reason.

Verified with an external-implementer compile matrix. A type implementing
only the pre-existing `IToolbar` members compiles against the new Core on
both netstandard2.0 and net11.0, and fails with CS0535 on netstandard2.0
against the previous design.

Also fixes three contract issues found in review:

- Removed the platform-backend write in Tizen's `ShellView`, which did
  `DrawerToggleVisible = DrawerToggleVisible && FlyoutBehavior == Flyout`.
  That contradicted the documented framework ownership, was redundant
  because `ShellToolbar` already folds `FlyoutBehavior` into the value,
  latched to false since it could never restore true, and compared against
  the raw bindable property rather than the effective flyout behavior. The
  method now only re-renders the navigation slot.
- Reordered `ShellToolbarTracker.ApplyToolbarChanges` to assign
  `BackButtonVisible` before `DrawerToggleVisible`. With a drawer mapping
  now registered on Android, forwarding the drawer value first would map
  the shared navigation slot against a stale back-button value.
- Dropped the incorrect mutual-exclusivity claim from the docs and tests.
  On Windows `ShellToolbar` derives the drawer toggle purely from
  `FlyoutBehavior`, so both values can be true simultaneously; the back
  button merely takes precedence when rendering. The guarantee the
  framework actually provides is ordering, and the test is renamed to
  `DrawerToggleValueIsCurrentWhenBackButtonMapperRuns` to say so.

Docs now record actual built-in consumption: Android and Tizen read the
value from `ToolbarExtensions.UpdateBackButton`; Windows, iOS and
MacCatalyst render the flyout toggle from the flyout/navigation view and
never read it, which is why the mapping is registered only for the first
two.

Tests add an external implementer that omits the capability interface
(compiles, reports no drawer toggle) and one that opts into it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 27, 2026 00:19
@Redth

Redth commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Pushed 24ae20ff — full redesign in response to review. Requesting fresh review.

Blocker confirmed and fixed. The previous head added DrawerToggleVisible to IToolbar as a default interface member. On netstandard2.0 DIMs are unsupported, so the member was abstract and broke existing external implementers. Verified rather than assumed:

error CS0535: 'LegacyExternalToolbar' does not implement interface member 'IToolbar.DrawerToggleVisible'

Replaced with an optional read-only capability interface, Microsoft.Maui.IToolbarDrawerToggleVisible, implemented by Controls.Toolbar and consumed via toolbar is IToolbarDrawerToggleVisible { DrawerToggleVisible: true }. IToolbar now gains no members on any TFM, so the change is purely additive. Same shape as ISwipeItemMenuItemIconColor, whose doc comment cites this exact rationale.

Compile matrix, legacy implementer vs Microsoft.Maui.dll:

Design netstandard2.0 net11.0
Previous (DIM) ❌ CS0535
This head

Contract issues, all fixed:

  • False mutual-exclusivity claim removed. Windows can expose both values simultaneously; back merely takes precedence at render. Docs corrected and the test renamed to DrawerToggleValueIsCurrentWhenBackButtonMapperRuns, which is the guarantee that actually holds (ordering).
  • Tizen backend write removed. ShellView did DrawerToggleVisible = DrawerToggleVisible && FlyoutBehavior == Flyout — a backend writing a framework-owned value. It was also redundant, latching (X = X && cond never restores true), and compared the raw bindable property instead of the effective flyout behavior. Method renamed RefreshNavigationSlot() and now only re-renders.
  • Android forwarding reordered. ShellToolbarTracker.ApplyToolbarChanges set DrawerToggleVisible before BackButtonVisible; inert previously, but stale-mapping once a drawer mapping exists.
  • Actual platform consumption documented — Android and Tizen read it from UpdateBackButton; Windows, iOS and MacCatalyst render the flyout toggle from the flyout/navigation view and never read it, which is why the mapping is Android/Tizen only.

Validation: Controls.Core.UnitTests 6218 passed / 0 failed / 30 skipped. Core.csproj clean on netstandard2.0 + net11.0, Controls.Core.csproj clean on net11.0-android37.0. New tests cover an implementer that omits the capability interface and one that opts in; four tests still fail if the notification fix is reverted.

⚠️ One gap I want called out rather than buried: the Tizen TFM is not compile-verified locally (NETSDK1139, workload not installed). The Tizen changes are a rename with all references updated plus one deletion, and the Tizen mapper mirrors the Android one that does compile — but please confirm the Tizen leg in CI before merging.

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

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

src/Controls/tests/Core.UnitTests/ToolbarDrawerToggleTests.cs:290

  • LegacyExternalToolbar implements IElement.Parent/IElement.Handler as non-nullable, but both members are nullable on IElement and this stub returns null / is unset. This can produce nullable-mismatch warnings and makes the test double’s contract misleading. Align the annotations with IElement (IElement? / IElementHandler?).

This issue also appears on line 302 of the same file.

			public string Title => string.Empty;

			public IElement Parent => null;

			public IElementHandler Handler { get; set; }

src/Controls/tests/Core.UnitTests/ToolbarDrawerToggleTests.cs:306

  • CapableExternalToolbar implements IElement.Parent/IElement.Handler as non-nullable, but IElement declares both as nullable and this test double returns null / leaves Handler unset. Align the nullability to avoid interface implementation warnings and to accurately model external implementations.
			public string Title => string.Empty;

			public IElement Parent => null;

			public IElementHandler Handler { get; set; }

Comment on lines 102 to +106
Element.Toolbar.PropertyChanged += (s, e) =>
{
if (e.PropertyName == "BackButtonVisible")
{
UpdateDrawerToggleVisible();
RefreshNavigationSlot();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants