Skip to content

Conversation

@davidhicks980
Copy link
Contributor

@davidhicks980 davidhicks980 commented Nov 6, 2023

Resolves #60298, notDmDrl/pull_down_button#26

This PR adds a CupertinoMenu, CupertinoNestedMenu, and associated widgets that increase feature parity with iOS.

I'm submitting a draft PR to collect feedback on the overall API and layout design before writing more extensive layout and API tests. I have added a few tests based on the PopupMenu widget, and I've done some manual accessibility testing across iOS, Android, and MacOS. Comments and an example have been added, but could use review. I also added TODO annotations for any particularly pressing questions I have.

I'm still fairly new to Flutter, so I apologize for any unconventional code. Let me know if there are any pieces we need to tear out or rethink. I'm interested to hear different ideas regarding how to solve this menu, so I appreciate you all taking a look!

Also, I used @notDmDrl's repo as a base for my contributions: https://github.com/notDmDrl/pull_down_button, so I'm not sure whether I should contact him with regards to signing a CLA.

Basic example:

import 'package:flutter/cupertino.dart';

/// Flutter code sample for [CupertinoMenuButton]
void main() => runApp(const CupertinoMenuApp());
class CupertinoMenuApp extends StatelessWidget {
  const CupertinoMenuApp({super.key});
  @override
  Widget build(BuildContext context) {
    return const CupertinoApp(
      theme: CupertinoThemeData(brightness: Brightness.light),
      home: CupertinoMenuExample(),
    );
  }
}

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

  @override
  State<CupertinoMenuExample> createState() => _CupertinoMenuExampleState();
}

class _CupertinoMenuExampleState extends State<CupertinoMenuExample> {
  String _checkedValue = 'Cat';
  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      navigationBar: const CupertinoNavigationBar(
        middle: Text('CupertinoMenu Sample'),
      ),
      child: Align(
        alignment: Alignment(0, -0.5),
        child: SizedBox(
          width: 100,
          height: 100,
          child:  CupertinoMenuButton<String>(
            itemBuilder: (BuildContext context) {
              return <CupertinoMenuEntry<String>>[
                CupertinoStickyMenuHeader(
                  leading: Container(
                    width: 42,
                    height: 42,
                    decoration:  ShapeDecoration(
                      shape: CircleBorder(),
                      gradient: LinearGradient(
                        begin: Alignment.topCenter,
                        end: Alignment.bottomCenter,
                        colors: <Color>[
                          CupertinoColors.activeBlue,
                          CupertinoColors.activeBlue.withOpacity(0.5),
                        ],
                      ),
                    ),
                  ),
                  trailing: CupertinoButton(
                    minSize: 34,
                    padding: EdgeInsets.zero,
                    child: Container(
                      width: 34,
                      height: 34,
                      decoration: ShapeDecoration(
                        shape: const CircleBorder(),
                        color: CupertinoColors.systemFill.resolveFrom(context),
                      ),
                      child: Icon(
                        CupertinoIcons.share,
                        color: CupertinoColors.label.resolveFrom(context),
                        size: 16,
                        semanticLabel: 'Triangle',
                      ),
                    ),
                    onPressed: () {},
                  ),
                  subtitle: const Text('Folder'),
                  child: const Text('Downloads'),
                ),
                CupertinoNestedMenu<String>(
                  itemBuilder: (BuildContext context) => <CupertinoMenuEntry<String>>[
                    CupertinoCheckedMenuItem<String>(
                      value: 'Cat',
                      checked: _checkedValue == 'Cat',
                      shouldPopMenuOnPressed: false,
                      onTap: (){
                        setState(() {
                          _checkedValue = 'Cat';
                        });
                      },
                      child: const Text('Cat'),
                    ),
                    CupertinoCheckedMenuItem<String>(
                      value: 'Feline',
                      checked: _checkedValue == 'Feline',
                      onTap: (){
                        setState(() {
                          _checkedValue = 'Feline';
                        });
                      },
                      shouldPopMenuOnPressed: false,
                      child: const Text('Feline'),
                    ),
                    CupertinoCheckedMenuItem<String>(
                      value: 'Kitten',
                      checked: _checkedValue == 'Kitten',
                      onTap: (){
                        setState(() {
                          _checkedValue = 'Kitten';
                        });
                      },
                      shouldPopMenuOnPressed: false,
                      child: const Text('Kitten'),
                    ),
                  ],
                  subtitle:  Text(_checkedValue),
                  title: const TextSpan(text: 'Favorite Animal'),
                ),
                CupertinoMenuItem<String>(
                  trailing: const Icon(
                    CupertinoIcons.textformat_size,
                  ),
                  child: const Text(
                    'Simple',
                  ),
                  onTap: () {},
                ),
                const CupertinoMenuItem<String>(
                  shouldPopMenuOnPressed: false,
                  trailing: Icon(
                    CupertinoIcons.textformat_size,
                  ),
                  isDefaultAction: true,
                  child: Text('Default'),
                ),
                const CupertinoMenuItem<String>(
                  trailing: Icon(CupertinoIcons.cloud_upload),
                  value: 'Disabled',
                  enabled: false,
                  child: Text('Disabled'),
                ),
                const CupertinoMenuActionItem<String>(
                  icon: Icon(
                    CupertinoIcons.triangle,
                    semanticLabel: 'Triangle',
                  ),
                  value: 'Triangle',
                  child: Text('Triangle'),
                ),
                const CupertinoMenuActionItem<String>(
                  icon: Icon(
                    CupertinoIcons.square,
                    semanticLabel: 'Square',
                  ),
                  value: 'Square',
                  child: Text('Square'),
                ),
                const CupertinoMenuActionItem<String>(
                  icon: Icon(
                    CupertinoIcons.circle,
                    semanticLabel: 'Circle',
                  ),
                  value: 'Circle',
                  child: Text('Circle'),
                ),
                const CupertinoMenuActionItem<String>(
                  icon: Icon(
                    CupertinoIcons.star,
                    semanticLabel: 'Star',
                  ),
                  value: 'Star',
                  child: Text('Star'),
                ),
                const CupertinoMenuLargeDivider(),
                const CupertinoMenuItem<String>(
                  value: 'Delete',
                  isDestructiveAction: true,
                  trailing: Icon(
                    CupertinoIcons.delete,
                    semanticLabel: 'Delete',
                  ),
                  child: Text('Delete'),
                ),
              ];
            },
          ),
        ),
      ),
    );
  }
}
Screen.Recording.2023-11-06.at.7.00.25.AM.mov

@MitchellGoodwin

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.
  • All existing and new tests are passing.

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

@github-actions github-actions bot added framework flutter/packages/flutter repository. See also f: labels. f: cupertino flutter/packages/flutter/cupertino repository d: api docs Issues with https://api.flutter.dev/ d: examples Sample code and demos labels Nov 6, 2023
@MitchellGoodwin
Copy link
Contributor

Thank you very much for putting this together! I'll block out some time to go through the design.

On basing it off of the pull_down_package, that might be fine, but the most foolproof way to make the lawyers happy is if @notDmDrl was willing to sign a CLA. If not, that's perfectly fine and we can see what guidance is available.

@notDmDrl
Copy link

notDmDrl commented Nov 7, 2023

@MitchellGoodwin @davidhicks980 I signed CLA

@MitchellGoodwin
Copy link
Contributor

@MitchellGoodwin @davidhicks980 I signed CLA

Thank you!

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.

Welcome @davidhicks980

@Hixie
Copy link
Contributor

Hixie commented Nov 10, 2023

@notDmDrl just for the complete avoidance of doubt, can you confirm whether you are ok with this code landing with the usual Flutter license and copyright rather than your MIT license and copyright?

@notDmDrl
Copy link

@Hixie, yes, I am ok with it :)

If there is anything that needs to be done with my package for this PR to land, please let me know

@davidhicks980
Copy link
Contributor Author

Thanks @Piinks and team for taking a look and giving feedback! I'm still moving things around and fixing issues so I appreciate you all directing me towards any code I may have forgotten to prune. Also, some features that were missing have since been added -- in particular, sticky submenu headers. Whenever I have a moment, I'll draft up an overview of the mechanics of the menu in case any of my comments are ambiguous.

Screen.Recording.2023-11-12.at.7.34.06.AM.mov

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.

As this is still a draft I have not been through it entirely, but I wanted to ask:

  1. Why should this be in the framework instead of in a separate package? We typically ask this in so far as weighing the maintenance burden of adopting this into the framework.
  2. Can it be split up into multiple PRs so it is easier to review? I think I saw mention of different features that have been added here. Landing a basic version and adding features in follow up PRs might be easier to do, since reviewers can better focus on one aspect at a time rather than everything all at once.

@davidhicks980
Copy link
Contributor Author

davidhicks980 commented Nov 14, 2023

As this is still a draft I have not been through it entirely, but I wanted to ask:

  1. Why should this be in the framework instead of in a separate package? We typically ask this in so far as weighing the maintenance burden of adopting this into the framework.

Keeping it as a separate package is something I considered, and it could be done. Flutter is massive, and each additional component has maintenance overhead. That said, there are a few reasons that push me towards adding the menu to the framework:

  1. Menus are used in most default iOS apps, so it does add feature parity to the Cupertino library. For example, the native filesystem menu on iOS uses nested layers for view options: image
  2. The native menu is not something that can be easily replicated by a solo developer, and I couldn't find a good analog component within the Flutter codebase. One of the features I love about Flutter is the ease with which one can utilize the Flutter source code in their own apps. While components like the MenuAnchor offer a simple desktop-style dropdown menu, I think it could be helpful to demonstrate how to handle components with complex animated interactions.
  3. Some of the features in this PR could be applied to other Cupertino widgets. For example, the CupertinoPanListener can apply drag-hover effects found on iOS components to any class that mixes in PanTarget:
/// Can be mixed into a [State] to receive callbacks when a pointer enters or
/// leaves a [PanTarget]. The [PanTarget] is should be an ancestor of a
/// [CupertinoPanListener].
mixin PanTarget<T extends StatefulWidget> on State<T> {
  /// Called when a pointer enters the [PanTarget]. Return true if the pointer
  /// should be considered "on" the [PanTarget], and false otherwise (for
  /// example, when the [PanTarget] is disabled).
  bool didPanEnter();

  /// Called when the pointer leaves the [PanTarget]. If [pointerUp] is true,
  /// then the pointer was lifted while pressed on this menu item.
  void didPanLeave(bool pointerUp);
}

Screen.Recording.2023-11-13.at.11.51.48.PM.mov

  1. I'm sure every open source contributor says this, but I'll keep maintaining this if/when it lands.

With all of that said, I'm also aware of how much work maintaining a codebase as large as Flutter's is, so let me know what you all are thinking.

  1. Can it be split up into multiple PRs so it is easier to review? I think I saw mention of different features that have been added here. Landing a basic version and adding features in follow up PRs might be easier to do, since reviewers can better focus on one aspect at a time rather than everything all at once.

Of course -- would something like this work?

  • PR 1 - CupertinoMenuButton, CupertinoMenu, CupertinoMenuItem, CupertinoMenuDivider, CupertinoMenuLargeDivider, CupertinoMenuController, showCupertinoMenu
  • PR 2 - CupertinoMenuActionItem (horizontal menu items)
  • PR 3 - CupertinoMenuTitle (small section title)
  • PR 4 - CupertinoStickyMenuHeader
  • PR 5 - CupertinoDragListener (drag highlight)
  • PR 6 - CupertinoNestedMenu, CupertinoNestedMenuItemAnchor

I would submit each additional PR after the previous lands. I was actually considering this but was afraid to submit a partially-completed menu, so it's good to hear you had the same thought!

@Piinks
Copy link
Contributor

Piinks commented Nov 27, 2023

Sounds good to me! Thank you for sharing.

Of course -- would something like this work?

  • PR 1 - CupertinoMenuButton, CupertinoMenu, CupertinoMenuItem, CupertinoMenuDivider, CupertinoMenuLargeDivider, CupertinoMenuController, showCupertinoMenu
  • PR 2 - CupertinoMenuActionItem (horizontal menu items)
  • PR 3 - CupertinoMenuTitle (small section title)
  • PR 4 - CupertinoStickyMenuHeader
  • PR 5 - CupertinoDragListener (drag highlight)
  • PR 6 - CupertinoNestedMenu, CupertinoNestedMenuItemAnchor

I would submit each additional PR after the previous lands.

Absolutely! 💯 I would recommend filing an issue for each step (or PR) of the way so it is easier to track.

I was actually considering this but was afraid to submit a partially-completed menu, so it's good to hear you had the same thought!

Rather than committing unfinished portions, or parts that won't work until other changes land, why not land a basic version of the menu that works, then follow after it with additional features and stylings like dividers and headers? We typically do this with large features, so basic functionality is available, and then we add more bells and whistles. :)

I only skimmed this a couple of times so far since it is so large, but is this using MenuAnchor? It is meant to be a primitive for building menus such as these, and may simplify a lot of the code here (as well as reduce that burden of maintenance and bug fixes since it could re-use a bunch of logic 😉 ).

@davidhicks980
Copy link
Contributor Author

davidhicks980 commented Nov 28, 2023

Sounds great.

Rather than committing unfinished portions, or parts that won't work until other changes land, why not land a basic version of the menu that works, then follow after it with additional features and stylings like dividers and headers? We typically do this with large features, so basic functionality is available, and then we add more bells and whistles. :)

Whoops, I meant this, but you said it a lot more clearly!

I only skimmed this a couple of times so far since it is so large, but is this using MenuAnchor? It is meant to be a primitive for building menus such as these, and may simplify a lot of the code here (as well as reduce that burden of maintenance and bug fixes since it could re-use a bunch of logic 😉 ).

I'm asking this here but it could also be asked in the first PR. I spent some time trying to wrap this around the MenuAnchor, but there were a few problems. The implementation appears to have changed since I last took a look -- I think it's now using an OverlayPortal instead of an OverlayEntry, and the bulk of the widget has moved out of the _MenuAnchorState._open method. However, it'd still be hard to work around the following:

  1. There was some material-specific code to work around. For example, _Submenu has padding and constraints that are determined based on Material-specific theming. This is not a huge problem, and probably could be worked around. Before, _Submenu was in the _open method, which made things more difficult.
@override
Widget build(BuildContext context) {
  Widget child = OverlayPortal(
    controller: _overlayController,
    overlayChildBuilder: (BuildContext context) {
     return _Submenu( // Submenu
        anchor: this,
        menuStyle: widget.style,
        alignmentOffset: widget.alignmentOffset ?? Offset.zero,
        menuPosition: _menuPosition,
        clipBehavior: widget.clipBehavior,
        menuChildren: widget.menuChildren,
        crossAxisUnconstrained: widget.crossAxisUnconstrained,
      );
    },
    child: _buildContents(context),
  );
//...
  1. Another issue was with regard to the menu contents being wrapped in a SingleChildScrollView. From my understanding (which may be limited or out of date), sticky headers would be impossible without a CustomScrollView or a custom scrollable implementation. SingleChildScrollView would have been nice to use because it automatically becomes scrollable when the menu contents overflow. Currently, I'm measuring, which is certainly not a great way to determine! I just didn't want to add more code.
Screen.Recording.2023-11-28.at.3.15.28.PM.mov
  1. There is also the coordination of layer interactions using multiple overlays. iOS menu layers move in response to their children growing (see below). The CupertinoMenu component uses a FlowDelegate for almost all positioning and scaling, because each layer could be painted per-frame without performing layout. Otherwise, I would have to either use a [MultiChildLayoutDelegate], which forces a layout per-frame (let me know if I'm wrong here), or I would have to coordinate movement across multiple Overlays (the MenuAnchor method). From my understanding, Overlays are basically Stacks, so using a Position widget across each overlay would involve a per-frame layout. A Transform widget could be used, but it would basically be a FlowDelegate with additional complexity. By using a FlowDelegate and measuring each layer when it is initially opened, all animations can be driven based on the final size of the each menu layer without having to continuously relayout each layer. That said, the topmost layer is still driven by a ConstraintsAnimation, so continuous layout for the top layer cannot be avoided.

I actually began working on this menu using overlays, but there were some more issues I came across. When scaling nested layers, the untransformed position of the nested anchor couldn't be easily be accessed via context.renderObject(), because the position would be affected by individual widget scaling and the scale of the entire menu. Scaling of underlying layers led to fragile alignment between the top nested anchor and the bottom nested anchor, which need to align perfectly after the menu has fully closed. MatrixUtils could be used to perform an inverse transform, but complexity grew to a point where the FlowDelegate appeared to be a much simpler solution.

Simulator.Screen.Recording.-.iPhone.15.Pro.-.2023-11-28.at.15.50.45.mp4
Simulator.Screen.Recording.-.iPhone.15.Pro.-.2023-11-28.at.16.07.32.mp4
  1. This has less to do with the MenuAnchor, but I figured I'd explain it since I'm explaining some of the other design decisions. The iOS version appears to use a physics simulation. To create the same effect, the CupertinoMenu implementation uses a bespoke AnimationController whose AnimationStatus can be manually set for each layer, because Simulation animations will not otherwise report a reverse/dismissed status. Each animation is added into a single animation that controls the entire menu's position, which is passed into the FlowDelegate for positioning. I found it more intuitive to use the animation itself as a source of truth rather than synchronizing an animation and a controller since the animation's value directly corresponds to the state of the menu. For example, users would be able to determine whether the menu is 50% or 80% closed based on the Animation's value. That said, this may violate separation of concerns.
// Runs a simulation on the given animation controller, and returns whether or
// not the simulation has stopped running.
Future<bool> _runSimulation({
  required _AnimationControllerWithStatusOverride controller,
  required SpringSimulation simulation,
  required bool forward,
}) {
  final Completer<bool> simulationCompleter = Completer<bool>();

// Each layer has an animation controller that serves as a source-of-truth 
// regarding the opened/opening/closed/closing status of the menu layer.
  controller
    ..overrideStatus(
        forward
        ? AnimationStatus.forward
        : AnimationStatus.reverse,
    )
    ..animateWith(simulation)
        .whenCompleteOrCancel(() {
          if (!controller.isAnimating) {
            controller.overrideStatus(
                forward
                ? AnimationStatus.completed
                : AnimationStatus.dismissed,
            );
            simulationCompleter.complete(true);
          } else {
            simulationCompleter.complete(false);
          }
        });
  return simulationCompleter.future;
}

// Sums all current layer animations. A function is used instead of a
// CompoundAnimation to make it easier to add/remove animations.
void _updateAnimationValue() {
  double value = 0;
  for (
    final _AnimationControllerWithStatusOverride controller
    in _layerIdToAnimation.values
  ) {
    value += controller.value;
  }

  _nestingAnimation?.value = value;
}
Simulator.Screen.Recording.-.iPhone.15.Pro.-.2023-11-28.at.16.54.03.mp4
Simulator.Screen.Recording.-.iPhone.15.Pro.-.2023-11-28.at.16.53.32.mp4

Anyways, sorry for the essay! Also, I went back and fixed some of the language.. let me know if it makes sense, though. I'll start splitting things up and sending PRs to get feedback. Thank you for taking the time to look through everything -- hopefully we can start landing features!

@mark8044
Copy link

mark8044 commented Dec 5, 2023

@davidhicks980 These screenshots look epic. Anxiously awaiting this! Thanks so much for your work 👍 🥇 👍 🥇

@davidhicks980
Copy link
Contributor Author

davidhicks980 commented Dec 5, 2023

Quick update on this: I did a small rewrite to use a MenuAnchor that exposed an "overlayChildBuilder" on the MenuAnchor base class and it seems to work well at the moment. Also removed most of the hacky bits. I'll submit a PR soon.

@protected
Widget overlayChildBuilder(BuildContext context) {
  return _Submenu(
    anchor: this,
    menuStyle: widget.style,
    alignmentOffset: widget.alignmentOffset ?? Offset.zero,
    menuPosition: _menuPosition,
    clipBehavior: widget.clipBehavior,
    menuChildren: widget.menuChildren,
    crossAxisUnconstrained: widget.crossAxisUnconstrained,
  );
}

…onsider preventing scroll once the menu layer begins closing.
@Void48
Copy link

Void48 commented Jan 24, 2024

@davidhicks980 Thanks for the work! Looking forward to it

@JaidynBluesky
Copy link

Any update on this? Would love for some way to try this out in the interim @davidhicks980, this is just what I need for my app.

@davidhicks980
Copy link
Contributor Author

davidhicks980 commented Feb 20, 2024

@JaidynBluesky If you need all of the features right now, you should be able to copy the menu_item.dart and the menu.dart: https://github.com/flutter/flutter/blob/6bd90293300da86e31d9f0644c5e21a8e52c1a82/packages/flutter/lib/src/cupertino/menu.dart
and
https://github.com/flutter/flutter/blob/6bd90293300da86e31d9f0644c5e21a8e52c1a82/packages/flutter/lib/src/cupertino/menu_item.dart .

Just place menu.dart and menu_item.dart in your project folder and replace the local imports with a single "import package:flutter/cupertino":

/// In menu.dart + menu_item.dart

/// Replace this: 
import 'colors.dart';
import 'constants.dart';
import 'icons.dart';

/// With this
import 'package:flutter/cupertino.dart';

From there, you should just be able to import like any other file:

/// Import from wherever you copied the files
import 'menu_item.dart';
import 'menu.dart';

///.... Use like any other widget

CupertinoMenuButton<int>(
    itemBuilder: (BuildContext context) {
      return <CupertinoMenuEntry<int>>[
       const CupertinoMenuItem<int>(value: 1, child: Text('A')),
       const CupertinoMenuItem<int>(value: 2, child: Text('B')),
       const CupertinoMenuItem<int>(value: 3, child: Text('C')),
        CupertinoNestedMenu<int>(
          itemBuilder: (BuildContext context) {
            return [
                 const CupertinoMenuItem<int>(value: 1, child: Text('D')),
                 const CupertinoMenuItem<int>(value: 2, child: Text('E')),
                 const CupertinoMenuItem<int>(value: 3, child: Text('F')),
            ];
          },
          subtitle: const Text('Subtitle'),
          title: const TextSpan(text: 'Nested Menu'),
        ),
      ];
    },
    child: Icon(CupertinoIcons.plus, size: 24)
  );
///....

(I'm not on a device I can test at the moment, so sorry in advance for any errors you may encounter. Let me know if anything is complicated though):

That said, if you only need a simple menu with a single layer of items, the new version based on MenuAnchor will probably be closer to the final version of the menu, and has many more tests:
#143712

@Piinks
Copy link
Contributor

Piinks commented Apr 23, 2024

Hey @davidhicks980, we just came across this in triage today. Is #143712 meant to replace this PR? If so, should we close this?

@davidhicks980
Copy link
Contributor Author

@Piinks - yes to both! I'll go ahead and close.

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

8 participants