Close CupertinoContextMenu overlay if the widget is disposed or a new route is pushed (#170186)

Fixes [CupertinoContextMenu potential unremoved overlay
entry](https://github.com/flutter/flutter/issues/131471)
Fixes [CupertinoContextMenu onTap gesture interferes with child widget
with onTap
GestureRecognizer](https://github.com/flutter/flutter/issues/169911)

<details>
<summary>Sample code</summary>

```dart

import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

bool ctxMenuRemoved = false;

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

  @override
  Widget build(BuildContext context) {
    final colorScheme = ColorScheme.fromSeed(seedColor: Colors.orange);
    return MaterialApp(
      theme: ThemeData(
        colorScheme: colorScheme,
        appBarTheme:
            AppBarTheme(backgroundColor: colorScheme.secondaryContainer),
      ),
      home: const HomePage(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(
          title: Text('Home'),
        ),
        body: Center(
          child: CupertinoContextMenu(
            actions: [
              CupertinoContextMenuAction(
                child: Text('Test'),
              ),
            ],
            child: GestureDetector(
              onTap: () {
                Navigator.of(context).push(
                  MaterialPageRoute(builder: (context) => _OtherPage()),
                );
              },
              child: Container(
                color: Colors.orange,
                height: 100,
                width: 100,
              ),
            ),
          ),
        ),
      );
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Align(
        child: Builder(builder: (context) {
          return Listener(
            onPointerDown: (_) {
              Timer(const Duration(milliseconds: 480), () {
                ctxMenuRemoved = true;
                (context as Element).markNeedsBuild();
              });
            },
            child: ctxMenuRemoved
                ? const SizedBox()
                : CupertinoContextMenu(
                    actions: [
                      CupertinoContextMenuAction(
                        child: const Text('Action one'),
                        onPressed: () {},
                      ),
                    ],
                    child: Container(
                      height: 100,
                      width: 100,
                      color: Colors.black45,
                    ),
                  ),
          );
        }),
      ),
    );
  }
}


```

</details>
This commit is contained in:
Victor Sanni 2025-06-20 15:20:11 -07:00 committed by GitHub
parent cca7287639
commit be8cdeb84f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 121 additions and 18 deletions

View File

@ -488,6 +488,25 @@ class _CupertinoContextMenuState extends State<CupertinoContextMenu> with Ticker
_route!.animation!.addStatusListener(_routeAnimationStatusListener);
}
void _removeContextMenuDecoy() {
// Keep the decoy on the screen for one extra frame. We have to do this
// because _ContextMenuRoute renders its first frame offscreen.
// Otherwise there would be a visible flash when nothing is rendered for
// one frame.
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
if (mounted) {
_closeContextMenu();
_openController.reset();
}
}, debugLabel: 'removeContextMenuDecoy');
}
void _closeContextMenu() {
_lastOverlayEntry?.remove();
_lastOverlayEntry?.dispose();
_lastOverlayEntry = null;
}
void _onDecoyAnimationStatusChange(AnimationStatus animationStatus) {
switch (animationStatus) {
case AnimationStatus.dismissed:
@ -496,28 +515,15 @@ class _CupertinoContextMenuState extends State<CupertinoContextMenu> with Ticker
_childHidden = false;
});
}
_lastOverlayEntry?.remove();
_lastOverlayEntry?.dispose();
_lastOverlayEntry = null;
_closeContextMenu();
case AnimationStatus.completed:
setState(() {
_childHidden = true;
});
_openContextMenu();
// Keep the decoy on the screen for one extra frame. We have to do this
// because _ContextMenuRoute renders its first frame offscreen.
// Otherwise there would be a visible flash when nothing is rendered for
// one frame.
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
_lastOverlayEntry?.remove();
_lastOverlayEntry?.dispose();
_lastOverlayEntry = null;
_openController.reset();
}, debugLabel: 'removeContextMenuDecoy');
_removeContextMenuDecoy();
case AnimationStatus.forward:
case AnimationStatus.reverse:
if (!ModalRoute.of(context)!.isCurrent) {
_removeContextMenuDecoy();
}
return;
}
}
@ -617,6 +623,7 @@ class _CupertinoContextMenuState extends State<CupertinoContextMenu> with Ticker
@override
void dispose() {
_closeContextMenu();
_tapGestureRecognizer.dispose();
_openController.dispose();
super.dispose();

View File

@ -1145,4 +1145,100 @@ void main() {
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Item 1'), findsOneWidget);
});
testWidgets('Pushing a new route removes overlay', (WidgetTester tester) async {
final Widget child = getChild();
const String page = 'Page 2';
await tester.pumpWidget(
CupertinoApp(
home: Builder(
builder: (BuildContext context) {
return Center(
child: CupertinoContextMenu(
actions: const <Widget>[CupertinoContextMenuAction(child: Text('Test'))],
child: GestureDetector(
onTap: () {
Navigator.of(context).push(
CupertinoPageRoute<Widget>(
builder:
(BuildContext context) =>
const CupertinoPageScaffold(child: Text(page)),
),
);
},
child: child,
),
),
);
},
),
),
);
expect(find.byWidget(child), findsOneWidget);
final Rect childRect = tester.getRect(find.byWidget(child));
expect(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DecoyChild'), findsNothing);
// Start a press on the child.
final TestGesture gesture = await tester.startGesture(childRect.center);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text(page), findsNothing);
await tester.pump(const Duration(milliseconds: 300));
await gesture.up();
// Kickstart the route transition.
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
// As the transition starts, the overlay has been removed.
// Only the child transitioning out is shown.
expect(find.text(page), findsOneWidget);
expect(find.byWidget(child), findsOneWidget);
});
testWidgets('Removing context menu from widget tree removes overlay', (
WidgetTester tester,
) async {
final Widget child = getChild();
bool ctxMenuRemoved = false;
late StateSetter setState;
await tester.pumpWidget(
CupertinoApp(
home: StatefulBuilder(
builder: (BuildContext context, StateSetter stateSetter) {
setState = stateSetter;
return Center(
child:
ctxMenuRemoved
? const SizedBox()
: CupertinoContextMenu(
actions: <Widget>[
CupertinoContextMenuAction(child: const Text('Test'), onPressed: () {}),
],
child: child,
),
);
},
),
),
);
expect(find.byWidget(child), findsOneWidget);
final Rect childRect = tester.getRect(find.byWidget(child));
// Start a press on the child.
final TestGesture gesture = await tester.startGesture(childRect.center);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
setState(() {
ctxMenuRemoved = true;
});
await gesture.up();
await tester.pumpAndSettle();
expect(find.byWidget(child), findsNothing);
});
}