From 54c6893c4353c1ece439cbc4dad8f863c08494e4 Mon Sep 17 00:00:00 2001 From: Liubou Sakalouskaya <> Date: Tue, 7 Apr 2026 17:59:53 +0200 Subject: [PATCH] Add barrierBlocksInteraction to allow pass-through outside taps --- .../lib/src/dropdown_button2.dart | 19 +- .../lib/src/dropdown_route.dart | 177 ++++++++++++++---- .../test/dropdown_button2_test.dart | 88 +++++++++ 3 files changed, 245 insertions(+), 39 deletions(-) diff --git a/packages/dropdown_button2/lib/src/dropdown_button2.dart b/packages/dropdown_button2/lib/src/dropdown_button2.dart index 40323fc..a6928bd 100644 --- a/packages/dropdown_button2/lib/src/dropdown_button2.dart +++ b/packages/dropdown_button2/lib/src/dropdown_button2.dart @@ -7,18 +7,20 @@ import 'dart:math' as math; import 'dart:ui'; + import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'seperated_sliver_child_builder_delegate.dart'; part 'button_style_data.dart'; -part 'dropdown_style_data.dart'; -part 'dropdown_route.dart'; part 'dropdown_menu.dart'; part 'dropdown_menu_item.dart'; part 'dropdown_menu_separators.dart'; +part 'dropdown_route.dart'; +part 'dropdown_style_data.dart'; part 'enums.dart'; part 'utils.dart'; @@ -123,6 +125,7 @@ class DropdownButton2 extends StatefulWidget { this.openWithLongPress = false, this.barrierDismissible = true, this.barrierCoversButton = true, + this.barrierBlocksInteraction = true, this.barrierColor, this.barrierLabel, this.openDropdownListenable, @@ -163,6 +166,7 @@ class DropdownButton2 extends StatefulWidget { required this.openWithLongPress, required this.barrierDismissible, required this.barrierCoversButton, + required this.barrierBlocksInteraction, required this.barrierColor, required this.barrierLabel, required this.openDropdownListenable, @@ -352,6 +356,14 @@ class DropdownButton2 extends StatefulWidget { /// Defaults to true. final bool barrierCoversButton; + /// Whether to block interaction with underlying widgets when the dropdown is open. + /// + /// When false, taps outside the dropdown can pass through to underlying + /// widgets while still dismissing the dropdown. + /// + /// Defaults to true. + final bool barrierBlocksInteraction; + /// The color to use for the modal barrier. If this is null, the barrier will /// be transparent. final Color? barrierColor; @@ -698,6 +710,7 @@ class _DropdownButton2State extends State> with WidgetsBin barrierLabel: widget.barrierLabel ?? MaterialLocalizations.of(context).modalBarrierDismissLabel, barrierCoversButton: widget.barrierCoversButton, + barrierBlocksInteraction: widget.barrierBlocksInteraction, parentFocusNode: _focusNode, enableFeedback: widget.enableFeedback ?? true, textDirection: textDirection, @@ -1090,6 +1103,7 @@ class DropdownButtonFormField2 extends FormField { bool openWithLongPress = false, bool barrierDismissible = true, bool barrierCoversButton = true, + bool barrierBlocksInteraction = true, Color? barrierColor, String? barrierLabel, Listenable? openDropdownListenable, @@ -1181,6 +1195,7 @@ class DropdownButtonFormField2 extends FormField { openWithLongPress: openWithLongPress, barrierDismissible: barrierDismissible, barrierCoversButton: barrierCoversButton, + barrierBlocksInteraction: barrierBlocksInteraction, barrierColor: barrierColor, barrierLabel: barrierLabel, openDropdownListenable: openDropdownListenable, diff --git a/packages/dropdown_button2/lib/src/dropdown_route.dart b/packages/dropdown_button2/lib/src/dropdown_route.dart index 65241b5..315c9b5 100644 --- a/packages/dropdown_button2/lib/src/dropdown_route.dart +++ b/packages/dropdown_button2/lib/src/dropdown_route.dart @@ -14,6 +14,7 @@ class _DropdownRoute extends PopupRoute<_DropdownRouteResult> { Color? barrierColor, this.barrierLabel, required this.barrierCoversButton, + required this.barrierBlocksInteraction, required this.parentFocusNode, required this.enableFeedback, required this.textDirection, @@ -59,12 +60,22 @@ class _DropdownRoute extends PopupRoute<_DropdownRouteResult> { final bool barrierCoversButton; + final bool barrierBlocksInteraction; + final FocusScopeNode _childNode = FocusScopeNode(debugLabel: 'Child'); + @override + Widget buildModalBarrier() => IgnorePointer( + ignoring: !barrierBlocksInteraction, + child: super.buildModalBarrier(), + ); + @override Widget buildPage(BuildContext context, _, __) { + final TextDirection resolvedTextDirection = + textDirection ?? Directionality.of(context); return Directionality( - textDirection: textDirection ?? Directionality.of(context), + textDirection: resolvedTextDirection, child: FocusScope.withExternalFocusNode( focusScopeNode: _childNode, parentNode: parentFocusNode, @@ -94,15 +105,56 @@ class _DropdownRoute extends PopupRoute<_DropdownRouteResult> { style: style, enableFeedback: enableFeedback, ); - return barrierCoversButton + final dismissibleRoutePage = barrierBlocksInteraction ? routePage + : Stack( + fit: StackFit.expand, + children: [ + // In non-blocking mode, listen for outside taps so we can + // dismiss the menu, while still passing the same pointer + // event through to underlying widgets. + if (barrierDismissible) + Positioned.fill( + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (PointerDownEvent event) { + final Rect menuRect = getMenuRect( + buttonRect: rect, + availableSize: Size( + constraints.maxWidth, + actualConstraints.maxHeight, + ), + mediaQueryPadding: mediaQueryPadding, + textDirection: resolvedTextDirection, + ); + if (!menuRect.contains(event.position)) { + _dismiss(); + } + }, + ), + ), + routePage, + // Always intercept anchor-button taps while the menu is open + // so the underlying button does not receive the same gesture. + // If the route is dismissible, treat the tap as close. + Positioned.fromRect( + rect: rect, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: barrierDismissible ? _dismiss : () {}, + ), + ), + ], + ); + return barrierCoversButton + ? dismissibleRoutePage : _CustomModalBarrier( barrierColor: _altBarrierColor, animation: animation, barrierCurve: barrierCurve, buttonRect: rect, buttonBorderRadius: buttonBorderRadius ?? BorderRadius.zero, - child: routePage, + child: dismissibleRoutePage, ); }, ); @@ -242,6 +294,81 @@ class _DropdownRoute extends PopupRoute<_DropdownRouteResult> { return _MenuLimits(menuTop, menuBottom, menuHeight, scrollOffset); } + double getMenuWidth({ + required double availableWidth, + required double buttonWidth, + }) { + // The width of a menu should be at most the view width. This ensures that + // the menu does not extend past the left and right edges of the screen. + final double? configuredMenuWidth = dropdownStyle.width; + return math.min(availableWidth, configuredMenuWidth ?? buttonWidth); + } + + double getMenuLeft({ + required Rect buttonRect, + required double availableWidth, + required double menuWidth, + required TextDirection textDirection, + }) { + final Offset offset = dropdownStyle.offset; + return switch (dropdownStyle.direction) { + DropdownDirection.textDirection => switch (textDirection) { + TextDirection.rtl => clampDouble( + buttonRect.right - menuWidth + offset.dx, + 0.0, + availableWidth - menuWidth, + ), + TextDirection.ltr => clampDouble( + buttonRect.left + offset.dx, + 0.0, + availableWidth - menuWidth, + ), + }, + DropdownDirection.right => clampDouble( + buttonRect.left + offset.dx, + 0.0, + availableWidth - menuWidth, + ), + DropdownDirection.left => clampDouble( + buttonRect.right - menuWidth + offset.dx, + 0.0, + availableWidth - menuWidth, + ), + DropdownDirection.center => clampDouble( + (availableWidth - menuWidth) / 2 + offset.dx, + 0.0, + availableWidth - menuWidth, + ), + }; + } + + Rect getMenuRect({ + required Rect buttonRect, + required Size availableSize, + required EdgeInsets mediaQueryPadding, + required TextDirection textDirection, + }) { + final _MenuLimits menuLimits = getMenuLimits( + buttonRect, + availableSize.height, + mediaQueryPadding, + selectedIndex, + ); + + final double menuWidth = getMenuWidth( + availableWidth: availableSize.width, + buttonWidth: buttonRect.width, + ); + final double left = getMenuLeft( + buttonRect: buttonRect, + availableWidth: availableSize.width, + menuWidth: menuWidth, + textDirection: textDirection, + ); + + return Rect.fromLTWH(left, menuLimits.top, menuWidth, menuLimits.height); + } + // The maximum height of a simple menu should be one or more rows less than // the view height. This ensures a tappable area outside of the simple menu // with which to dismiss the menu. @@ -373,10 +500,10 @@ class _DropdownMenuRouteLayout extends SingleChildLayoutDelegate { if (preferredMaxHeight != null && preferredMaxHeight <= maxHeight) { maxHeight = preferredMaxHeight; } - // The width of a menu should be at most the view width. This ensures that - // the menu does not extend past the left and right edges of the screen. - final double? menuWidth = route.dropdownStyle.width; - final double width = math.min(constraints.maxWidth, menuWidth ?? buttonRect.width); + final double width = route.getMenuWidth( + availableWidth: constraints.maxWidth, + buttonWidth: buttonRect.width, + ); return BoxConstraints( minWidth: width, maxWidth: width, @@ -406,36 +533,12 @@ class _DropdownMenuRouteLayout extends SingleChildLayoutDelegate { }()); assert(textDirection != null); - final Offset offset = route.dropdownStyle.offset; - final double left = switch (route.dropdownStyle.direction) { - DropdownDirection.textDirection => switch (textDirection!) { - TextDirection.rtl => clampDouble( - buttonRect.right - childSize.width + offset.dx, - 0.0, - size.width - childSize.width, - ), - TextDirection.ltr => clampDouble( - buttonRect.left + offset.dx, - 0.0, - size.width - childSize.width, - ), - }, - DropdownDirection.right => clampDouble( - buttonRect.left + offset.dx, - 0.0, - size.width - childSize.width, - ), - DropdownDirection.left => clampDouble( - buttonRect.right - childSize.width + offset.dx, - 0.0, - size.width - childSize.width, - ), - DropdownDirection.center => clampDouble( - (size.width - childSize.width) / 2 + offset.dx, - 0.0, - size.width - childSize.width, - ), - }; + final double left = route.getMenuLeft( + buttonRect: buttonRect, + availableWidth: size.width, + menuWidth: childSize.width, + textDirection: textDirection!, + ); return Offset(left, menuLimits.top); } diff --git a/packages/dropdown_button2/test/dropdown_button2_test.dart b/packages/dropdown_button2/test/dropdown_button2_test.dart index fd4f9fa..071ed4b 100644 --- a/packages/dropdown_button2/test/dropdown_button2_test.dart +++ b/packages/dropdown_button2/test/dropdown_button2_test.dart @@ -681,4 +681,92 @@ void main() { }); }, ); + + group('barrierBlocksInteraction', () { + final List menuItems = List.generate(4, (int i) => i); + final findDropdownMenu = find.byType(ListView); + + List> buildItems() { + return menuItems.map((int item) { + return DropdownItem( + value: item, + child: Text(item.toString()), + ); + }).toList(); + } + + testWidgets( + 'barrierBlocksInteraction false: outside control receives tap and menu closes', + (WidgetTester tester) async { + int outsideTaps = 0; + final valueListenable = ValueNotifier(menuItems.first); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: DropdownButton2( + barrierBlocksInteraction: false, + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + ), + ), + ElevatedButton( + onPressed: () => outsideTaps++, + child: const Text('Outside'), + ), + ], + ), + ), + ), + ); + + await tester.tap(find.text('${valueListenable.value}')); + await tester.pumpAndSettle(); + expect(findDropdownMenu, findsOneWidget); + + await tester.tap(find.text('Outside')); + await tester.pumpAndSettle(); + + expect(outsideTaps, 1); + expect(findDropdownMenu, findsNothing); + }, + ); + + testWidgets( + 'barrierBlocksInteraction true: tapping scaffold outside menu dismisses', + (WidgetTester tester) async { + final valueListenable = ValueNotifier(menuItems.first); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: DropdownButton2( + valueListenable: valueListenable, + items: buildItems(), + onChanged: (_) {}, + ), + ), + ), + ), + ); + + await tester.tap(find.text('${valueListenable.value}')); + await tester.pumpAndSettle(); + expect(findDropdownMenu, findsOneWidget); + + // Bottom-right of the 800x600 test surface is outside the open menu. + await tester.tapAt(const Offset(780, 580)); + await tester.pumpAndSettle(); + + expect(findDropdownMenu, findsNothing); + }, + ); + }); }