-
Notifications
You must be signed in to change notification settings - Fork 28.2k
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
base: master
Are you sure you want to change the base?
Implement MenuControllerDecorator for animating menus. #163481
Conversation
@QuncCccccc I added you as a reviewer since this seems to be related to your PR #143416. |
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! |
@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. |
967aa26
to
2c55a0c
Compare
Since this is related to RawMenuAnchor, I might need to ask some help from @dkwingsmt. Not super familiar with this new widget:) |
There was a problem hiding this 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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].
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
/// The anchor that this controller controls. | ||
/// | ||
/// This is set automatically when a [MenuController] is given to the anchor | ||
/// it controls. |
There was a problem hiding this comment.
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 //
.
/// 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. |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
@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?
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:
I'll give more answers a bit later. |
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 |
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 |
(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 In summary, I'm imagining a design where:
Let's put that aside, because I think there's another unnecessary limitation that you've put on your design:
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 Lately I've been reviewing a PR for another widget
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 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! |
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:
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. |
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. |
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.
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 :)
I understand. Since 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?
(This part isn't a refute but just me trying to understand your code and design.)
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. |
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.
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.
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. |
This PR adds a
MenuControllerDecorator
mixinclass 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 aMenuController.maybeAnimationStatusOf
static method to interrogate the current animation status of the nearest menu. I was torn between creating a new enum, such asand 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:
Full example using simulations
How it works/why it's complicated
Say you have this simplified setup
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 withclosed
@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.