Skip to content

Conversation

@davidhicks980
Copy link
Contributor

@davidhicks980 davidhicks980 commented Feb 19, 2024

This PR implements CupertinoMenuAnchor, CupertinoMenuItem, CupertinoLargeMenuDivider using MenuAnchor as a base.

Resolves #60298, notDmDrl/pull_down_button#26, #137936

Tests modeled after MenuAnchor have been added to cover the API surface... perhaps too many tests. I split CupertinoMenuItem tests from CupertinoMenuAnchor tests to improve performance.

Four examples have been added with tests

  • Simple example demonstrating CupertinoMenuItem styling
  • Menu button example based on material/menu_anchor.0.dart
  • Context menu example based on simplified material/menu_anchor.1.dart
  • Nested example that replicates the iOS history stack. This one is more complex and may need to be removed.

Before implementing tests for the modifications made to MenuAnchor (a MenuAnchor.withOverlayBuilder constructor was added), I wanted to get some feedback. I initially made MenuAnchorState public and made the overlayBuilder a protected method, but found that adding a separate constructor required fewer changes and felt simpler overall. The tradeoff is that users would need to know how to properly set up semantics, focus, and actions if they were to use this constructor.

Also, I'm curious whether it would be valuable for me to fullfill this PR: #135025. I attempted to build a generic Panel widget that would encompass functionality and animations without providing structure, but I decided to stick to a wrapper until I heard feedback.

Some features that were going to be included in a future PR made it into this PR because they were either necessary to fulfill a core functionality of the menu (_PanRegion, which is modeled after _TapRegion, and _CupertinoMenuDivider), or they were so simple that it'd be more work to leave them out (CupertinoLargeMenuDivider). In the case of the former, all classes are private, fully documented, and have been tested in the context of their use in the menu anchor. With regard to CupertinoLargeMenuDivider, it has tests, documentation, and a public API.

A few loose ends:

  • LocalizedShortcutLabeler was made public rather than implementing a Cupertino version, seeing as it appears to have already accounted for iOS and MacOS and has public documentation/tests
  • Should accelerators be included?
  • Should the ability to customize the menu surface be removed?
  • To account for features like a sticky header, the menu's default scrollable is a CustomScrollView that is shrink wrapped. This introduces a performance penalty by default, although shrink wrapping can be disabled. Is there a better alternative?

Last, CupertinoMenuItem, CupertinoMenuAnchor, MenuItemButtons, and MenuAnchor all interop and can be swapped out pretty easily.

Screen.Recording.2024-02-19.at.12.47.24.PM.mov

Pre-launch Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I read and followed the Flutter Style Guide, including Features we expect every widget to implement.
  • I signed the CLA.
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is test-exempt.
    • Missing MenuAnchor tests
  • All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel on Discord.

@Piinks @MitchellGoodwin Sorry in advance if you guys already saw this, but I forgot to tag you both

@github-actions github-actions bot added framework flutter/packages/flutter repository. See also f: labels. f: material design flutter/packages/flutter/material repository. f: cupertino flutter/packages/flutter/cupertino repository d: api docs Issues with https://api.flutter.dev/ d: examples Sample code and demos labels Feb 19, 2024
@goderbauer
Copy link
Member

Hello @davidhicks980, sorry for the delay on this one. It looks like this change is failing some checks. Could you take a look at those and fix the issues? Thanks.

@davidhicks980
Copy link
Contributor Author

davidhicks980 commented Apr 24, 2024

@goderbauer Sure thing! I'll take a look later this week.

Edit 04/29: I have to make a few changes to accommodate text height differences across platforms (particularly browser), and only got around to some of the changes this weekend. I'll have more time later this week/weekend to finish up.

@davidhicks980
Copy link
Contributor Author

davidhicks980 commented May 10, 2024

Okay, assuming I correctly fixed the last four linux warnings, this should be good for a prelim look!

There is a lot of weirdness in this PR, particularly with regard to color blending. I tried to explain most of it, but please let me know if anything doesn't make sense.

Also, as I've already mentioned, the _PanRegion and related classes were added to match native behavior, but it may make sense to split this feature off into a separate file. I wanted to get feedback on it before doing so, as the implementation I've written is primitive (e.g. there is no way to isolate pans to a single region). I'd also be curious to hear whether we should incorporate menu text theming into the CupertinoTheme class... but I was afraid to add too much complexity with this commit.

Copy link
Contributor

@Piinks Piinks left a comment

Choose a reason for hiding this comment

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

So excited about this! I have not reviewed all of the PR, the import of material into cupertino will need to be resolved first.

Also, in the earlier PR, we talked about splitting this up over multiple PRs. A change of this size is difficult to review and verify test coverage for. There are some changes in the material library in addition to new cupertino widgets here. Is it possible we can split this up a bit?

Comment on lines 17 to 19
import '../material/material_localizations.dart'
show MaterialLocalizations;
import '../material/menu_anchor.dart'
Copy link
Contributor

Choose a reason for hiding this comment

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

This is not a valid import, the Cupertino library cannot import the material library, since material already imports cupertino. It creates a circular dependency between the libraries.

Copy link
Contributor Author

@davidhicks980 davidhicks980 May 15, 2024

Choose a reason for hiding this comment

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

The circular dependency problem is something I was unsure of how to fix. I asked a (long winded) question in the Discord chat, but no one got back to me (see here). Would you suggest I try moving the MenuAnchor core to the Widgets library, or do you have a suggestion? I'm mostly concerned about compromising any plans you all have, but I wasn't sure who to ask.

Copy link
Contributor

Choose a reason for hiding this comment

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

Would you suggest I try moving the MenuAnchor core to the Widgets library

This might be an option, let me ask some folks about it and circle back.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good! I'll see what parts I can shed off of the CupertinoMenuAnchor to make it smaller.

Copy link
Contributor

Choose a reason for hiding this comment

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

Moving MenuAnchor and MenuController --> widgets SGT-everyone I spoke with! Is this something you are interested in doing in a separate change? If not, I can send a PR. :)

The only material-specific bit we would need to leave in material would be MenuAnchor.menuStyle. So this would probably look like a RawMenuAnchor class in widgets, with MenuAnchor as a subclass in material so it can have the MenuStyle.

Copy link
Contributor Author

@davidhicks980 davidhicks980 May 16, 2024

Choose a reason for hiding this comment

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

That's exciting! I'd be happy to. I believe I tried in the past and there are a few tricky dependencies between the two (mostly related to _Submenu, which depends on _MenuAnchorState internals, material theming, and DismissMenuAction, and MenuDirectionalFocusAction) but I'll go ahead and get a PR in so that we can discuss. I'll be out-of-town this weekend, but will submit ASAP!

const bool _kDebugMenus = false;

/// Whether [defaultTargetPlatform] is an Apple platform (Mac or iOS).
bool get _isApple {
Copy link
Contributor

Choose a reason for hiding this comment

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

We would use _isCupertino here. Avoiding brand names.

Copy link
Contributor Author

@davidhicks980 davidhicks980 May 15, 2024

Choose a reason for hiding this comment

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

Fixed! This was in the material repo so I submitted a PR here: #148432.

@mark8044

This comment was marked as off-topic.

@MaherSafadii

This comment was marked as off-topic.

@MaherSafadii

This comment was marked as off-topic.

@laterdayi

This comment was marked as off-topic.

@davidhicks980
Copy link
Contributor Author

Whoops, I overlooked these comments. Because there is a circular dependency on the Material library, we decided to split the material MenuAnchor into a RawMenuAnchor base that lives in the Widgets library. In order to do that, I wanted to refactor the focus behavior to make it simpler to split. That PR is here: #150950. I've also improved the CupertinoPopupSurface (see #151430) to be used with this menu so it doesn't have to rely on a bespoke surface implementation. Once underlying issues are worked out, we'll commit a RawMenuAnchor, which can then be used by this menu. So for right now, I suggest using https://github.com/notDmDrl/pull_down_button for a full-featured, easily-customizable menu (recommended), or you can just copy the code from this PR and import it into your project.

@laterdayi

This comment was marked as off-topic.

@MitchellGoodwin
Copy link
Contributor

Please try and keep comments asking for status updates and any other comments not directly related to the implementation on the original issue. The PRs themselves should be mostly technical discussion to make it easier to review and possible investigations later after this is merged.

@Piinks
Copy link
Contributor

Piinks commented Sep 24, 2024

(An update from PR triage): There is work outside of this PR ongoing to unblock it.

@davidhicks980 davidhicks980 mentioned this pull request Nov 11, 2024
9 tasks
@goderbauer
Copy link
Member

(triage): This is still blocked on #158255.

github-merge-queue bot pushed a commit that referenced this pull request Feb 4, 2025
This PR adds a `RawMenuAnchor()` widget to widgets.dart. The purpose of
this widget is to provide a menu primitive for the Material and
Cupertino libraries (and others) to build upon. Additionally, this PR
makes MenuController an inherited widget to simplify nested access to
the menu (e.g. if you want to launch a context menu from a deeply-nested
widget).

This PR:
* Centralizes core menu logic to a private class,` _RawMenuAnchor()`, 
  * Provides the internals for interacting with menus:
    * TapRegion interop
    * DismissMenuAction handler
    * Close on scroll/resize
    * Focus traversal information, if applicable
* Subclasses override `_open`, `_close`, `_isOpen`, `_buildAnchor`, and
`_menuScopeNode`
* State is accessible by descendents via
`MenuController.maybeOf(context)._anchor`
* Adds 2 public constructors, backed by a `_RawMenuAnchor()` that
contains shared logic.
  * `RawMenuAnchor()`
    * Users build the overlay from scratch.
* Provides anchor/overlay position information and TapRegionGroupId to
builder
    * Does not provide FocusScope management. 
  * `RawMenuAnchorGroup()`
    * A primitive for menus that do not have overlays (menu bars). 
* This was previously called RawMenuAnchor.node(), but @dkwingsmt made a
good case for splitting out the constructor.

<s>Documentation examples have been added, and can be viewed at
https://menu-anchor.web.app/</s>


<s>https://github.com/user-attachments/assets/25d35f23-2aad-4d07-9172-5c3fd65d53cf</s>

@dkwingsmt 

List which issues are fixed by this PR.
#143712

Some issues that need to be addressed:

Semantics:
<img width="1027" alt="image"
src="https://github.com/user-attachments/assets/d69661c9-8435-4d9c-b200-474968cb57eb">
I'm basing the menu semantics off of the comment
[here](https://github.com/flutter/engine/blob/ef3ca70db20230436b6defe5b4cdb0d242deea96/lib/web_ui/lib/src/engine/semantics/semantics.dart#L382),
but I'm unsure whether the route should be given a name. There is no
menubar/menu/menuitem role in Flutter, so I'm assuming the menu should
be composed of nested dialogs

<s>Unlike the menubar pattern from
[W3C](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/examples/menubar-navigation/),
the RawMenuAnchor

- does not close on tab/shift-tab. I left this behavior out of the menu
so that users could customize tab behavior, but I'm not opinionated
either way
- does not open on ArrowUp/ArrowDown, because this could interfere with
user focus behavior in unconventional menu setups (e.g. a vertical
menu).
- does not automatically focus the first item in a menu overlay when
activated via enter/spacebar, but does focus the first item when
horizontal traversal opens a submenu. Automatically focusing the first
item whenever an overlay opens interferes with hover traversal, and I
couldn't think of a good way to only focus the first item when an
overlay is triggered via enter/spacebar.
- doesn't focus disabled items (I wasn't sure how to address this
without editing MenuItemButton)

While it is possible to nest menus -- for example, a dropdown anchor
within a full-app context menu area -- nested menus behave as a single
group. I was considering adding an additional parameter that separates
nested root menus from their parents, and am interested to hear your
feedback.</s>

*If you had to change anything in the [flutter/tests] repo, include a
link to the migration guide as per the [breaking change policy].*

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

---------

Co-authored-by: Bruno Leroux <[email protected]>
Co-authored-by: chunhtai <[email protected]>
@MaherSafadii
Copy link

Update: The blocking PR has been merged

@davidhicks980
Copy link
Contributor Author

Update: The blocking PR has been merged

I need to file one more PR before I can come back to this. There needs to be a way for "MenuController.close()" to animate the menu closed, without having to create a whole new menu controller.

@Piinks
Copy link
Contributor

Piinks commented Apr 18, 2025

👋 Hey @davidhicks980 checking in from stale PR triage, I know you have been really hard at work landing changes to support this. 💙 What's the current status?

@davidhicks980
Copy link
Contributor Author

Howdy @Piinks ,

I'm waiting for #165556 to land. Then, this PR will be unblocked and it'll be relatively straightforward -- basically just adapting tests.

@davidhicks980 davidhicks980 force-pushed the feat/cupertino_menu_anchor branch from 97b55f1 to 05b71be Compare May 12, 2025 06:32
github-merge-queue bot pushed a commit that referenced this pull request Jun 24, 2025
Alternative to #163481,
#167537,
#163481 that uses callbacks.

@dkwingsmt - you inspired me to simplify the menu behavior. I didn't end
up using Actions, mainly because nested behavior was unwieldy and
capturing BuildContext has drawbacks. This uses a basic callback
mechanism to animate the menu open and closed. Check out the examples.

<hr />

### The problem
RawMenuAnchor synchronously shows or hides an overlay menu in response
to `MenuController.open()` and `MenuController.close`, respectively.
Because animations cannot be run on a hidden overlay, there currently is
no way for developers to add animations to RawMenuAnchor and its
subclasses (MenuAnchor, DropdownMenuButton, etc).

### The solution
This PR:
- Adds two callbacks -- `onOpenRequested` and `onCloseRequested` -- to
RawMenuAnchor.
- onOpenRequested is called with a position and a showOverlay callback,
which opens the menu when called.
- onCloseRequested is called with a hideOverlay callback, which hides
the menu when called.

When `MenuController.open()` and `MenuController.close()` are called,
onOpenRequested and onCloseRequested are invoked, respectively.

Precursor for #143416,
#135025,
#143712

## Demo


https://github.com/user-attachments/assets/bb14abca-af26-45fe-8d45-289b5d07dab2

```dart
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// ignore_for_file: public_member_api_docs

import 'dart:ui' as ui;

import 'package:flutter/material.dart' hide MenuController, RawMenuAnchor, RawMenuOverlayInfo;

import 'raw_menu_anchor.dart';

/// Flutter code sample for a [RawMenuAnchor] that animates a simple menu using
/// [RawMenuAnchor.onOpenRequested] and [RawMenuAnchor.onCloseRequested].
void main() {
  runApp(const App());
}

class Menu extends StatefulWidget {
  const Menu({super.key});

  @OverRide
  State<Menu> createState() => _MenuState();
}

class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
  late final AnimationController animationController;
  final MenuController menuController = MenuController();

  @OverRide
  void initState() {
    super.initState();
    animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
  }

  @OverRide
  void dispose() {
    animationController.dispose();
    super.dispose();
  }

  void _handleMenuOpenRequest(Offset? position, void Function({Offset? position}) showOverlay) {
    // Mount or reposition the menu before animating the menu open.
    showOverlay(position: position);

    if (animationController.isForwardOrCompleted) {
      // If the menu is already open or opening, the animation is already
      // running forward.
      return;
    }

    // Animate the menu into view. This will cancel the closing animation.
    animationController.forward();
  }

  void _handleMenuCloseRequest(VoidCallback hideOverlay) {
    if (!animationController.isForwardOrCompleted) {
      // If the menu is already closed or closing, do nothing.
      return;
    }

    // Animate the menu out of view.
    //
    // Be sure to use `whenComplete` so that the closing animation
    // can be interrupted by an opening animation.
    animationController.reverse().whenComplete(() {
      if (mounted) {
        // Hide the menu after the menu has closed
        hideOverlay();
      }
    });
  }

  @OverRide
  Widget build(BuildContext context) {
    return RawMenuAnchor(
      controller: menuController,
      onOpenRequested: _handleMenuOpenRequest,
      onCloseRequested: _handleMenuCloseRequest,
      overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
        final ui.Offset position = info.anchorRect.bottomLeft;
        return Positioned(
          top: position.dy + 5,
          left: position.dx,
          child: TapRegion(
            groupId: info.tapRegionGroupId,
            child: Material(
              color: ColorScheme.of(context).primaryContainer,
              shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
              elevation: 3,
              child: SizeTransition(
                sizeFactor: animationController,
                child: const SizedBox(
                  height: 200,
                  width: 150,
                  child: Center(child: Text('Howdy', textAlign: TextAlign.center)),
                ),
              ),
            ),
          ),
        );
      },
      builder: (BuildContext context, MenuController menuController, Widget? child) {
        return FilledButton(
          onPressed: () {
            if (animationController.isForwardOrCompleted) {
              menuController.close();
            } else {
              menuController.open();
            }
          },
          child: const Text('Toggle Menu'),
        );
      },
    );
  }
}

class App extends StatelessWidget {
  const App({super.key});

  @OverRide
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue)),
      home: const Scaffold(body: Center(child: Menu())),
    );
  }
}

```

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

---------

Co-authored-by: Tong Mu <[email protected]>
mboetger pushed a commit to mboetger/flutter that referenced this pull request Jul 21, 2025
Alternative to flutter#163481,
flutter#167537,
flutter#163481 that uses callbacks.

@dkwingsmt - you inspired me to simplify the menu behavior. I didn't end
up using Actions, mainly because nested behavior was unwieldy and
capturing BuildContext has drawbacks. This uses a basic callback
mechanism to animate the menu open and closed. Check out the examples.

<hr />

### The problem
RawMenuAnchor synchronously shows or hides an overlay menu in response
to `MenuController.open()` and `MenuController.close`, respectively.
Because animations cannot be run on a hidden overlay, there currently is
no way for developers to add animations to RawMenuAnchor and its
subclasses (MenuAnchor, DropdownMenuButton, etc).

### The solution
This PR:
- Adds two callbacks -- `onOpenRequested` and `onCloseRequested` -- to
RawMenuAnchor.
- onOpenRequested is called with a position and a showOverlay callback,
which opens the menu when called.
- onCloseRequested is called with a hideOverlay callback, which hides
the menu when called.

When `MenuController.open()` and `MenuController.close()` are called,
onOpenRequested and onCloseRequested are invoked, respectively.

Precursor for flutter#143416,
flutter#135025,
flutter#143712

## Demo


https://github.com/user-attachments/assets/bb14abca-af26-45fe-8d45-289b5d07dab2

```dart
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// ignore_for_file: public_member_api_docs

import 'dart:ui' as ui;

import 'package:flutter/material.dart' hide MenuController, RawMenuAnchor, RawMenuOverlayInfo;

import 'raw_menu_anchor.dart';

/// Flutter code sample for a [RawMenuAnchor] that animates a simple menu using
/// [RawMenuAnchor.onOpenRequested] and [RawMenuAnchor.onCloseRequested].
void main() {
  runApp(const App());
}

class Menu extends StatefulWidget {
  const Menu({super.key});

  @OverRide
  State<Menu> createState() => _MenuState();
}

class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
  late final AnimationController animationController;
  final MenuController menuController = MenuController();

  @OverRide
  void initState() {
    super.initState();
    animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
  }

  @OverRide
  void dispose() {
    animationController.dispose();
    super.dispose();
  }

  void _handleMenuOpenRequest(Offset? position, void Function({Offset? position}) showOverlay) {
    // Mount or reposition the menu before animating the menu open.
    showOverlay(position: position);

    if (animationController.isForwardOrCompleted) {
      // If the menu is already open or opening, the animation is already
      // running forward.
      return;
    }

    // Animate the menu into view. This will cancel the closing animation.
    animationController.forward();
  }

  void _handleMenuCloseRequest(VoidCallback hideOverlay) {
    if (!animationController.isForwardOrCompleted) {
      // If the menu is already closed or closing, do nothing.
      return;
    }

    // Animate the menu out of view.
    //
    // Be sure to use `whenComplete` so that the closing animation
    // can be interrupted by an opening animation.
    animationController.reverse().whenComplete(() {
      if (mounted) {
        // Hide the menu after the menu has closed
        hideOverlay();
      }
    });
  }

  @OverRide
  Widget build(BuildContext context) {
    return RawMenuAnchor(
      controller: menuController,
      onOpenRequested: _handleMenuOpenRequest,
      onCloseRequested: _handleMenuCloseRequest,
      overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
        final ui.Offset position = info.anchorRect.bottomLeft;
        return Positioned(
          top: position.dy + 5,
          left: position.dx,
          child: TapRegion(
            groupId: info.tapRegionGroupId,
            child: Material(
              color: ColorScheme.of(context).primaryContainer,
              shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
              elevation: 3,
              child: SizeTransition(
                sizeFactor: animationController,
                child: const SizedBox(
                  height: 200,
                  width: 150,
                  child: Center(child: Text('Howdy', textAlign: TextAlign.center)),
                ),
              ),
            ),
          ),
        );
      },
      builder: (BuildContext context, MenuController menuController, Widget? child) {
        return FilledButton(
          onPressed: () {
            if (animationController.isForwardOrCompleted) {
              menuController.close();
            } else {
              menuController.open();
            }
          },
          child: const Text('Toggle Menu'),
        );
      },
    );
  }
}

class App extends StatelessWidget {
  const App({super.key});

  @OverRide
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue)),
      home: const Scaffold(body: Center(child: Menu())),
    );
  }
}

```

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

---------

Co-authored-by: Tong Mu <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

d: api docs Issues with https://api.flutter.dev/ d: examples Sample code and demos f: cupertino flutter/packages/flutter/cupertino repository f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pull-Down Menus for iOS 14

7 participants