Skip to content

[Material] showMenu() assumes the popup has an unconstrained height when it positions the popup #142896

@tvolkert

Description

@tvolkert

Steps to reproduce

Run the following app and click to open the popup menu:

import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(home: Scaffold(body: BugReport())));
}

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

  @override
  State<BugReport> createState() => _BugReportState();
}

class _BugReportState extends State<BugReport> {
  static const int _length = 50;

  List<PopupMenuEntry<int>> _buildItems(BuildContext context) {
    return List<PopupMenuEntry<int>>.generate(_length, (int index) {
      return PopupMenuItem<int>(value: index, child: Text('$index'));
    });
  }

  @override
  Widget build(BuildContext context) {
    final Size screenSize = MediaQuery.of(context).size;
    return Padding(
      padding: const EdgeInsets.all(50),
      child: Align(
        alignment: Alignment.bottomCenter,
        child: PopupMenuButton(
          itemBuilder: _buildItems,
          constraints: BoxConstraints(maxHeight: screenSize.height / 4),
          initialValue: _length - 1,
          child: const Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('Click here to open popup menu '),
              Icon(Icons.ads_click),
            ],
          ),
        ),
      ),
    );
  }
}

Expected behavior

You expect the popup to appear over the popup menu button.

Actual behavior

The popup appears at the top of the screen, far above the popup menu.

Diagnosis

The following code block assumes the popup menu will be given its full height (lower down in this method, it calls _fitInsideScreen, but that simply truncates the size of the popup - it doesn't reposition it):

// Find the ideal vertical position.
double y = position.top;
if (selectedItemIndex != null) {
double selectedItemOffset = _kMenuVerticalPadding;
for (int index = 0; index < selectedItemIndex!; index += 1) {
selectedItemOffset += itemSizes[index]!.height;
}
selectedItemOffset += itemSizes[selectedItemIndex!]!.height / 2;
y = y + buttonHeight / 2.0 - selectedItemOffset;
}

Test reproduction

Once fixed, this test should pass (as of this filing, the test fails):

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('PopupMenuButton properly positions a constrained-size popup', (WidgetTester tester) async {
    final Size windowSize = tester.view.physicalSize / tester.view.devicePixelRatio;
    const int length = 50;
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: Padding(
            padding: const EdgeInsets.all(50),
            child: Align(
              alignment: Alignment.bottomCenter,
              child: PopupMenuButton<int>(
                itemBuilder: (BuildContext context) {
                  return List<PopupMenuEntry<int>>.generate(length, (int index) {
                    return PopupMenuItem<int>(value: index, child: Text('item #$index'));
                  });
                },
                constraints: BoxConstraints(maxHeight: windowSize.height / 3),
                popUpAnimationStyle: AnimationStyle.noAnimation,
                initialValue: length - 1,
                child: const Text('click here'),
              ),
            ),
          ),
        ),
      ),
    );
    await tester.tap(find.text('click here'));
    await tester.pump();

    // Set up finders and verify basic widget structure
    final Finder findButton = find.byType(PopupMenuButton<int>);
    final Finder findLastItem = find.text('item #49');
    final Finder findListBody = find.byType(ListBody);
    final Finder findListViewport = find.ancestor(
      of: findListBody,
      matching: find.byType(SingleChildScrollView),
    );
    expect(findButton, findsOne);
    expect(findLastItem, findsOne);
    expect(findListBody, findsOne);
    expect(findListViewport, findsOne);

    // The button and the list viewport should overlap
    final RenderBox button = tester.renderObject<RenderBox>(findButton);
    final Rect buttonBounds = button.localToGlobal(Offset.zero) & button.size;
    final RenderBox listViewport = tester.renderObject<RenderBox>(findListViewport);
    final Rect listViewportBounds = listViewport.localToGlobal(Offset.zero) & listViewport.size;
    expect(listViewportBounds.topLeft.dy, lessThanOrEqualTo(windowSize.height));
    expect(listViewportBounds.bottomRight.dy, lessThanOrEqualTo(windowSize.height));
    expect(listViewportBounds, overlaps(buttonBounds));
  });
}

Matcher overlaps(Rect other) => OverlapsMatcher(other);

class OverlapsMatcher extends Matcher {
  OverlapsMatcher(this.other);

  final Rect other;

  @override
  Description describe(Description description) {
    return description.add("<Rect that overlaps with $other>");
  }

  @override
  bool matches(Object? item, Map matchState) => item is Rect && item.overlaps(other);

  @override
  Description describeMismatch(dynamic item, Description mismatchDescription,
      Map matchState, bool verbose) {
    return mismatchDescription.add("does not overlap");
  }
}

Related issues

  1. [Material] showMenu() doesn't scroll initial item to visible  #142895

Flutter version

[✓] Flutter (Channel main, 3.20.0-1.0.pre.20, on macOS 14.1 23B2073 darwin-arm64, locale en-US)
    • Flutter version 3.20.0-1.0.pre.20 on channel main at /Users/tvolkert/project/flutter/flutter
    • Upstream repository [email protected]:flutter/flutter.git
    • Framework revision f8a77225f3 (9 hours ago), 2024-02-04 14:28:18 +0000
    • Engine revision f34c658b96
    • Dart version 3.4.0 (build 3.4.0-99.0.dev)
    • DevTools version 2.31.0

Metadata

Metadata

Assignees

Labels

P2Important issues not at the top of the work listf: material designflutter/packages/flutter/material repository.frameworkflutter/packages/flutter repository. See also f: labels.team-designOwned by Design Languages teamtriaged-designTriaged by Design Languages team

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions