davidhicks980 dfadc91433
Add RawMenuAnchor animation callbacks (#167806)
Alternative to https://github.com/flutter/flutter/pull/163481,
https://github.com/flutter/flutter/pull/167537,
https://github.com/flutter/flutter/pull/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.

<hr />

### The problem
RawMenuAnchor synchronously shows or hides an overlay menu in response
to `MenuController.open()` and `MenuController.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:
- Adds two callbacks -- `onOpenRequested` and `onCloseRequested` -- to
RawMenuAnchor.
- onOpenRequested is called with a position and a showOverlay callback,
which opens the menu when called.
- onCloseRequested is called with a hideOverlay callback, which hides
the menu when called.

When `MenuController.open()` and `MenuController.close()` are called,
onOpenRequested and onCloseRequested are invoked, respectively.

Precursor for https://github.com/flutter/flutter/pull/143416,
https://github.com/flutter/flutter/issues/135025,
https://github.com/flutter/flutter/pull/143712

## Demo


https://github.com/user-attachments/assets/bb14abca-af26-45fe-8d45-289b5d07dab2

```dart
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// ignore_for_file: public_member_api_docs

import 'dart:ui' as ui;

import 'package:flutter/material.dart' hide MenuController, RawMenuAnchor, RawMenuOverlayInfo;

import 'raw_menu_anchor.dart';

/// Flutter code sample for a [RawMenuAnchor] that animates a simple menu using
/// [RawMenuAnchor.onOpenRequested] and [RawMenuAnchor.onCloseRequested].
void main() {
  runApp(const App());
}

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

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

class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
  late final AnimationController animationController;
  final MenuController menuController = MenuController();

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

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

  void _handleMenuOpenRequest(Offset? position, void Function({Offset? position}) showOverlay) {
    // Mount or reposition the menu before animating the menu open.
    showOverlay(position: position);

    if (animationController.isForwardOrCompleted) {
      // If the menu is already open or opening, the animation is already
      // running forward.
      return;
    }

    // Animate the menu into view. This will cancel the closing animation.
    animationController.forward();
  }

  void _handleMenuCloseRequest(VoidCallback hideOverlay) {
    if (!animationController.isForwardOrCompleted) {
      // If the menu is already closed or closing, do nothing.
      return;
    }

    // Animate the menu out of view.
    //
    // Be sure to use `whenComplete` so that the closing animation
    // can be interrupted by an opening animation.
    animationController.reverse().whenComplete(() {
      if (mounted) {
        // Hide the menu after the menu has closed
        hideOverlay();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return RawMenuAnchor(
      controller: menuController,
      onOpenRequested: _handleMenuOpenRequest,
      onCloseRequested: _handleMenuCloseRequest,
      overlayBuilder: (BuildContext context, RawMenuOverlayInfo info) {
        final ui.Offset position = info.anchorRect.bottomLeft;
        return Positioned(
          top: position.dy + 5,
          left: position.dx,
          child: TapRegion(
            groupId: info.tapRegionGroupId,
            child: Material(
              color: ColorScheme.of(context).primaryContainer,
              shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
              elevation: 3,
              child: SizeTransition(
                sizeFactor: animationController,
                child: const SizedBox(
                  height: 200,
                  width: 150,
                  child: Center(child: Text('Howdy', textAlign: TextAlign.center)),
                ),
              ),
            ),
          ),
        );
      },
      builder: (BuildContext context, MenuController menuController, Widget? child) {
        return FilledButton(
          onPressed: () {
            if (animationController.isForwardOrCompleted) {
              menuController.close();
            } else {
              menuController.open();
            }
          },
          child: const Text('Toggle Menu'),
        );
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue)),
      home: const Scaffold(body: Center(child: Menu())),
    );
  }
}

```

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

---------

Co-authored-by: Tong Mu <dkwingsmt@users.noreply.github.com>
2025-06-24 17:46:50 +00:00
..