Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement MenuControllerDecorator for animating menus. #163481

Open
wants to merge 15 commits into
base: master
Choose a base branch
from

Conversation

davidhicks980
Copy link
Contributor

@davidhicks980 davidhicks980 commented Feb 17, 2025

This PR adds a MenuControllerDecorator mixin class that intercepts calls to MenuController.open() and MenuController.close() to allow users to run animations or add a delay prior to a menu opening or closing.

Additionally, this PR adds a MenuController.animationStatus getter and a MenuController.maybeAnimationStatusOf static method to interrogate the current animation status of the nearest menu. I was torn between creating a new enum, such as

enum MenuStatus  {
  opened,
  opening,
  closed,
  closing
}

and sticking with AnimationStatus. I stuck with AnimationStatus, but let me know if you have a preference.

Precursor for #143416, #135025, #143712

Demo

Screen.Recording.2025-02-17.at.8.58.43.AM.mov

Basic usage:

class _MenuAnimator extends MenuControllerDecorator {
  const _MenuAnimator({required super.menuController, required this.animationController});
  final AnimationController animationController;

  @override
  void handleMenuOpenRequest({ui.Offset? position}) {
    // Call whenComplete() rather than whenCompleteOrCancel() to avoid marking
    // the menu as opened when the [AnimationStatus] moves from forward to
    // reverse.
    animationController.forward().whenComplete(markMenuOpened);
  }

  @override
  void handleMenuCloseRequest() {
    // Animate the children of this menu closed.
    closeChildren();
    animationController.reverse().whenComplete(markMenuClosed);
  }
}

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

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

class _MenuState extends State<MenuControllerDecoratorExample>
    with SingleTickerProviderStateMixin {
  late final AnimationController animationController;
  late final _MenuAnimator menuAnimator;

  @override
  void initState() {
    super.initState();
    animationController = AnimationController.unbounded(vsync: this);
    menuAnimator = _MenuAnimator(
      menuController: MenuController(),
      animationController: animationController,
    );
  }

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

 @override
  Widget build(BuildContext context) {
    return RawMenuAnchor(
      controller: menuAnimator,  // Pass in the decorator
      overlayBuilder: (context, info) {
          // Use your animation controller to animate the menu
      },
      child: widget.child
    );
  }
Full example using simulations
/// Flutter code sample for a [MenuControllerDecorator] that animates a
/// [RawMenuAnchor] with a [SpringSimulation].
void main() {
  runApp(const MenuControllerDecoratorApp());
}

class _MenuAnimator extends MenuControllerDecorator {
  const _MenuAnimator({required super.menuController, required this.animationController});

  final AnimationController animationController;
  SpringSimulation get forwardSpring => SpringSimulation(
    SpringDescription.withDampingRatio(mass: 1.0, stiffness: 150, ratio: 0.7),
    animationController.value,
    1.0,
    0.0,
  );
  SpringSimulation get reverseSpring => SpringSimulation(
    SpringDescription.withDampingRatio(mass: 1.0, stiffness: 200, ratio: 0.7),
    animationController.value,
    0.0,
    0.0,
  );

  @override
  void handleMenuOpenRequest({ui.Offset? position}) {
    // Call whenComplete() rather than whenCompleteOrCancel() to avoid marking
    // the menu as opened when the [AnimationStatus] moves from forward to
    // reverse.
    animationController.animateWith(forwardSpring).whenComplete(markMenuOpened);
  }

  @override
  void handleMenuCloseRequest() {
    // Call whenComplete() rather than whenCompleteOrCancel() to avoid marking
    // the menu as closed when the [AnimationStatus] moves from reverse to
    // forward.
    animationController.animateBackWith(reverseSpring).whenComplete(markMenuClosed);
  }
}

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

  @override
  State<MenuControllerDecoratorExample> createState() => _MenuControllerDecoratorExampleState();
}

class _MenuControllerDecoratorExampleState extends State<MenuControllerDecoratorExample>
    with SingleTickerProviderStateMixin {
  late final AnimationController animationController;
  late final _MenuAnimator menuController;

  @override
  void initState() {
    super.initState();
    // Use an unbounded animation controller to allow simulations to run
    // indefinitely.
    animationController = AnimationController.unbounded(vsync: this);
    menuController = _MenuAnimator(
      menuController: MenuController(),
      animationController: animationController,
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return RawMenuAnchor(
      controller: menuController,
      overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
        // Center the menu below the anchor.
        final ui.Offset position = info.anchorRect.bottomCenter.translate(-75, 4);
        final ColorScheme colorScheme = ColorScheme.of(context);
        return Positioned(
          top: position.dy,
          left: position.dx,
          child: Semantics(
            explicitChildNodes: true,
            scopesRoute: true,
            child: ExcludeFocus(
              excluding: MenuController.maybeAnimationStatusOf(context) == AnimationStatus.reverse,
              child: TapRegion(
                groupId: info.tapRegionGroupId,
                onTapOutside: (PointerDownEvent event) {
                  menuController.close();
                },
                child: ScaleTransition(
                  scale: animationController.view,
                  child: FadeTransition(
                    opacity: animationController.drive(
                      Animatable<double>.fromCallback(
                        (double value) => ui.clampDouble(value, 0, 1),
                      ),
                    ),
                    child: Material(
                      elevation: 8,
                      clipBehavior: Clip.antiAlias,
                      borderRadius: BorderRadius.circular(16),
                      color: colorScheme.primary,
                      child: SizeTransition(
                        axisAlignment: -1,
                        sizeFactor: animationController.view,
                        fixedCrossAxisSizeFactor: 1.0,
                        child: SizedBox(
                          height: 200,
                          width: 150,
                          child: Text(
                            'ANIMATION STATUS:\n${animationController.status.name}',
                            textAlign: TextAlign.center,
                            style: TextStyle(color: colorScheme.onPrimary, fontSize: 12),
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ),
        );
      },
      builder: (BuildContext context, MenuController menuController, Widget? child) {
        return FilledButton(
          onPressed: () {
            if (menuController.animationStatus.isForwardOrCompleted) {
              menuController.close();
            } else {
              menuController.open();
            }
          },
          child: Text(menuController.animationStatus.isForwardOrCompleted ? 'Close' : 'Open'),
        );
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.from(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.blue,
          dynamicSchemeVariant: DynamicSchemeVariant.vibrant,
        ),
      ),
      home: const Scaffold(body: Center(child: MenuControllerDecoratorExample())),
    );
  }
}
How it works/why it's complicated

Say you have this simplified setup

// Simplified version of MenuAnchor
class AnimatedMenu extends StatefulWidget {
  const AnimatedMenu({
    super.key,
    required this.userController,
  });

  // ~~ REASON FOR COMPLEXITY ~~
  // This is an external controller, passed in by a user.
  // 
  // We want to allow users to call userController.open() and have menu animate open, 
  // without giving the user access to the internal decorated controller
  final MenuController userController;

  @override
  State<AnimatedMenu> createState() => _AnimatedMenuState();
}

class _DecoratedMenuState extends State<DecoratedMenu> {
  // Wraps the userController to intercept open and close requests
  late MenuControllerDecorator _menuAnimator;

  @override
  void initState() {
    super.initState();
    animationController = AnimationController(
      duration: const Duration(milliseconds: 200),
      vsync: this,
    );
    _menuAnimator = _MenuAnimator(
      menuController: widget.userController,
    );
  }

  @override
  void didUpdateWidget(covariant DecoratedMenu oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.userController != oldWidget.userController) {
      _menuAnimator = _MenuAnimator(
        menuController: widget.userController,
        animationController: animationController,
      );
    }
  }

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

  @override
  Widget build(BuildContext context) {
    // Build the menu
    return RawMenuAnchor(
      controller: _menuAnimator,
      // .. 
     )
  }
}

Changes in this PR:

I'll use menu in place of _RawMenuAnchorBaseMixin, which is the class that controls the internal state of the menu.

Before change:

userController.open() -> menu.open()

After change:
With controllers that are not animated:

userController.open() -> menu.requestOpen() -> userController._handleOpenRequest() -> menu.open()

With controllers that are animated (such as the AnimatedMenu example):

userController.open() -> menu.requestOpen() -> animatedController._handleOpenRequest() -> menu.open()

So, the important bit is that _handleOpenRequest() will be called on the controller that is passed into the anchor (animatedController in the animated example, and userController in the sync example), regardless of which controller is calling open(). _handleOpenRequest calls handleMenuOpenRequest, which allows us to customize how the menu behaves when opening.

The same sequence of methods is called in when closing, except open is replaced with closed

@marcglasberg I'm tagging you because I've seen you request this on a few threads.

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

I also fixed the grammar of 3 test names.. this may need to be split out.

TODO: Add a menu position test.

Pre-launch Checklist

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. d: api docs Issues with https://api.flutter.dev/ d: examples Sample code and demos labels Feb 17, 2025
@davidhicks980 davidhicks980 changed the title Implement MenuControllerDecorator animating menus. Implement MenuControllerDecorator for animating menus. Feb 17, 2025
@justinmc justinmc requested a review from QuncCccccc February 18, 2025 23:05
@justinmc
Copy link
Contributor

@QuncCccccc I added you as a reviewer since this seems to be related to your PR #143416.

@QuncCccccc
Copy link
Contributor

Ah thanks for letting me know🤩!

@davidhicks980 Thanks so much for adding the animation feature🥳! That's the thing that I wanted to implement but never really finished. I will take a look when I get any chance!

@davidhicks980
Copy link
Contributor Author

davidhicks980 commented Feb 19, 2025

@QuncCccccc No worries! This doesn't implement the material animations, but it should make it much simpler to add them without having to worry about internal menu state or changing the MenuController api.

The way the decorator works is a bit complicated, and I'm not sure whether it can be further simplified without complicating the external interface, so I'll add a summary up above for why the complexity exists and why it may be unavoidable. But, by all means, let me know if there's a better way of handling this problem, or ask clarifying questions.

@github-actions github-actions bot added the a: desktop Running on desktop label Feb 19, 2025
@davidhicks980 davidhicks980 force-pushed the menu_controller_decorator branch from 967aa26 to 2c55a0c Compare February 19, 2025 03:12
@github-actions github-actions bot removed the a: desktop Running on desktop label Feb 19, 2025
@QuncCccccc
Copy link
Contributor

Since this is related to RawMenuAnchor, I might need to ask some help from @dkwingsmt. Not super familiar with this new widget:)

@QuncCccccc QuncCccccc requested a review from dkwingsmt February 20, 2025 21:04
Copy link
Contributor

@dkwingsmt dkwingsmt left a comment

Choose a reason for hiding this comment

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

Sorry for late review. Besides some minor doc suggestions (which can be addressed later), I'm mostly asking the reasoning behind the current design: What problem is this trying to fix? What's the current MenuController incapable of? Why is this new abstract class considered the better approach? What kind of customization are we expecting from app code here?

assert(mounted);
if (_animationStatus != status) {
_animationStatus = status;
// TODO(davidhicks980): Create a helper function to safely call setState
Copy link
Contributor

Choose a reason for hiding this comment

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

You can probably open an issue for this, although I think the current code is ok as it as and doesn't need a TODO. (A TODO needs an tracking anyway)

Maybe you can create a method called _markNeedsBuild, which is essentially what setState does.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This caused test failures, which I need to investigate... but I was thinking the same thing regarding _markNeedsBuild.

Copy link
Contributor

@dkwingsmt dkwingsmt Mar 13, 2025

Choose a reason for hiding this comment

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

What do you mean by "This caused test failures, which I need to investigate"? I was saying that the current code looks ok. What is needing investigation?

@@ -399,24 +423,70 @@ mixin _RawMenuAnchorBaseMixin<T extends StatefulWidget> on State<T> {

/// Open the menu, optionally at a position relative to the [RawMenuAnchor].
///
/// Call this when the menu should be shown to the user.
/// Call this to show the menu overlay, before any animations are run.
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't quite understand this sentence. Does it mean the animations run at all?

From requestOpen I guess this method skips the animation and opens immediately. If so this should be a clearer way to put it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

requestOpen is called to start the opening animation.

handleMenuOpenRequest is called by the decorator in response to requestOpen

The order is requestOpen -> decorator starts animation -> decorator mounts overlay.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

open() just shows the overlay. It calls OverlayController.show(). The behavior of open() is unchanged.

But, if an opening animation is being run, the overlay needs to be opened. Otherwise, the menu items wouldn't be mounted in the widget tree.

The actual behavior is requestOpen -> start animation AND show overlay -> wait for animation to finish -> call markMenuOpened.

/// [MenuAnchor]. Unlike [MenuController.maybeOf], this method will establish a
/// dependency relationship, so the calling widget will rebuild when the menu
/// opens and closes, and when the [MenuController] changes.
/// Calling [MenuController.maybeIsOpenOf] will return whether the nearest
Copy link
Contributor

Choose a reason for hiding this comment

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

These two paragraphs seem more suitable as the documentation for these methods instead of this class. Maybe write it this way:

The controller has some methods that not only query the status of the
nearest ancestor, but also establish a dependency relationship between
the calling widget and the ancestor, such as [maybeIsOpenOf] and
[maybeAnimationStatusOf].

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment on lines 916 to 919
/// The anchor that this controller controls.
///
/// This is set automatically when a [MenuController] is given to the anchor
/// it controls.
Copy link
Contributor

Choose a reason for hiding this comment

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

Private fields should only use //.

Suggested change
/// The anchor that this controller controls.
///
/// This is set automatically when a [MenuController] is given to the anchor
/// it controls.
// The anchor that this controller controls.
//
// This is set automatically when a [MenuController] is given to the anchor
// it controls.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

///
/// See also:
/// * [MenuController], a controller used to open and close a menu anchor.
abstract class MenuControllerDecorator extends MenuController {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we want to implement it this way? AFAIS there are two alternative ways:

  • Expand the APIs of MenuController so that widgets just extend it into a subclass.
  • Add a delegate class, and the current feature is kept as "the default delegate".

You decorator approach is pretty unexpected to me. It also leads to some awkward code, such as class AnimatedMenuController extends MenuControllerDecorator, where a controller is extended to be a decorator which is extended to be a controller again.

Why would you say it's better than the other approaches?

Copy link
Contributor Author

@davidhicks980 davidhicks980 Mar 1, 2025

Choose a reason for hiding this comment

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

  • Expand the APIs of MenuController so that widgets just extend it into a subclass.

    • I forget who suggested it, but I was told to avoid subclassing MenuController for different menus.
    • Adding methods like animateOpen or animateClosed would be awkward for menus which aren't animated, plus it would be a much greater lift on end users to add animated behavior to current menus (e.g. they would need to rewrite all of their menu controllers to use MenuController.animateOpen/MenuController.animateClosed).
    • Designing a MenuController API for both menu developers and end users could lead to confusion. For example, end users have no reason to call "MenuController.handleMenuOpenRequest".
  • Add a delegate class, and the current feature is kept as "the default delegate".

    • From my understanding, decorators are specialized delegates (I'm using this and this as sources here). So, this is an abstract delegate.
    • The reason why it is called "Decorator" instead of "Delegate" is because I think Decorators are meant to be mimick the class they are wrapping. In this case, the Decorator is mimicking the MenuController.

I should name AnimatedMenuController something different. It's meant to be the controller used by the component library author, rather than by end users. "_MenuControllerDelegate", "_MenuAnimationDelegate", or something similar would work.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I ended up calling it _MenuAnimator. That said, what is the interface of the delegate you are thinking of?

The reason why the decorator is so awkward is to allow the end user to pass in a default MenuController and allow menu developers to add behavior to the user's MenuController without the user knowing and without the decorator storing any MenuController state (all state, such as the AnimationStatus, is on RawMenuAnchor)

@davidhicks980
Copy link
Contributor Author

@dkwingsmt Yeah, it's a strange implementation. Hopefully, I'll be able to clear some things up.

Important note: AnimatedMenuController is private and is only meant for the component library author. I made it public because it's an example, but I can change that.

What problem is this trying to fix? What's the current MenuController incapable of?

  • RawMenuAnchor can't animate closed.
    • Calling MenuController.close() hides the overlay that the menu is shown in.
  • There is no way to animate the menu closed when scrolling/resizing/pressing escape
  • There is no way to interrogate whether the menu is opening or closing using the MenuController
  • MenuController should work for both animated and synchronous menus.
  • Behavior should be stateless. In other words, library authors should not need to attach and detach decorated controllers, because that leads to complex code and hard-to-track errors. The decorated doesn't store the anchor or the menu status -- everything is stored on the anchor itself (hence it is also a const constructor.

The reason why it's an abstract class is because it's not meant to be instantiated by anyone except component library authors. End-users of menu components use the same MenuController API exactly as they current do, but menu library developers should be able to customize what happens when users call MenuController.open() and MenuController.close().

Real world example

If I was an end user who has MenuAnchors throughout my app, the only code I would need to change to add animation to MenuAnchor is:

MenuAnchor(
  animated: true,
 //  Everything else stays the same
)

I'll give more answers a bit later.

@davidhicks980
Copy link
Contributor Author

davidhicks980 commented Mar 1, 2025

Okay, I created a sample that logs each method call. It's still complicated, but it should show each method that is being called.

void main() {
  runApp(const MaterialApp(home: Scaffold(body: App())));
}

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

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: Scaffold(body: Demo()));
  }
}

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

  @override
  State<Demo> createState() => _DemoState();
}

class _DemoState extends State<Demo> with SingleTickerProviderStateMixin {
  late final AnimationController animationController;

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

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        Center(
          child: TextButton(
            onPressed: () {
              if (animationStatus.isForwardOrCompleted) {
                close_();
              } else {
                open_();
              }
            },
            child: Text(
              animationStatus.isForwardOrCompleted
                  ? 'MenuController.close()'
                  : 'MenuController.open()',
            ),
          ),
        ),
        if (_isOpen)
          SizeTransition(
            sizeFactor: animationController,
            child: Center(
              child: Container(height: 200, width: 200, color: Colors.black.withOpacity(0.5)),
            ),
          ),
      ],
    );
  }

  // **********************************
  // **  _RawMenuAnchorBaseMixin **
  // **********************************

  bool _isOpen = false;
  AnimationStatus get animationStatus => _animationStatus;
  AnimationStatus _animationStatus = AnimationStatus.dismissed;
  set animationStatus(AnimationStatus value) {
    if (_animationStatus == value) {
      return;
    }

    print('BaseMixin.animationStatus set to $value');
    setState(() {
      _animationStatus = value;
    });
  }

  void open() {
    print('BaseMixin.open() shows the menu overlay');
    setState(() {
      _isOpen = true;
    });
  }

  void close() {
    print('BaseMixin.close() hides the menu overlay \n');
    setState(() {
      _isOpen = false;
    });
  }

  void requestOpen() {
    print('BaseMixin.requestOpen()');
    _handleMenuOpenRequest();
  }

  void requestClose() {
    print('BaseMixin.requestClose()');
    _handleMenuCloseRequest();
  }
  

  // **********************************
  // **  MenuControllerDecorator **
  // **********************************
  void open_() {
    print('MenuController.open() called by user');
    requestOpen();
  }

  void _handleMenuOpenRequest() {
    print('Decorator._handleMenuOpenRequest()');
    handleMenuOpenRequest();
    open();
    animationStatus = AnimationStatus.forward; // Sets the status on _RawMenuAnchorBaseMixin

    print("\n \nAnimating open\n");
  }

  void handleMenuOpenRequest() {
    print('Decorator.handleMenuOpenRequest() starts the opening animation');
    animationController.forward().whenComplete(markMenuOpened);
  }

  void markMenuOpened() {
    print('Decorator.markMenuOpened() called after the menu has finished opening.');
    animationStatus = AnimationStatus.completed; // Sets the status on _RawMenuAnchorBaseMixin
  }

  void close_() {
    print('\n \nMenuController.close() called by user');
    requestClose();
  }

  void _handleMenuCloseRequest() {
    print('Decorator._handleMenuCloseRequest()');
    handleMenuCloseRequest();
    animationStatus = AnimationStatus.reverse; // Sets the status on _RawMenuAnchorBaseMixin
    print("\n \nAnimating closed...\n");
  }

  void handleMenuCloseRequest() {
    print('Decorator.handleMenuCloseRequest() starts the closing animation');
    animationController.reverse().whenComplete(markMenuClosed);
  }

  void markMenuClosed() {
    print('Decorator.markMenuClosed() called after the menu has finished closing.');
    animationStatus = AnimationStatus.dismissed; // Sets the status on _RawMenuAnchorBaseMixin
    close();
  }
}
Screen.Recording.2025-03-01.at.4.06.56.AM.mov

@davidhicks980
Copy link
Contributor Author

davidhicks980 commented Mar 8, 2025

I forgot to mention an issue I encounted.

This proposal started as a mixin that could be mixed into a widget's state that was then passed into RawMenuAnchor.

class _MenuState extends State<Menu> with MenuControllerDelegate {
  MenuController get menuController => _menuController
  @override
  didMenuBeginOpening() {
    // ...
  }

  @override
  didMenuBeginClosing() {
    //... 
  }  

  @override
  Widget build(BuildContext context) {
    return RawMenuAnchor(
      menuController: this
      // ...
    );
  }
}

The widget's state would intercept calls that the MenuController made.

The problem with this approach was that RawMenuAnchor did not recognize when the MenuController owned by the widget changed. In other words, if I updated a _menuController property on my state, _MenuState would remain the same, so _RawMenuAnchorState would not be able to attach/reattach the menu controller during didUpdateWidget, since didUpdateWidget was never called was called with the same menu controller. That's why the decorator needs to be recreated every time a new MenuController is passed into the widget's state.

@dkwingsmt
Copy link
Contributor

dkwingsmt commented Mar 13, 2025

(Sorry for delayed reply, I was engaged with some urgent stuff lately... But I've made it through and I'll be more prompt!)

With you comment here I understand why it's called a decorator. But I doubt if the decorator pattern is the most suitable here.

If I understand correctly (and from the descriptions you linked), the decorator pattern is useful when the app wants pick whether certain features should be added at runtime, especially when there are many optional features to pick from and combine. With the decorator pattern, the feature of the final object is not decided by the class, but by the creation process at runtime.

In the case of the menu anchor, you're saying you want to allow the app to decide whether their menu should be animated; 3rd party developers can even add more optional features to the controller; you can use the MaterialMenuController on a CupertinoDropDownMenu for a mixed style...

But these don't sound useful cases to me. I would imagine that typical end developers will pick a menu class, say CupertinoDropDownMenu, which comes with iOS-style layout and iOS-style animation, and they're happy with it (up to a few customization options). This is an example where the feature of the final object can be decided by the class. So if I were to design it, I would let the RawMenuAnchor have the complete customizability to add animations (including closing ones), and a themed library can wrap around this widget, which the same MenuController class (which is not subclassed at all) can handle.

In summary, I'm imagining a design where:

  • Everything related to the theme is defined in the themed widget class, not the menu controller class.
  • The menu controller does not need to be subclassed for a theme.

Let's put that aside, because I think there's another unnecessary limitation that you've put on your design:

[We'd like to solve the problem that] There is no way to interrogate whether the menu is opening or closing using the MenuController
[We'd like to achieve this while ensuring] Behavior should be stateless. In other words, library authors should not need to attach and detach decorated controllers, because that leads to complex code and hard-to-track errors. The decorated doesn't store the anchor or the menu status -- everything is stored on the anchor itself (hence it is also a const constructor.

The menu controller doesn't have to be const. In fact, it's quite weird that it's const. Because the controller is supposed to be created and owned by a stateful widget, which creates its properties at runtime during initStates anyway. Also, to think about it conceptually: a menu controller instance is supposed to correspond a menu instance on the screen. Since there can be many menu instances on a screen, how will menu controllers that are identical to each other help?

Lately I've been reviewing a PR for another widget Expansible, which happens to use a controller class as well, and I quite like its design (link to the controller class). In short:

  • The controller class is mutable and extends ChangeNotifier.
  • The controller class stores the source-of-truth state.
  • The widget class attaches itself to its provided controller to listen to state changes, which triggers the widget class's animations.

This design solves the goal of allowing the controller to query the state (which is held by the controller) without needing the themed widget classes to manually attach/detach controllers, and also allows the themed widget to define animations instead of subclassing the controller class.

At last, we need to come up with an API for the themed widget class to define animations. The Expansible PR's solution (link) might also be helpful: Giving the builder callback an additional property Animation<double>.

In general I think the decorator pattern is an overkill to our problem and leads to many complicated design in this PR. Let me know I missed any considerations! Also thank you for your explanation!

@davidhicks980
Copy link
Contributor Author

davidhicks980 commented Mar 13, 2025

Whoops, I edited my examples but the push never was pushed? Not sure why.

Actually, there should be no MaterialMenuController or CupertinoMenuController. There should only be MenuController. All the decorator does is allows users to pass in a MenuController to a themed menu (e.g. CupertinoDropDownMenu), and have the CupertinoDropDownMenu internals decide how open() and close() behaves, rather than the MenuController.

This API is only meant for the developer of CupertinoDropDownMenu, not end-users. Users will just use MenuController.

The reason for the decorator is to allow MenuController to open animated menus without changing the MenuController API. In other words, calling MenuController.open() (instead of MaterialMenuController.open() or CupertinoMenuController.open()) will open both animated and regular menus.

MenuController isn't const because it stores the anchor it is attached to. The decorator is const since it redirects method calls to the MenuController. To your point, it doesn't make sense to have the MenuController be const.

ChangeNotifier would be challenging because (1) ChangeNotifiers need to be disposed by the user, whereas MenuController has, up until now, not been disposed, and (2) we would be moving state from the RawMenuAnchor to the MenuController. Because OverlayController.show() needs to be called, storing state on the MenuController will lead to the MenuController not accurately reflecting whether the MenuController is open. While this doesn't seem like a big problem, what if a user accesses the items of a MenuAnchor with a GlobalKey, thinking that the anchor is open since MenuController.isOpen returns true?

Also, while the ExpansionTileController doesn't have to be detached, it does have to be disposed:

. Not a huge deal, but it'd be a pain to fix every instance of MenuController at this point.

Otherwise, it would make sense to use a ChangeNotifier.

There are a few issues with moving animations on to the RawMenuAnchor. What if you have multiple animations that run? What if you aren't using an Animation?

I think there may be a few other issues with treating it the same as an ExpansionTile, because Overlays aren't children of their anchors... but it's hard for me to conceptualize.

Also, I'm sorry if my explanation is still a bit confusing. The main point of the PR is to let users of MenuController keep everything the same, while making it easy for menu developers to add animations to their menu.

@davidhicks980
Copy link
Contributor Author

I should add though, let me know what design you are thinking about so I can try and integrate. I had a design doc I was working on for this but I wasn't sure if it was overkill.

@dkwingsmt
Copy link
Contributor

dkwingsmt commented Mar 14, 2025

Thank you for your explanation! A lot of them make sense to me, and in summary now I agree with the decorator design, although I still want to see if there's any chance for the notifier design as well as understanding the obstacles better.

This API is only meant for the developer of CupertinoDropDownMenu, not end-users. Users will just use MenuController.

That makes a lot more sense. Although this would be much clearer if the doc for the decorator class had explicitly pointed out this way, and that the examples were written so that a themed menu anchor class was created and the main app used the themed menu class with original menu controller. (Don't hurry fixing until we've agreed on the design :)

ChangeNotifiers need to be disposed by the user, whereas MenuController has, up until now, not been disposed
it'd be a pain to fix every instance of MenuController at this point.

I understand. Since MenuController has been out for so long we'd really like to avoid this breakage if possible, which is a big no for the notifier design.

Although do you think this can be avoided/alleviated by making the controller implicitly dispose the resources when the widget is unmounted & when the widget swaps the controller down?

OverlayController.show() needs to be called, storing state on the MenuController will lead to the MenuController not accurately reflecting whether the MenuController is open.

(This part isn't a refute but just me trying to understand your code and design.)
Sorry but I still don't understand the issue of showing the overlay much (despite the fact that you've also explained this in the .open() method). My current guess is that, now that menus have opening animations, calling controller.open() does not mean the menu is actually fully opened, so the controller should not record itself as "opened" but at most "opening" for a while?

What if you have multiple animations that run? What if you aren't using an Animation?

I've thought about this too. My current idea is that we can treat the entire opening process as a linear progress bar from 0 to 1 (by setting the curve to linear) and the themed callbacks can convert the progress to the multiple animations. Likewise, if you don't want animations you can set duration to 0, and the curve no longer matters. In general I don't think we need to design our API specifically for themes without animations. No-animation widgets are just a special case for animations with duration 0.

@davidhicks980
Copy link
Contributor Author

davidhicks980 commented Mar 14, 2025

Although do you think this can be avoided/alleviated by making the controller implicitly dispose the resources when the widget is unmounted & when the widget swaps the controller down?

I think this is generally bad practice, but I'm not sure if that's just convention or if there is a real reason for it. I think the reasoning is just that it leads to bugs if an object you create is not being destroyed by you, because you may try to use it after a widget has been unmounted. I haven't come across a situation in the framework where a controller you own is destroyed by a widget, but I may not be looking hard enough.

(This part isn't a refute but just me trying to understand your code and design.)
Sorry but I still don't understand the issue of showing the overlay much (despite the fact that you've also explained this in the .open() method). My current guess is that, now that menus have opening animations, calling controller.open() does not mean the menu is actually fully opened, so the controller should not record itself as "opened" but at most "opening" for a while?

So, I'm mainly in favor of storing the state on the RawMenuAnchor because it's one less lifecycle to manage, but I'll try to explain the point a bit more. Honestly, I'm still a bit confused by how real the issue is, so you may want to consult someone more knowledgeable than I am.

https://dartpad.dev/c607d09391baa1ea303ffeeb3cb41f14?channel=main

Resize the screen and you should encounter an error. The menu can't be opened during didChangeDependencies.

I think this is what I meant by synchronizing state (I wrote it a little while ago). Opening the menu during didChangeDependencies throws an error. This means passing a MenuController with isOpen == true via an inherited widget will throw, unless we introduce a post-frame callback. If we introduced a post-frame callback, the state would be out-of-sync, which is why I think I made that comment.

I've thought about this too. My current idea is that we can treat the entire opening process as a linear progress bar from 0 to 1 (by setting the curve to linear) and the themed callbacks can convert the progress to the multiple animations. Likewise, if you don't want animations you can set duration to 0, and the curve no longer matters. In general I don't think we need to design our API specifically for themes without animations. No-animation widgets are just a special case for animations with duration 0.

So, this paradigm does work well, but it may be better for the MenuAnchor/CupertinoMenuAnchor layer, rather than the RawMenuAnchor layer. But this can be implemented using the decorator.

I'm not sure how we could implement simulations, and some interactions would be a lot more challenging. For example, with CupertinoMenuAnchor, if you tap a menu layer below the top menu layer, the topmost layer is supposed to close at a much faster rate than the default. It's easier to just call () => controller.animateBack(... duration: 50ms) rather than pass a new duration to the RawMenuAnchor.

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 framework flutter/packages/flutter repository. See also f: labels.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants