-
Notifications
You must be signed in to change notification settings - Fork 28.5k
Add RawMenuAnchor animation callbacks #167806
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
base: master
Are you sure you want to change the base?
Add RawMenuAnchor animation callbacks #167806
Conversation
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.
Nice one! I like how this solution targets the core obstacle in a direct and simple way, i.e. how to pass the message from the menu controller to the themed menu.
This entire batch of suggestions focus on the documentation and naming, mostly because I myself was a bit lost while reading everything, especially everything related to "request". I'd like to propose a different set of terms to make the structure a bit clearer, and to also verify my understanding. Feel free to take any or no parts of my suggestion or change it whatever way you think fits.
@@ -186,6 +188,12 @@ class RawMenuAnchor extends StatefulWidget { | |||
/// A callback that is invoked when the menu is closed. | |||
final VoidCallback? onClose; | |||
|
|||
/// A callback that is invoked when [MenuController.open] is called. |
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.
/// A callback that is invoked when [MenuController.open] is called. | |
/// Called when a request is made to open the menu. | |
/// | |
/// This callback is typically used by themed menu widgets to intercept open | |
/// requests, for example, to play an animation or delay the operation. | |
/// If the request is intercepted, it is the responsibility of the handler to | |
/// eventually call [MenuController.open] with `transition: true`. | |
/// By default, this callback directly makes the aforementioned call. |
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 added more words since we added the showOverlay/hideOverlay callbacks, but modeled the docs after your suggestion. Let me know what you think.
/// A callback that is invoked when [MenuController.open] is called. | ||
final VoidCallback? onOpenRequested; | ||
|
||
/// A callback that is invoked when [MenuController.close] is called. |
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.
/// A callback that is invoked when [MenuController.close] is called. | |
/// Called when a request is made to close the menu. | |
/// | |
/// This callback is typically used by themed menu widgets to intercept close | |
/// requests, for example, to animate the menu out or delay the dismissal. | |
/// If the request is intercepted, it is the responsibility of the handler to | |
/// eventually result in a call to [MenuController.open] with `transition: true`. | |
/// By default, this callback directly makes the aforementioned call. |
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 added more words since we added the showOverlay/hideOverlay callbacks, but modeled the docs after your suggestion. Let me know what you think.
/// size, then any open menus will automatically close. | ||
void open({Offset? position}) { | ||
/// size, then any open menu will automatically close. | ||
void open({Offset? position, bool transition = true}) { |
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'd like to avoid the same method being called twice with different flags during a single opening/closing action, since this call stack will be confusing and even prone to infinite recursion if not managed correctly.
What if, instead of making onOpenRequested
call menuController.open(transition: true)
, the onOpenRequested
is given a callback to finalize the opening? Do you think this will be simpler?
Another approach is to add performOpen
and performClose
methods to MenuController
. We can clearly document them to avoid being miscalled by applications.
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 was debating between the callback or the transition property.. the recursion problem is a great point. What do you think would be a good name for the callback? For the decorator I used markMenuOpened, but that was a bit confusing. done()
/complete()
/finishedOpen
/finish
?
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.
Any of them is an improvement, which we can start with.
Another option is to use a name with better meaning, such as showOverlay
. Although this isn't the only thing that this callback does, my understanding is that this gives a close enough impression to the user (library developer) what it 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.
Sweet -- I'll change the callbacks to showOverlay/hideOverlay.
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! Unfortunately, function signatures don't seem to show positional argument names when using intellisense. Currently, I'm passing the position (1st argument in photo) and the showOverlay callback (2nd argument) into onOpenRequested, but I'm not sure if the ambiguity of the function signature warrants using named parameters. Otherwise, seems to work well.

/// Close the menu. | ||
/// Close the menu and all of its children. | ||
/// | ||
/// If `inDispose` is true, the menu will close without rebuilding its parent. |
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.
Shall we rename it triggerRebuild
(with negation) for more clarity what it does?
/// If `triggerRebuild` is false, the menu will close without rebuilding its parent, which is useful when the
/// menu is closed due to unmounting. Defaults to true.
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.
That sounds good to me, although this was from the original MenuAnchor (not sure if it matters).
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.
Did you meant to say RawMenuAnchor
? That's fine, because these APIs are private.
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.
Actually, the original MenuAnchor that RawMenuAnchor is based off of. But sounds good -- I'll make the change.
Edit: One caveat I just came across. inDispose only blocks rebuilds for the overlay menu, and it also blocks a post-frame callback if the menu was in the process of closing. It may actually be better if we just took out the "If inDispose
is true, the menu will close without rebuilding its parent." comment. Let me know...
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 I don't quite understand. Can you point out the line that implements each usage of inDispose
?
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.
Instances are below.
// in _RawMenuAnchorBaseMixin
@protected
void closeChildren({bool inDispose = false}) {
assert(_debugMenuInfo('Closing children of $this${inDispose ? ' (dispose)' : ''}'));
for (final _RawMenuAnchorBaseMixin child in List<_RawMenuAnchorBaseMixin>.from(
_anchorChildren,
)) {
// ** Skipping the child's closing animation if we are disposing
if (inDispose) {
child.close(inDispose: inDispose);
} else {
child.handleCloseRequest();
}
}
}
// In _RawMenuAnchorGroupState
@override
void close({bool inDispose = false}) {
if (!isOpen) {
return;
}
closeChildren(inDispose: inDispose);
// ** Notifying our parent and rebuilding this widget iff we are not disposing.
if (!inDispose) {
if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
setState(() {
// Mark dirty, but only if mounted and not in a build.
});
} else {
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) {
if (mounted) {
setState(() {
// Mark dirty.
});
}
});
}
}
}
// In _RawMenuAnchorState
@override
void close({bool inDispose = false}) {
assert(_debugMenuInfo('Closing $this'));
if (!isOpen) {
return;
}
closeChildren(inDispose: inDispose);
if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
_overlayController.hide();
} else if (!inDispose) {
// ** Adding a post-frame callback to close this overlay iff we are not disposing.
SchedulerBinding.instance.addPostFrameCallback((_) {
_overlayController.hide();
}, debugLabel: 'MenuAnchor.hide');
}
// ** Notify our parent and rebuild this widget iff we are not disposing.
if (!inDispose) {
_parent?._childChangedOpenState();
widget.onClose?.call();
if (mounted &&
SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
setState(() {
// Mark dirty, but only if mounted and not in a build.
});
}
}
}
/// | ||
/// The optional `position` argument should specify the location of the menu | ||
/// in the local coordinates of the [RawMenuAnchor]. | ||
void requestOpen({Offset? position}) { |
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 suggest move the implementation of _RawMenuAnchorBaseMixin.requestOpen
to _RawMenuAnchorState
since this default implementation only affects this widget alone.
Also I suggest renaming this method to either handleControllerOpen
or handleOpenRequested
to clearly indicate when it is called. In general, I consider the two methods "handlers" because they're basically callbacks to be implemented in the perspective of the menu controller.
(Same suggestions for requestClose
.)
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.
Another way is to define a getter that returns onOpenRequested
, i.e. both the anchor and the anchor group has onOpenRequested
for the menu controller to use.
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 renamed the callbacks to handleOpenRequested/handleCloseRequested. Moving handleOpenRequested on to only RawMenuAnchor is problematic, since MenuController only has access to _RawMenuAnchorBaseMixin. While we could do a typecheck (check if _anchor
is _RawMenuAnchorState
), that seems messier than just keeping them on the base class. Let me know what you think.
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.
No, I mean keeping the handleOpenRequested
as pure method and move the implementation to the two state classes. Is it possible?
Also cc @chunhtai |
/// Called when the menu overlay is shown | ||
/// | ||
/// This callback is called when the menu overlay is added to the widget tree, | ||
/// typically before opening animations begin. [onOpen] can be used to respond | ||
/// when the menu first becomes interactive, such as by setting focus to the | ||
/// menu. | ||
/// | ||
/// An open menu that is repositioned will not trigger [onOpen]. |
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.
Slight fix.
/// Called when the menu overlay is shown | |
/// | |
/// This callback is called when the menu overlay is added to the widget tree, | |
/// typically before opening animations begin. [onOpen] can be used to respond | |
/// when the menu first becomes interactive, such as by setting focus to the | |
/// menu. | |
/// | |
/// An open menu that is repositioned will not trigger [onOpen]. | |
/// Called when the menu overlay is shown. | |
/// | |
/// This callback is triggered when the menu overlay is inserted into the widget | |
/// tree, typically before any opening animations begin. [onOpen] can be used to | |
/// respond when the menu first becomes interactive, such as by setting focus to | |
/// a menu item. | |
/// | |
/// This callback is not called when an already open menu is repositioned. |
/// Called when the menu overlay is hidden. | ||
/// | ||
/// This callback is triggered after the menu overlay has been removed from | ||
/// the widget tree, typically after all closing animations have completed. It | ||
/// is typically used by applications to respond when the menu has been | ||
/// dismissed. |
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.
Slight fix, I wonder if this is the correct understanding.
/// Called when the menu overlay is hidden. | |
/// | |
/// This callback is triggered after the menu overlay has been removed from | |
/// the widget tree, typically after all closing animations have completed. It | |
/// is typically used by applications to respond when the menu has been | |
/// dismissed. | |
/// Called when the menu overlay is hidden. | |
/// | |
/// This callback is triggered when the menu overlay is removed from the widget | |
/// tree, typically after any closing animations have completed. It is typically | |
/// used by applications to respond when the menu has been dismissed, such as | |
/// by restoring focus to a previously active element. |
Also did you remove
/// It is not called when the menu is unmounted.
/// By default it does nothing.
intentionally? I think the two sentences are worth adding. (The 2nd sentence also to onOpen
).
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.
So, this is another confusing aspect of the menu. If the focus moves onto the menu, the menu takes focus when it begins opening (when onOpen is called), and moves off the menu when it begins closing (when onCloseRequested is called). I observed this in several menus. Otherwise, it'd be bad a11y -- the user would have to wait for the menu to open or close before that user could move to another focus node.
For the removal question, I'm not sure why I took out the first part ("It is not called when the menu is unmounted") -- it may have been an accident. The "by default it does nothing" part was removed since the value defaults to null, so I thought it was evident that the callback did nothing, but I do know there are classes that have default behavior if a null value is provided. So, I'll add them both back in.
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.
Do you mind if I rewrite it slightly to "By default, [onOpen] does nothing"? The rewording makes it a bit less ambigious since "menu" is also being referenced in the previous sentence.
/// Close the menu. | ||
/// Close the menu and all of its children. | ||
/// | ||
/// If `inDispose` is true, the menu will close without rebuilding its parent. |
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 I don't quite understand. Can you point out the line that implements each usage of inDispose
?
/// | ||
/// The optional `position` argument should specify the location of the menu | ||
/// in the local coordinates of the [RawMenuAnchor]. | ||
void requestOpen({Offset? position}) { |
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.
No, I mean keeping the handleOpenRequested
as pure method and move the implementation to the two state classes. Is it possible?
/// The `position` argument is the position passed to [MenuController.open]. | ||
/// Handlers should provide this argument to `showOverlay` in order to | ||
/// position the menu relative to the anchor. When a menu is repositioned, | ||
/// [onOpenRequested] may be called with a new `position` value while the menu | ||
/// is still open. In this case, opening delays or animations should be | ||
/// skipped, and `showOverlay` should be called with the new `position` value. |
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.
Is there a way to distinguish between repositioning and fresh open? Do you think a repositioning callback would help?
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.
It is possible, but I would advise against it since the user has a few ways of figuring out if it's a reposition. If the menu is already open or opening, the user could check if their animation is already running forward or has finished. The user could also store the value of "position" and check for equality.
Also, the separate callback would still need a way of calling showOverlay with the new position, so users would have to write two callbacks that behave almost the same. Likewise, since "position" can be null, it could introduce some confusion as to whether moving from a null to a non-null "position" and vise versa counts as repositioning.
@override | ||
void handleOpenRequest({ui.Offset? position}) { | ||
if (widget.onOpenRequested != null) { | ||
widget.onOpenRequested!(position, open); |
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.
It seems to me that this if
clause can be refactored so that widget.onOpenRequested
is non null and has a default callback
onOpenRequested = onOpenRequested ?? (position, open) => open(position)
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.
Where would we be assigning onOpenRequested? We can't do the assignment in the RawMenuAnchor constructor since the widget is const.
While we could write something like widget.onOpenRequested?.call(position, open) ?? open(position)
, I tend to find null coalescing to be a bit unsightly compared to using a boring "if" statement. I may just be getting old.
I missed this comment. I think I pushed changes that do this -- let me know if it looks right. By pure method, are you referring to an abstract method in _RawMenuAnchorBaseMixin? |
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.
Overall LGTM, I think this pr is cleaner now. Thanks for working on this!
/// Signature for the callback used by [RawMenuAnchor.onOpenRequested] to | ||
/// respond to a request to open a menu. | ||
typedef RawMenuAnchorOpenRequestedCallback = | ||
void Function(Offset? position, void Function({Offset? position}) showOverlay); |
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.
should also typedef the parameter type Function({Offset? position})
@@ -117,6 +117,15 @@ typedef RawMenuAnchorOverlayBuilder = | |||
typedef RawMenuAnchorChildBuilder = | |||
Widget Function(BuildContext context, MenuController controller, Widget? child); | |||
|
|||
/// Signature for the callback used by [RawMenuAnchor.onOpenRequested] to | |||
/// respond to a request to open a menu. |
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.
the comment should also explain what each parameter are and how to use them in typical cases
@override | ||
void handleCloseRequest() { | ||
if (widget.onCloseRequested != null) { | ||
if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { |
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 need a postframe callback? would be good to have some comments on this
/// is still open. In this case, opening delays or animations should be | ||
/// skipped, and `showOverlay` should be called with the new `position` value. | ||
/// | ||
/// Defaults to null, which means that the menu will be opened immediately. |
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.
Somewhere in here or other place should document the invoking order of onOpenRequested
, showOverlay
and onOpen
same for onCloseRequest
and onClose
/// | ||
/// Unless the menu needs to be closed immediately, [handleCloseRequest] should be | ||
/// called instead of [close]. Doing so allows subclasses to control how the | ||
/// menu is opened. |
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.
/// menu is opened. | |
/// menu is closed. |
/// skipped, and `showOverlay` should be called with the new `position` value. | ||
/// | ||
/// Defaults to null, which means that the menu will be opened immediately. | ||
final RawMenuAnchorOpenRequestedCallback? onOpenRequested; |
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.
Can this have a default value to call the showOverlay directly?
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.
Tong also was wondering this, and at the time I thought it would make RawMenuAnchor non const. I just checked, and it turns out I was wrong. So we can. Do these look good, or should I rename/make public?
static void _defaultOnOpenRequested(
Offset? position,
RawMenuAnchorShowOverlayCallback showOverlay,
) {
showOverlay(position: position);
}
static void _defaultOnCloseRequested(RawMenuAnchorHideOverlayCallback close) {
close();
}
Alternative to #163481, #167537, #163481 that uses callbacks.
@dkwingsmt - you inspired me to simplify the menu behavior. I didn't end up using Actions, mainly because nested behavior was unwieldy and capturing BuildContext has drawbacks. This uses a basic callback mechanism to animate the menu open and closed. Check out the examples.
The problem
RawMenuAnchor synchronously shows or hides an overlay menu in response to
MenuController.open()
andMenuController.close
, respectively. Because animations cannot be run on a hidden overlay, there currently is no way for developers to add animations to RawMenuAnchor and its subclasses (MenuAnchor, DropdownMenuButton, etc).The solution
This PR:
transition
flag to MenuController.open() and MenuController.close(). This flag defaults to "true"onOpenRequested
andonCloseRequested
-- to RawMenuAnchor.When
MenuController.open()
andMenuController.close()
are called with transition == true (the default), onOpenRequested and onCloseRequested are invoked, respectively.Developers who are animating a RawMenuAnchor open within onOpenRequested should call MenuController.open(transition: false) whenever they wish to show their menu overlay. Typically, this is before any animations are run.
Developers who are closing a RawMenuAnchor within onCloseRequested should call MenuController.close(transition: false) once they are finished animating their menu closed. This ensures the menu overlay is only hidden when the entire closing animation is finished.
Precursor for #143416, #135025, #143712
Demo
Screen.Recording.2025-02-17.at.8.58.43.AM.mov
Pre-launch Checklist
///
).If you need help, consider asking for advice on the #hackers-new channel on Discord.