Skip to content

[widgets/raw_menu_anchor.dart] Call onCloseRequested on closed menus #186379

Description

@davidhicks980

Steps to reproduce

  1. Copy example into dartpad
  2. Run on master
  3. Hover over items slowly
  4. Run on stable
  5. Hover over items slowly

Expected results

Moving between menu items should reset the delay.

Actual results

Moving between menu items triggers a new delay regardless of whether the previous delay has finished.

Code sample

Code sample
import 'dart:async';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';

import 'dart:async';
/// Flutter code sample for a [RawMenuAnchor] that animates a nested menu using
/// [RawMenuAnchor.onOpenRequested] and [RawMenuAnchor.onCloseRequested].
void main() {
  runApp(const RawMenuAnchorSubmenuAnimationApp());
}

/// Signature for the function that builds a [Menu]'s contents.
typedef MenuPanelBuilder =
    Widget Function(BuildContext context);

/// Signature for the function that builds a [Menu]'s anchor button.
///
/// The [MenuController] can be used to open and close the menu.

typedef MenuButtonBuilder =
    Widget Function(
      BuildContext context,
      MenuController controller,
    );

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

  @override
  Widget build(BuildContext context) {
    return Menu(
      panelBuilder: (BuildContext context) {
        final MenuController rootMenuController = MenuController.maybeOf(
          context,
        )!;
        return Align(
          alignment: .topRight,
          child: Column(
            children: <Widget>[
              for (int i = 0; i < 4; i++)
                Menu(
                  panelBuilder: (BuildContext context) {
                    return SizedBox.square(
                      dimension: 120,
                      child: Center(
                        child: Text(
                          'Panel',
                          textAlign: .center,
                        ),
                      ),
                    );
                  },
                  buttonBuilder:
                      (
                        BuildContext context,
                        MenuController controller,
                      ) {
                        return MenuItemButton(
                          requestFocusOnHover: true,
                          onFocusChange: (bool focused) {
                            if (focused) {
                              controller.open();
                            }
                          },
                          onPressed: () {
                            if (!controller.isOpen) {
                              rootMenuController.closeChildren();
                              controller.open();
                            } else {
                              controller.close();
                            }
                          },
                          trailingIcon: const Icon(Icons.arrow_forward),
                          child: Text('Submenu $i'),
                        );
                      },
                ),
            ],
          ),
        );
      },
      buttonBuilder:
          (
            BuildContext context,
            MenuController controller,
          ) {
            return FilledButton(
              onPressed: () {
                if (controller.isOpen) {
                  controller.close();
                } else {
                  controller.open();
                }
              },
              child: const Text('Menu'),
            );
          },
    );
  }
}

class Menu extends StatefulWidget {
  const Menu({
    super.key,
    required this.panelBuilder,
    required this.buttonBuilder,
  });
  final MenuPanelBuilder panelBuilder;
  final MenuButtonBuilder buttonBuilder;

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

class MenuState extends State<Menu> with SingleTickerProviderStateMixin {
  final MenuController menuController = MenuController();
  bool get isSubmenu => MenuController.maybeOf(context) != null;

  
  Timer? openTimer;

  void _handleMenuOpenRequest(Offset? position, VoidCallback showOverlay) {
    MenuController.maybeOf(context)?.closeChildren();
    openTimer?.cancel();
    openTimer = Timer(Duration(milliseconds: 500), () {
      openTimer = null;
      showOverlay();
    });
  }

  void _handleMenuCloseRequest(VoidCallback hideOverlay) {
    openTimer?.cancel();
    openTimer = null;
    hideOverlay();
  }

  @override
  Widget build(BuildContext context) {
    return Semantics(
      role: .menu,
      child: RawMenuAnchor(
        controller: menuController,
        onOpenRequested: _handleMenuOpenRequest,
        onCloseRequested: _handleMenuCloseRequest,
        overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
          final ui.Offset position = isSubmenu
              ? info.anchorRect.topRight
              : info.anchorRect.bottomLeft;
          return Positioned(
            top: position.dy,
            left: position.dx,
            child: Semantics(
              explicitChildNodes: true,
              scopesRoute: true,
              // Remove focus while the menu is closing.
              child:  TapRegion(
                  groupId: info.tapRegionGroupId,
                  onTapOutside: (PointerDownEvent event) {
                    menuController.close();
                  },
                  child: Container(
                    color: Colors.white,
                    padding: EdgeInsets.all(10),
                    child: widget.panelBuilder(context)),
                ),
              ),
            
          );
        },
        builder:
            (BuildContext context, MenuController controller, Widget? child) {
              return widget.buttonBuilder(context, controller);
            },
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
     
      home: const Scaffold(
        body: Center(child: RawMenuAnchorSubmenuAnimationExample()),
      ),
    );
  }
}

Screenshots or Video

Screenshots / Video demonstration

Broken version (Main branch)

D539CF51-7049-4D74-A6E2-C5E6A0894702.mov

Correct version (Stable branch)

Screen.Recording.2026-05-11.at.6.24.59.PM.mov

Logs

Logs
N/A

Flutter Doctor output

Doctor output
Dart SDK 3.11.5 and Flutter SDK 3.41.9 (Dartpad)

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions