-
Notifications
You must be signed in to change notification settings - Fork 28.7k
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.
Also cc @chunhtai |
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!
@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
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.
Changes in MediaQuery.sizeOf(context) cause RawMenuAnchor to close during didChangeDependencies. When this happens, calling setState during the closing sequence (i.e. handleCloseRequest -> onCloseRequested -> hideOverlay) will throw an error, since we're scheduling a build during a build. I think this could be fixed if OverlayPortal is given a declarative API (see #136769). I'll add comments explaining this.
While I was investigating how to phrase the comments, I realized that we could remove the post-frame callbacks if we used didChangeMetrics instead of MediaQuery, since didChangeMetrics is called during the idle phase. The drawback of this approach is that we would be rebuilding when the screen size changes rather than when the MediaQuery size changes. As a result, if a user modified the size of an ancestor MediaQuery widget, RawMenuAnchor wouldn't close. I submitted an issue about this here: #168539

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 filed a PR regarding this issue here: #168623. For now, I'll comment in the explanation.
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
/// 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
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, I initially wrote out a detailed list, but I found it easier to digest when reading:
onOpenRequested:
/// When [MenuController.open] is called, the full opening sequence is:
/// 1. [onOpenRequested]
/// 2. (optional delay)
/// 3. `showOverlay`
/// 4. [onOpen]
/// 5. (optional animation)
onCloseRequested:
/// When [MenuController.close] is called, the full closing sequence is:
/// 1. [onCloseRequested]
/// 2. (optional animation)
/// 3. `hideOverlay`
/// 4. [onClose]
I also tried combining both steps and placing them in the widget description, but I found it more intuitive to read parameter definitions.
Let me know if I should add more detail or change anything.
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.
Great so far! More discussion on comments and minor API design.
Also: Can you remove the comment here? Overriding methods should not typically have comments (unless they're doing very differently from what the base method instructs).
Clarify this a bit more. What do you mean by fast opening/closing?
The menu would open or close, as expected. For a given menu, showOverlay and hideOverlay are the equivalent of calling _RawMenuAnchorState.open() and _RawMenuAnchorState.close(), which stay the same throughout the lifecycle of a widget.
Calling showOverlay on an already-open menu effectively just calls onOpen and setState, which rebuilds the overlayBuilder. Calling hideOverlay on an already-closed menu just returns. This means that onClose isn't called. We could change that behavior. |
For example, the themed menu is controlled by two buttons, one opens the menu and the other closes the menu. The Consider the following event sequence, where the user quickly taps open then close.
With the current way, the overlay would appear between 100ms and 160ms. I think this is not desired. Because when the user pressed the closing button, the user intends that the menu should now be closed, and any scheduled opening sequence should be terminated. I propose that upon |
@dkwingsmt OHH I see what you are suggesting: we should make previous showOverlay/hideOverlay callbacks do nothing if onOpenRequested/onCloseRequested is called again. It's not a bad idea, but I would advise against it. For lower-level apis, imo predictability is more important than convenience. Changing the behavior of a callback depending on external factors (in this case, whether the parent function has been called again) makes the behavior of a system harder to reason about. Also, it's easy to mitigate redundant calls. TickerFuture.whenComplete (shown in the PR examples) and Timer.cancel (shown in the example I gave above) address most use cases, including the example you provided. |
IMO it's not that hard to understand: a If we really want to go this direction, it's ok as well, but now that the |
It's not the difficulty of the concept. It's the fact that the function behaves differently depending on when it is called. We could add an assert function to check whether a stale callback is used, but I don't think the functions should be made noop.
|
You're probably right. Keeping |
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.
LGTM. What a journey! Thank you for sticking with the PR all along.
@chunhtai Can you take another look? |
@dkwingsmt Sounds good, although we can always expand the MenuController api with future PRs. I'm still curious whether anyone will ask for a way to observe whether a menu anchor is opening/closing... we shall see. Anyways, thanks for the feedback! I think we landed on a solid design, so I'm excited to see what the community does with it. |
super.dispose(); | ||
} | ||
|
||
void _handleMenuOpenRequest(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.
void _handleMenuOpenRequest(Offset? position, void Function({Offset? position}) showOverlay) { | |
void _handleMenuOpenRequest(Offset? position, RawMenuAnchorShowOverlayCallback 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.
Replaced with VoidCallback
|
||
class Menu extends StatefulWidget { | ||
const Menu({super.key, required this.panelBuilder, required this.builder}); | ||
final Widget Function(BuildContext, AnimationStatus) panelBuilder; |
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.
typedef this
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
/// animation, the themed menu should show the overlay before starting the | ||
/// opening animation, since the animation plays on the overlay itself. | ||
/// | ||
/// The `position` argument is the `position` that [MenuController.open] was |
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.
curious why we expose the position parameter if it is just going to be fed into the showOverlay callback. is there a use case for position parameter?
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, I meant to remove the position parameter from the showOverlay callback. My mistake.
We kept the position in RawMenuAnchorOpenRequestedCallback for animations. It's the only way to access the position outside of overlayBuilder. I don't mind removing it -- we can always provide a different method of accessing the position if needed. Let me know and I'll push an update.
- I forgot: the other reason was to allow users to determine if the position has changed. Tong suggested that, when a user calls showOverlay, we stop checking if the position has changed before calling onOpen and continuing on with the function execution. Previously, we would only run the function body if the menu was previously closed/closing or when the position changed. Now, we call onOpen any time showOverlay is called. If users want to change this behavior, the position is an escape hatch.
Separate question: we could choose to only call onOpen and onClose when the overlay is first shown or hidden (e.g. not during opening <-> closing or position changes). I'm not sure what is most useful for users.
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 position
is provided by the caller of MenuController.open
. For example, it might be from the pointer event outside.
MenuController.open
passed this position to RawMenuController.handleOpenRequest
, which then send this position to the themed menu via onOpenRequested
. Later it will also send this position to overlayBuilder
. (The showOverlay
no longer receives a position.)
Although in most typical cases the position is only used for the overlayBuilder
to build an overlay at the correct position, we decided to also send it to onOpenRequested
to maximize flexibility of the themed menu. This is because onOpenRequested
is the earliest occasion when the themed menu can know the position. The themed menu might use for different reasons. One big reason could be to build something that also relies on the position but is not part of the overlay. There might be other usages, which I can't name, but I like this design for being simple and ensuring maximal flexibility.
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.
Animating reopening when the position changes is another good use case
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.
sounds good to me to keep this position and also removing the position from 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.
Done
/// even when the menu overlay is already showing. As a result, this callback | ||
/// can be used to observe when a menu is repositioned, or when a menu is | ||
/// reopened during a closing animation. | ||
/// |
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.
maybe mention this callback can be used for triggering animation for the opened 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.
Done
/// This callback is used by themed menu widgets to intercept close requests, | ||
/// typically with the intent of adding a delay or an animation before the | ||
/// menu is hidden. | ||
/// |
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.
handleCloseRequest is actually called regardless of whether the overlay is showing.
Is it only possible when someone called MenuController.closeChildren? still curious why we trigger this callback if menu is already closed
// Mount or reposition the menu before animating the menu open. | ||
showOverlay(position: position); | ||
|
||
if (_animationStatus.isForwardOrCompleted) { |
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.
if the possition is open in a different place, is it typical to retrigger the animation in the new place?
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.
OneDrive and Google Drive run the opening animation every time, MacOS, Google Docs, and Microsoft Word don't animate at all. If we wanted to animate on repositions, we'd need access to the position.
/// Typically, [onCloseRequested] consists of the following steps: | ||
/// | ||
/// 1. Optionally start the closing animation and wait for it to complete. | ||
/// 2. Call `hideOverlay` (whose call chain eventually invokes [onClose]). |
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.
Calling a widget onClose after a delay which the state may or may not have been destroyed is a footgun API. the parent widget which pass in the onClose callback may not expect it to be called after it is disposed.
not entirely sure what may be the best way to fix this issue with this type of api. Should the onClose be called synchronously when onCloseRequested is called? same of onOpen
I recently came across this in #162062 (comment)
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.
Right now, onClose can't be called after a disposal. If you check the dispose implementation in _RawMenuAnchorBaseMixin:

And if you check the close method on _RawMenuAnchorState:

When overlayController.hide() is called, isOpen synchronously flips to false. When isOpen is false, onClose won't get called. So, since dispose() calls close(), which hides the overlay and flips isOpen to false, onClose will not be called after disposal.
However, the open() method doesn't perform the same checks, so calling showOverlay after dispose() could call onOpen.
That said, even if showOverlay is called, isOpen will remain false since OverlayPortal checks to see whether a target is attached when returning a value for isShowing:

and the target (_OverlayPortalState) is detached during disposal:

This doesn't solve onOpen being called after disposal, but it does mean that onClose will never be called after disposal, since isOpen will always be false after disposal.
If you do try to call showOverlay after disposal, you should get an assertion, because the menu controller is no longer attached to RawMenuAnchor. This assertion prevents onOpen from being called, but if we wanted to block calls to showOverlay instead of asserting, we could just check for whether the component is mounted.
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 think you can make it a noop instead of an assertion? (Or make it throw an exception or a FlutterError if you deem proper) Assertions should only be used for internal errors (bugs with the framework's implementation) instead of user errors.
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.
Oh I didn't realize that... I switched it to checking if the component is mounted. Lmk if you all have a preference.
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.
In this case, I vote for noop. This is likely only show up in a corner case, which may not be easy to catch during development cycle. and it will make it slightly verbose to need to be aware of lifecycle during the async callback, and all customers will probably use the same code to prevent calls to showOverlay, might as well handle in the RawMenuAnchorState for the developer.
We should document this behavior in the api doc though, that calling showOverlay will be noop and onOpen will not be called if the widget is disposed
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. It was previously documented for onClose and onOpen, but I added docs for onCloseRequested and onOpenRequested. I also changed tests to reflect this behavior (before, I was testing for assertion)
widget.onCloseRequested(close); | ||
} else { | ||
SchedulerBinding.instance.addPostFrameCallback((_) { | ||
widget.onCloseRequested(close); |
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.
Same since this may potentially become a footgun
should this check if the widget has been disposed? i.e context.mounted
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 couldn't create a situation where this threw an error, possibly because of the phase checking. The finalization phase comes before the post frame callbacks, so I would expect an error. That said, since it's minimal overhead to add the mounted check for safety, I'll add a mounted check. Should I include a comment that clarifies that this may be an unnecessary check? I ask because there were many ambigious phase checks in the original codebase.
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 left out the comment, but did perform the mounted check.
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.
Left another comment in an old thread https://github.com/flutter/flutter/pull/167806/files/632b74dd01378e623e376780331b57b5c23006e0#r2138681431
besides the new comment i added, LGTM
/// This signature is used by the `showOverlay` argument in | ||
/// [RawMenuAnchor.onOpenRequested]. See [RawMenuAnchor.onOpenRequested] for | ||
/// more information. | ||
typedef RawMenuAnchorShowOverlayCallback = void Function(); |
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.
voidCallback
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.
or just use void callback in RawMenuAnchorOpenRequestedCallback, RawMenuAnchorCloseRequestedCallback
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
/// callback can be used to add a delay or a closing animation before the menu | ||
/// is hidden. | ||
/// | ||
/// After a close request is intercepted and closing behaviors have completed, |
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 this should mention this will be noop if disposed
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.
Added:
/// Calling `hideOverlay` after disposal is a no-op, meaning it will not
/// trigger [onClose] or hide the menu overlay.
/// A typical [onOpenRequested] consists of the following steps: | ||
/// | ||
/// 1. Optional delay. | ||
/// 2. Call `showOverlay` (whose call chain eventually invokes [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.
should also mention noop when disposed
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.
Added:
/// Calling `showOverlay` after disposal is a no-op, meaning it will not trigger
/// [onOpen] or show the menu overlay.
final Size newSize = MediaQuery.sizeOf(context); | ||
if (_viewSize != null && newSize != _viewSize && isOpen) { | ||
// Close the menus if the view changes size. | ||
handleCloseRequest(); |
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.
check for isOpen? or maybe add the shortcircuit logic in handleCloseRequest
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.
also continue on the discussion on whether to call handleCloseRequest when isClosed or closing.
When it isClosed, I think we have agreement that handleCloseRequest shouldn't be called. Regardless how it is triggered. (let me know otherwise)
When it is closing, do we allow people to not call hideOverlay when handleCloseRequest is called? if so I think we can't really tell whether it is still pending closing or the previous handleCloseRequest is aborted right?
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.
When it isClosed, I think we have agreement that handleCloseRequest shouldn't be called. Regardless how it is triggered. (let me know otherwise)
- I agree that we shouldn't call handleCloseRequested on resize/gesture if the menu is closed (this should already be implemented)
- I prefer calling handleCloseRequested when users call MenuController.close() while the menu is closed. Rationale:
- It seems confusing that handleOpenRequested is called when a menu is open, but handleCloseRequested is not called if a menu is closed.
- For developers of themed menus, adding animations is already trivial and blocking handleCloseRequested calls wouldn't make the code simpler
- It's slightly less opinionated
- To your other point, we have no way of blocking handleCloseRequested while the menu is closing. As a result, it's unexpected for handleCloseRequested to be called while the menu is closing, but not while the menu is closed.
That said, I also understand your point and am fine with preventing handleCloseRequested calls after the menu is closed, but I wanted to share the current reasoning and get your confirmation before blocking handleCloseRequested calls while the menu is closed.
When it is closing, do we allow people to not call hideOverlay when handleCloseRequest is called? if so I think we can't really tell whether it is still pending closing or the previous handleCloseRequest is aborted right?
Just to clarify: you're asking whether we should block handleCloseRequested calls if the menu is closing, but not closed?
To your point, we can't reliably tell if a menu is closing. While we could try to infer that a menu is closing if handleCloseRequested is called without being interrupted by handleOpenRequested, this would block new handleCloseRequested calls if a previous handleCloseRequested call is aborted (which you mentioned). As a result, themed menus could no longer choose to conditionally prevent menu closures.
So, it's more flexible to just allow redundant handleCloseRequested while the menu is closing. While this does lead to onCloseRequested being repeatedly called during resize, it's easy enough for themed menus to block redundant calls.
Anyways, let me know what your thoughts are and I'll update the codebase!
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:
onOpenRequested
andonCloseRequested
-- to RawMenuAnchor.When
MenuController.open()
andMenuController.close()
are called, onOpenRequested and onCloseRequested are invoked, respectively.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.