Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions docs/design/ToolbarDrawerToggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Toolbar drawer toggle contract

`Microsoft.Maui.IToolbarDrawerToggleVisible.DrawerToggleVisible` tells a platform backend whether the
toolbar should render the drawer (flyout / "hamburger") affordance in its navigation slot.

## Why a separate interface

The member is **not** on `IToolbar`. `IToolbar` is a shipped public interface, and adding a member to it
would source-break every existing external implementer on `netstandard2.0`, where default interface
members are not supported and the member would therefore be abstract (`CS0535`).

`IToolbarDrawerToggleVisible` is an optional capability interface instead, so it is purely additive on
every target framework. This mirrors `ISwipeItemMenuItemIconColor`, which was added alongside
`ISwipeItemMenuItem` for the same reason.

Consume it by pattern matching:

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

A toolbar that does not implement the interface has no drawer toggle.

## Ownership

The value is **computed and owned by the cross-platform layer**, which is why the contract is read-only:

| Toolbar | How the value is computed |
| ------- | ------------------------- |
| `ShellToolbar` | `FlyoutBehavior == Flyout` and either the navigation stack has a single page or the back button is suppressed by a `BackButtonBehavior`. On Windows only `FlyoutBehavior == Flyout` is considered. |
| `NavigationPageToolbar` | The toolbar's parent is a `FlyoutPage`, `FlyoutPage.ShouldShowToolbarButton()` is `true`, and (outside Windows) no page has been pushed. |

Platform backends **render** this value; they must not compute or overwrite it.
`Microsoft.Maui.Controls.Toolbar` still exposes a settable `DrawerToggleVisible` property because it is
shipped public API, but the framework owns the value for the Shell and `NavigationPage` toolbars.

## Back button precedence — not mutual exclusion

`BackButtonVisible` and `DrawerToggleVisible` are **not** mutually exclusive. On Windows, `ShellToolbar`
sets the drawer toggle purely from `FlyoutBehavior`, so both values can be `true` at the same time.

What is guaranteed is **precedence**: the two share a single navigation slot, and the back button wins.
A backend should check the back button first and only fall through to the drawer toggle, exactly as the
built-in `ToolbarExtensions.UpdateBackButton` implementations do on Android and Tizen.

Ordering is also guaranteed: the drawer toggle backing value is updated *before* `BackButtonVisible`
notifies, and the `DrawerToggleVisible` notification is raised *after* it. So a backend rendering the
shared slot from either mapper always observes a settled pair rather than a half-applied transition.
For the same reason, code that forwards toolbar state between instances must assign `BackButtonVisible`
before `DrawerToggleVisible` (see `ShellToolbarTracker.ApplyToolbarChanges`).

## Change notification

Changes raise a handler update keyed on `"DrawerToggleVisible"`, exactly like any other toolbar property:

```csharp
public class MyToolbarHandler : ElementHandler<IToolbar, MyPlatformToolbar>
{
public static readonly IPropertyMapper<IToolbar, MyToolbarHandler> Mapper =
new PropertyMapper<IToolbar, MyToolbarHandler>(ElementMapper)
{
[nameof(IToolbar.Title)] = MapTitle,
[nameof(IToolbar.BackButtonVisible)] = MapNavigationSlot,
[nameof(IToolbarDrawerToggleVisible.DrawerToggleVisible)] = MapNavigationSlot,
};

public MyToolbarHandler() : base(Mapper) { }

protected override MyPlatformToolbar CreatePlatformElement() => new();

static void MapTitle(MyToolbarHandler handler, IToolbar toolbar) =>
handler.PlatformView.Title = toolbar.Title;

static void MapNavigationSlot(MyToolbarHandler handler, IToolbar toolbar)
{
if (toolbar.BackButtonVisible) // back wins
handler.PlatformView.ShowBackButton();
else if (toolbar is IToolbarDrawerToggleVisible { DrawerToggleVisible: true })
handler.PlatformView.ShowDrawerToggle();
else
handler.PlatformView.ClearNavigationSlot();
}
}
```

Pointing both keys at one method is the recommended shape, since the two properties render into the same
slot.

Each window owns its own toolbar instance, so drawer toggle state and its notifications are tracked per
window with no additional work from the backend.

## Built-in platform consumption

| Platform | Consumes `DrawerToggleVisible`? | Where |
| -------- | ------------------------------- | ----- |
| Android | Yes | `Controls.Platform.ToolbarExtensions.UpdateBackButton` selects a `DrawerArrowDrawable` with `Progress = 0` when the back button is hidden and the drawer toggle is visible. Shell additionally drives an `ActionBarDrawerToggle` from `ShellToolbarTracker`. |
| Tizen | Yes | `Controls.Platform.ToolbarExtensions.UpdateBackButton` installs a menu button, and `UpdateTitleIcon` avoids clearing the icon while the drawer toggle is visible. |
| Windows | No | The flyout toggle is rendered by `NavigationView`, not by the toolbar. |
| iOS / MacCatalyst | No | The flyout toggle is owned by the flyout/navigation controller, not by the toolbar. |

The `DrawerToggleVisible` property mapping is therefore registered only for Android and Tizen. Because
that mapping and the `BackButtonVisible` mapping both call `UpdateBackButton`, a change that flips both
values runs it twice; this is intentional and idempotent.
Original file line number Diff line number Diff line change
Expand Up @@ -585,8 +585,11 @@ internal static void ApplyToolbarChanges(Toolbar shellToolbar, Toolbar destinati
destination.Title = shellToolbar.Title;
destination.TitleView = shellToolbar.TitleView;
destination.DynamicOverflowEnabled = shellToolbar.DynamicOverflowEnabled;
destination.DrawerToggleVisible = shellToolbar.DrawerToggleVisible;
// BackButtonVisible must be assigned before DrawerToggleVisible: the two share the toolbar's
// navigation slot and the back button wins, so forwarding the drawer value first would map the
// slot against a stale back-button value.
destination.BackButtonVisible = shellToolbar.BackButtonVisible;
destination.DrawerToggleVisible = shellToolbar.DrawerToggleVisible;
destination.BackButtonEnabled = shellToolbar.BackButtonEnabled;
destination.IsVisible = shellToolbar.IsVisible;
}
Expand Down
9 changes: 5 additions & 4 deletions src/Controls/src/Core/Handlers/Shell/Tizen/ShellView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public void SetElement(Shell shell, IMauiContext context)
{
if (e.PropertyName == "BackButtonVisible")
{
UpdateDrawerToggleVisible();
RefreshNavigationSlot();
}
};

Expand All @@ -119,7 +119,7 @@ public void SetElement(Shell shell, IMauiContext context)
public void UpdateFlyoutBehavior(FlyoutBehavior flyoutBehavior)
{
_navigationDrawer.DrawerBehavior = flyoutBehavior.ToPlatform();
UpdateDrawerToggleVisible();
RefreshNavigationSlot();

if (_navigationDrawer.DrawerBehavior == TDrawerBehavior.Drawer)
_ = _navigationDrawer.CloseAsync(false);
Expand Down Expand Up @@ -441,9 +441,10 @@ void OnIconPressed(object? sender, EventArgs e)
IsOpened = true;
}

void UpdateDrawerToggleVisible()
// DrawerToggleVisible is computed and owned by ShellToolbar, which already accounts for
// FlyoutBehavior and the back button, so this only re-renders the navigation slot.
void RefreshNavigationSlot()
{
Element!.Toolbar.DrawerToggleVisible = ((Element!.Toolbar.DrawerToggleVisible) && (Element.FlyoutBehavior == FlyoutBehavior.Flyout));
_toolbar?.UpdateBackButton(Element!.Toolbar);
}

Expand Down
15 changes: 10 additions & 5 deletions src/Controls/src/Core/NavigationPage/NavigationPageToolbar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,14 +182,14 @@ void UpdateBackButton()

// Set this before BackButtonVisible triggers an update to the handler
// This way all useful information is present
if (Parent is FlyoutPage flyout && flyout.ShouldShowToolbarButton()
var drawerToggleVisible = Parent is FlyoutPage flyout && flyout.ShouldShowToolbarButton()
#if !WINDOWS // TODO NET 10 : Move this logic to ShouldShowToolbarButton
&& !anyPagesPushed.Value
#endif
)
_drawerToggleVisible = true;
else
_drawerToggleVisible = false;
;

var drawerToggleVisibleChanged = _drawerToggleVisible != drawerToggleVisible;
_drawerToggleVisible = drawerToggleVisible;

// Once we have better logic inside core to handle backbutton visiblity this
// code should all go away.
Expand Down Expand Up @@ -219,6 +219,11 @@ void UpdateBackButton()

_userChanged = false;
}

// Notified last so that BackButtonVisible (which takes precedence in the navigation slot)
// is already up to date when platform backends react to the drawer toggle change.
if (drawerToggleVisibleChanged)
NotifyPropertyChanged(nameof(DrawerToggleVisible));
}

void ApplyChanges(NavigationPage navigationPage)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,5 @@ virtual Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutTemplatedConte
~static readonly Microsoft.Maui.Controls.TabbedPage.BadgeColorProperty -> Microsoft.Maui.Controls.BindableProperty
~static readonly Microsoft.Maui.Controls.TabbedPage.BadgeTextColorProperty -> Microsoft.Maui.Controls.BindableProperty
~static readonly Microsoft.Maui.Controls.TabbedPage.BadgeTextProperty -> Microsoft.Maui.Controls.BindableProperty
static Microsoft.Maui.Controls.Toolbar.MapDrawerToggleVisible(Microsoft.Maui.Handlers.IToolbarHandler! arg1, Microsoft.Maui.Controls.Toolbar! arg2) -> void
static Microsoft.Maui.Controls.Toolbar.MapDrawerToggleVisible(Microsoft.Maui.Handlers.ToolbarHandler! arg1, Microsoft.Maui.Controls.Toolbar! arg2) -> void
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,5 @@ Microsoft.Maui.Controls.Label.~Label() -> void
~static readonly Microsoft.Maui.Controls.TabbedPage.BadgeColorProperty -> Microsoft.Maui.Controls.BindableProperty
~static readonly Microsoft.Maui.Controls.TabbedPage.BadgeTextColorProperty -> Microsoft.Maui.Controls.BindableProperty
~static readonly Microsoft.Maui.Controls.TabbedPage.BadgeTextProperty -> Microsoft.Maui.Controls.BindableProperty
static Microsoft.Maui.Controls.Toolbar.MapDrawerToggleVisible(Microsoft.Maui.Handlers.IToolbarHandler! handler, Microsoft.Maui.Controls.Toolbar! toolbar) -> void
static Microsoft.Maui.Controls.Toolbar.MapDrawerToggleVisible(Microsoft.Maui.Handlers.ToolbarHandler! handler, Microsoft.Maui.Controls.Toolbar! toolbar) -> void
12 changes: 10 additions & 2 deletions src/Controls/src/Core/ShellToolbar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,23 @@ internal void ApplyChanges()

var flyoutBehavior = (_shell as IFlyoutView).FlyoutBehavior;
#if WINDOWS
_drawerToggleVisible = flyoutBehavior is FlyoutBehavior.Flyout;
var drawerToggleVisible = flyoutBehavior is FlyoutBehavior.Flyout;
#else
_drawerToggleVisible = flyoutBehavior is FlyoutBehavior.Flyout && (stack.Count <= 1 || !backButtonVisible);
var drawerToggleVisible = flyoutBehavior is FlyoutBehavior.Flyout && (stack.Count <= 1 || !backButtonVisible);
#endif
var drawerToggleVisibleChanged = _drawerToggleVisible != drawerToggleVisible;
_drawerToggleVisible = drawerToggleVisible;

BackButtonVisible = backButtonVisible && stack.Count > 1;
BackButtonEnabled = _backButtonBehavior?.IsEnabled ?? true;
BackButtonAccessibilityLabel = _backButtonBehavior?.AccessibilityLabel;
ToolbarItems = _toolbarTracker.ToolbarItems;

// Notified after BackButtonVisible (which takes precedence in the navigation slot) so that
// platform backends observe a fully consistent toolbar state.
if (drawerToggleVisibleChanged)
NotifyPropertyChanged(nameof(DrawerToggleVisible));

UpdateTitle();

Func<bool> getDefaultNavBarIsVisible = () =>
Expand Down
8 changes: 8 additions & 0 deletions src/Controls/src/Core/Toolbar/Toolbar.Android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ public static void MapBackButtonVisible(ToolbarHandler arg1, Toolbar arg2) =>
public static void MapIsVisible(ToolbarHandler arg1, Toolbar arg2) =>
MapIsVisible((IToolbarHandler)arg1, arg2);

public static void MapDrawerToggleVisible(ToolbarHandler arg1, Toolbar arg2) =>
MapDrawerToggleVisible((IToolbarHandler)arg1, arg2);



public static void MapBarTextColor(IToolbarHandler arg1, Toolbar arg2)
Expand Down Expand Up @@ -258,6 +261,11 @@ public static void MapIsVisible(IToolbarHandler arg1, Toolbar arg2)
arg1.PlatformView.UpdateIsVisible(arg2);
}

public static void MapDrawerToggleVisible(IToolbarHandler arg1, Toolbar arg2)
{
arg1.PlatformView.UpdateBackButton(arg2);
}




Expand Down
7 changes: 7 additions & 0 deletions src/Controls/src/Core/Toolbar/Toolbar.Mapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ internal static void RemapForControls()
ToolbarHandler.Mapper.ReplaceMapping<Toolbar, IToolbarHandler>(nameof(Toolbar.BarBackground), MapBarBackground);
ToolbarHandler.Mapper.ReplaceMapping<Toolbar, IToolbarHandler>(nameof(Toolbar.BarTextColor), MapBarTextColor);
#endif
#if ANDROID || TIZEN
// Android and Tizen are the only built-in platforms that render the drawer affordance from the
// toolbar itself, in the same navigation slot as the back button, so a drawer toggle change has
// to re-evaluate that slot. Windows, iOS and MacCatalyst render the flyout toggle from the
// flyout/navigation view instead and never read this property.
ToolbarHandler.Mapper.ReplaceMapping<Toolbar, IToolbarHandler>(nameof(IToolbarDrawerToggleVisible.DrawerToggleVisible), MapDrawerToggleVisible);
#endif
#if WINDOWS
ToolbarHandler.Mapper.ReplaceMapping<Toolbar, IToolbarHandler>(nameof(Toolbar.BackButtonEnabled), MapBackButtonEnabled);
ToolbarHandler.Mapper.ReplaceMapping<Toolbar, IToolbarHandler>(PlatformConfiguration.WindowsSpecific.Page.ToolbarPlacementProperty.PropertyName, MapToolbarPlacement);
Expand Down
8 changes: 8 additions & 0 deletions src/Controls/src/Core/Toolbar/Toolbar.Tizen.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ public static void MapBackButtonVisible(ToolbarHandler handler, Toolbar toolbar)
public static void MapIsVisible(ToolbarHandler handler, Toolbar toolbar) =>
MapIsVisible((IToolbarHandler)handler, toolbar);

public static void MapDrawerToggleVisible(ToolbarHandler handler, Toolbar toolbar) =>
MapDrawerToggleVisible((IToolbarHandler)handler, toolbar);

public static void MapBackButtonAccessibilityLabel(ToolbarHandler handler, Toolbar toolbar) =>
MapBackButtonAccessibilityLabel((IToolbarHandler)handler, toolbar);

Expand Down Expand Up @@ -94,6 +97,11 @@ public static void MapIsVisible(IToolbarHandler handler, Toolbar toolbar)
handler.PlatformView.UpdateIsVisible(toolbar);
}

public static void MapDrawerToggleVisible(IToolbarHandler handler, Toolbar toolbar)
{
handler.PlatformView.UpdateBackButton(toolbar);
}

public static void MapBarBackground(IToolbarHandler handler, Toolbar toolbar)
{
handler.PlatformView.UpdateBarBackgroundColor(toolbar);
Expand Down
9 changes: 8 additions & 1 deletion src/Controls/src/Core/Toolbar/Toolbar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

namespace Microsoft.Maui.Controls
{
public partial class Toolbar : Maui.IToolbar, INotifyPropertyChanged
public partial class Toolbar : Maui.IToolbar, Maui.IToolbarDrawerToggleVisible, INotifyPropertyChanged
{
VisualElement _titleView;
string _title;
Expand Down Expand Up @@ -77,6 +77,13 @@ private protected void SetProperty<T>(ref T backingStore, T value,
return;

backingStore = value;
NotifyPropertyChanged(propertyName);
}

// Used by derived toolbars that assign a backing field directly (to keep a specific update ordering)
// but still need the handler and PropertyChanged subscribers to observe the change afterwards.
private protected void NotifyPropertyChanged(string propertyName)
{
Handler?.UpdateValue(propertyName);
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
Expand Down
Loading
Loading