From 13a0d475f57b66c307b460deeda19cd92b2047cb Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 18 Oct 2023 13:13:08 -0700 Subject: [PATCH] Convert menus to use OverlayPortal (#130534) ## Description This converts the `MenuAnchor` class to use `OverlayPortal` instead of directly using the overlay. ## Related Issues - Fixes https://github.com/flutter/flutter/issues/124830 ## Tests - No tests yet (hence it is a draft) --- .../material/menu_anchor/menu_anchor.2.dart | 10 +- .../menu_anchor/menu_anchor.1_test.dart | 11 + .../flutter/lib/src/material/menu_anchor.dart | 343 ++++++++++-------- .../lib/src/rendering/viewport_offset.dart | 2 +- .../lib/src/widgets/focus_traversal.dart | 10 +- packages/flutter/lib/src/widgets/overlay.dart | 2 +- .../lib/src/widgets/scroll_position.dart | 12 +- .../flutter/lib/src/widgets/scrollable.dart | 13 +- .../test/material/dropdown_menu_test.dart | 14 +- .../material/dropdown_menu_theme_test.dart | 8 +- .../test/material/menu_anchor_test.dart | 248 +++++++------ 11 files changed, 384 insertions(+), 289 deletions(-) diff --git a/examples/api/lib/material/menu_anchor/menu_anchor.2.dart b/examples/api/lib/material/menu_anchor/menu_anchor.2.dart index 0a411a13a08..7c241d3c5f1 100644 --- a/examples/api/lib/material/menu_anchor/menu_anchor.2.dart +++ b/examples/api/lib/material/menu_anchor/menu_anchor.2.dart @@ -6,11 +6,12 @@ import 'package:flutter/material.dart'; /// Flutter code sample for [MenuAnchor]. -// This is the type used by the menu below. -enum SampleItem { itemOne, itemTwo, itemThree } void main() => runApp(const MenuAnchorApp()); +// This is the type used by the menu below. +enum SampleItem { itemOne, itemTwo, itemThree } + class MenuAnchorApp extends StatelessWidget { const MenuAnchorApp({super.key}); @@ -36,7 +37,10 @@ class _MenuAnchorExampleState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('MenuAnchorButton')), + appBar: AppBar( + title: const Text('MenuAnchorButton'), + backgroundColor: Theme.of(context).primaryColorLight, + ), body: Center( child: MenuAnchor( builder: diff --git a/examples/api/test/material/menu_anchor/menu_anchor.1_test.dart b/examples/api/test/material/menu_anchor/menu_anchor.1_test.dart index fdb3eeea4e4..f7633726f33 100644 --- a/examples/api/test/material/menu_anchor/menu_anchor.1_test.dart +++ b/examples/api/test/material/menu_anchor/menu_anchor.1_test.dart @@ -51,7 +51,18 @@ void main() { expect(find.text('Background Color'), findsOneWidget); + // Focusing the background color item with the keyboard caused the submenu + // to open. Tapping it should cause it to close. await tester.tap(find.text('Background Color')); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text(example.MenuEntry.colorRed.label), findsNothing); + expect(find.text(example.MenuEntry.colorGreen.label), findsNothing); + expect(find.text(example.MenuEntry.colorBlue.label), findsNothing); + + await tester.tap(find.text('Background Color')); + await tester.pump(); await tester.pumpAndSettle(); expect(find.text(example.MenuEntry.colorRed.label), findsOneWidget); diff --git a/packages/flutter/lib/src/material/menu_anchor.dart b/packages/flutter/lib/src/material/menu_anchor.dart index b512d50c9f4..2f0c3a48bd2 100644 --- a/packages/flutter/lib/src/material/menu_anchor.dart +++ b/packages/flutter/lib/src/material/menu_anchor.dart @@ -291,16 +291,17 @@ class _MenuAnchorState extends State { // for the anchor's region that the CustomSingleChildLayout's delegate // uses to determine where to place the menu on the screen and to avoid the // view's edges. - final GlobalKey _anchorKey = GlobalKey(debugLabel: kReleaseMode ? null : 'MenuAnchor'); + final GlobalKey<_MenuAnchorState> _anchorKey = GlobalKey<_MenuAnchorState>(debugLabel: kReleaseMode ? null : 'MenuAnchor'); _MenuAnchorState? _parent; - final FocusScopeNode _menuScopeNode = FocusScopeNode(debugLabel: kReleaseMode ? null : 'MenuAnchor sub menu'); + late final FocusScopeNode _menuScopeNode; MenuController? _internalMenuController; final List<_MenuAnchorState> _anchorChildren = <_MenuAnchorState>[]; - ScrollPosition? _position; + ScrollPosition? _scrollPosition; Size? _viewSize; - OverlayEntry? _overlayEntry; + final OverlayPortalController _overlayController = OverlayPortalController(debugLabel: kReleaseMode ? null : 'MenuAnchor controller'); + Offset? _menuPosition; Axis get _orientation => Axis.vertical; - bool get _isOpen => _overlayEntry != null; + bool get _isOpen => _overlayController.isShowing; bool get _isRoot => _parent == null; bool get _isTopLevel => _parent?._isRoot ?? false; MenuController get _menuController => widget.controller ?? _internalMenuController!; @@ -308,6 +309,7 @@ class _MenuAnchorState extends State { @override void initState() { super.initState(); + _menuScopeNode = FocusScopeNode(debugLabel: kReleaseMode ? null : '${describeIdentity(this)} Sub Menu'); if (widget.controller == null) { _internalMenuController = MenuController(); } @@ -331,12 +333,15 @@ class _MenuAnchorState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - _parent?._removeChild(this); - _parent = _MenuAnchorState._maybeOf(context); - _parent?._addChild(this); - _position?.isScrollingNotifier.removeListener(_handleScroll); - _position = Scrollable.maybeOf(context)?.position; - _position?.isScrollingNotifier.addListener(_handleScroll); + final _MenuAnchorState? newParent = _MenuAnchorState._maybeOf(context); + if (newParent != _parent) { + _parent?._removeChild(this); + _parent = newParent; + _parent?._addChild(this); + } + _scrollPosition?.isScrollingNotifier.removeListener(_handleScroll); + _scrollPosition = Scrollable.maybeOf(context)?.position; + _scrollPosition?.isScrollingNotifier.addListener(_handleScroll); final Size newSize = MediaQuery.sizeOf(context); if (_viewSize != null && newSize != _viewSize) { // Close the menus if the view changes size. @@ -360,18 +365,25 @@ class _MenuAnchorState extends State { } } assert(_menuController._anchor == this); - if (_overlayEntry != null) { - // Needs to update the overlay entry on the next frame, since it's in the - // overlay. - SchedulerBinding.instance.addPostFrameCallback((Duration _) { - _overlayEntry?.markNeedsBuild(); - }); - } } @override Widget build(BuildContext context) { - Widget child = _buildContents(context); + Widget child = OverlayPortal( + controller: _overlayController, + overlayChildBuilder: (BuildContext context) { + return _Submenu( + anchor: this, + menuStyle: widget.style, + alignmentOffset: widget.alignmentOffset ?? Offset.zero, + menuPosition: _menuPosition, + clipBehavior: widget.clipBehavior, + menuChildren: widget.menuChildren, + crossAxisUnconstrained: widget.crossAxisUnconstrained, + ); + }, + child: _buildContents(context), + ); if (!widget.anchorTapClosesMenu) { child = TapRegion( @@ -394,18 +406,20 @@ class _MenuAnchorState extends State { } Widget _buildContents(BuildContext context) { - return Builder( - key: _anchorKey, - builder: (BuildContext context) { - if (widget.builder == null) { - return widget.child ?? const SizedBox(); - } - return widget.builder!( - context, - _menuController, - widget.child, - ); + return Actions( + actions: >{ + DirectionalFocusIntent: _MenuDirectionalFocusAction(), + PreviousFocusIntent: _MenuPreviousFocusAction(), + NextFocusIntent: _MenuNextFocusAction(), + DismissIntent: DismissMenuAction(controller: _menuController), }, + child: Builder( + key: _anchorKey, + builder: (BuildContext context) { + return widget.builder?.call(context, _menuController, widget.child) + ?? widget.child ?? const SizedBox(); + }, + ), ); } @@ -424,33 +438,42 @@ class _MenuAnchorState extends State { assert(_isRoot || _debugMenuInfo('Added root child: $child')); assert(!_anchorChildren.contains(child)); _anchorChildren.add(child); + assert(_debugMenuInfo('Added:\n${child.widget.toStringDeep()}')); assert(_debugMenuInfo('Tree:\n${widget.toStringDeep()}')); } void _removeChild(_MenuAnchorState child) { assert(_isRoot || _debugMenuInfo('Removed root child: $child')); assert(_anchorChildren.contains(child)); + assert(_debugMenuInfo('Removing:\n${child.widget.toStringDeep()}')); _anchorChildren.remove(child); assert(_debugMenuInfo('Tree:\n${widget.toStringDeep()}')); } - _MenuAnchorState? get _nextSibling { - final int index = _parent!._anchorChildren.indexOf(this); - assert(index != -1, 'Unable to find this widget $this in parent $_parent'); - if (index < _parent!._anchorChildren.length - 1) { - return _parent!._anchorChildren[index + 1]; + List<_MenuAnchorState> _getFocusableChildren() { + if (_parent == null) { + return <_MenuAnchorState>[]; } - return null; + return _parent!._anchorChildren.where((_MenuAnchorState menu) { + return menu.widget.childFocusNode?.canRequestFocus ?? false; + },).toList(); } - _MenuAnchorState? get _previousSibling { - final int index = _parent!._anchorChildren.indexOf(this); - assert(index != -1, 'Unable to find this widget $this in parent $_parent'); - if (index > 0) { - return _parent!._anchorChildren[index - 1]; - } + _MenuAnchorState? get _nextFocusableSibling { + final List<_MenuAnchorState> focusable = _getFocusableChildren(); + if (focusable.isEmpty) { + return null; + } + return focusable[(focusable.indexOf(this) + 1) % focusable.length]; + } + +_MenuAnchorState? get _previousFocusableSibling { + final List<_MenuAnchorState> focusable = _getFocusableChildren(); + if (focusable.isEmpty) { return null; } + return focusable[(focusable.indexOf(this) - 1 + focusable.length) % focusable.length]; +} _MenuAnchorState get _root { _MenuAnchorState anchor = this; @@ -462,19 +485,27 @@ class _MenuAnchorState extends State { _MenuAnchorState get _topLevel { _MenuAnchorState handle = this; - while (handle._parent!._isTopLevel) { + while (handle._parent != null && !handle._parent!._isTopLevel) { handle = handle._parent!; } return handle; } void _childChangedOpenState() { - if (mounted) { - _parent?._childChangedOpenState(); + _parent?._childChangedOpenState(); + assert(mounted); + if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { setState(() { - // Mark dirty, but only if mounted. + // Mark dirty now, but only if not in a build. + }); + } else { + SchedulerBinding.instance.addPostFrameCallback((Duration _) { + setState(() { + // Mark dirty after this frame, but only if in a build. + }); }); } + } void _focusButton() { @@ -524,53 +555,12 @@ class _MenuAnchorState extends State { assert(_debugMenuInfo( 'Opening $this at ${position ?? Offset.zero} with alignment offset ${widget.alignmentOffset ?? Offset.zero}')); _parent?._closeChildren(); // Close all siblings. - assert(_overlayEntry == null); + assert(!_overlayController.isShowing); - final BuildContext outerContext = context; _parent?._childChangedOpenState(); - setState(() { - _overlayEntry = OverlayEntry( - builder: (BuildContext context) { - final OverlayState overlay = Overlay.of(outerContext); - return Positioned.directional( - textDirection: Directionality.of(outerContext), - top: 0, - start: 0, - child: Directionality( - textDirection: Directionality.of(outerContext), - child: InheritedTheme.captureAll( - // Copy all the themes from the supplied outer context to the - // overlay. - outerContext, - _MenuAnchorScope( - // Re-advertize the anchor here in the overlay, since - // otherwise a search for the anchor by descendants won't find - // it. - anchorKey: _anchorKey, - anchor: this, - isOpen: _isOpen, - child: _Submenu( - anchor: this, - menuStyle: widget.style, - alignmentOffset: widget.alignmentOffset ?? Offset.zero, - menuPosition: position, - clipBehavior: widget.clipBehavior, - menuChildren: widget.menuChildren, - crossAxisUnconstrained: widget.crossAxisUnconstrained, - ), - ), - to: overlay.context, - ), - ), - ); - }, - ); - }); + _menuPosition = position; + _overlayController.show(); - if (_isRoot) { - FocusManager.instance.addEarlyKeyEventHandler(_checkForEscape); - } - Overlay.of(context).insert(_overlayEntry!); widget.onOpen?.call(); } @@ -587,15 +577,24 @@ class _MenuAnchorState extends State { FocusManager.instance.removeEarlyKeyEventHandler(_checkForEscape); } _closeChildren(inDispose: inDispose); - _overlayEntry?.remove(); - _overlayEntry?.dispose(); - _overlayEntry = null; + // Don't hide if we're in the middle of a build. + if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { + _overlayController.hide(); + } else if (!inDispose) { + SchedulerBinding.instance.addPostFrameCallback((_) { + _overlayController.hide(); + }); + } if (!inDispose) { // Notify that _childIsOpen changed state, but only if not // currently disposing. _parent?._childChangedOpenState(); widget.onClose?.call(); - setState(() {}); + if (mounted && SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { + setState(() { + // Mark dirty, but only if mounted and not in a build. + }); + } } } @@ -612,6 +611,11 @@ class _MenuAnchorState extends State { static _MenuAnchorState? _maybeOf(BuildContext context) { return context.dependOnInheritedWidgetOfExactType<_MenuAnchorScope>()?.anchor; } + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.debug}) { + return describeIdentity(this); + } } /// A controller to manage a menu created by a [MenuBar] or [MenuAnchor]. @@ -1901,6 +1905,7 @@ class _SubmenuButtonState extends State { }); _waitingToFocusMenu = true; } + setState(() { /* Rebuild with updated controller.isOpen value */ }); widget.onOpen?.call(); }, style: widget.menuStyle, @@ -1911,9 +1916,7 @@ class _SubmenuButtonState extends State { // once. ButtonStyle mergedStyle = widget.themeStyleOf(context)?.merge(widget.defaultStyleOf(context)) ?? widget.defaultStyleOf(context); - if (widget.style != null) { - mergedStyle = widget.style!.merge(mergedStyle); - } + mergedStyle = widget.style?.merge(mergedStyle) ?? mergedStyle; void toggleShowMenu(BuildContext context) { if (controller._anchor == null) { @@ -1944,7 +1947,7 @@ class _SubmenuButtonState extends State { } child = MergeSemantics( child: Semantics( - expanded: controller.isOpen, + expanded: _enabled && controller.isOpen, child: TextButton( style: mergedStyle, focusNode: _buttonFocusNode, @@ -2330,17 +2333,20 @@ class _MenuBarAnchorState extends _MenuAnchorState { @override Widget _buildContents(BuildContext context) { + final bool isOpen = _isOpen; return FocusScope( node: _menuScopeNode, - skipTraversal: !_isOpen, - canRequestFocus: _isOpen, + skipTraversal: !isOpen, + canRequestFocus: isOpen, child: ExcludeFocus( - excluding: !_isOpen, + excluding: !isOpen, child: Shortcuts( shortcuts: _kMenuTraversalShortcuts, child: Actions( actions: >{ DirectionalFocusIntent: _MenuDirectionalFocusAction(), + PreviousFocusIntent: _MenuPreviousFocusAction(), + NextFocusIntent: _MenuNextFocusAction(), DismissIntent: DismissMenuAction(controller: _menuController), }, child: Builder(builder: (BuildContext context) { @@ -2365,6 +2371,53 @@ class _MenuBarAnchorState extends _MenuAnchorState { } } +class _MenuPreviousFocusAction extends PreviousFocusAction { + @override + bool invoke(PreviousFocusIntent intent) { + assert(_debugMenuInfo('_MenuNextFocusAction invoked with $intent')); + final BuildContext? context = FocusManager.instance.primaryFocus?.context; + if (context == null) { + return super.invoke(intent); + } + final _MenuAnchorState? anchor = _MenuAnchorState._maybeOf(context); + if (anchor == null || !anchor._root._isOpen) { + return super.invoke(intent); + } + + return _moveToPreviousFocusable(anchor); + } + + static bool _moveToPreviousFocusable(_MenuAnchorState currentMenu) { + final _MenuAnchorState? sibling = currentMenu._previousFocusableSibling; + sibling?._focusButton(); + return true; + } +} + +class _MenuNextFocusAction extends NextFocusAction { + @override + bool invoke(NextFocusIntent intent) { + assert(_debugMenuInfo('_MenuNextFocusAction invoked with $intent')); + final BuildContext? context = FocusManager.instance.primaryFocus?.context; + if (context == null) { + return super.invoke(intent); + } + final _MenuAnchorState? anchor = _MenuAnchorState._maybeOf(context); + if (anchor == null || !anchor._root._isOpen) { + return super.invoke(intent); + } + + return _moveToNextFocusable(anchor); + } + + static bool _moveToNextFocusable(_MenuAnchorState currentMenu) { + final _MenuAnchorState? sibling = currentMenu._nextFocusableSibling; + sibling?._focusButton(); + return true; + } + +} + class _MenuDirectionalFocusAction extends DirectionalFocusAction { /// Creates a [DirectionalFocusAction]. _MenuDirectionalFocusAction(); @@ -2384,7 +2437,7 @@ class _MenuDirectionalFocusAction extends DirectionalFocusAction { } final bool buttonIsFocused = anchor.widget.childFocusNode?.hasPrimaryFocus ?? false; Axis orientation; - if (buttonIsFocused) { + if (buttonIsFocused && anchor._parent != null) { orientation = anchor._parent!._orientation; } else { orientation = anchor._orientation; @@ -2442,19 +2495,20 @@ class _MenuDirectionalFocusAction extends DirectionalFocusAction { return; } } else { - if (_moveToNextTopLevel(anchor)) { + if (_moveToNextFocusableTopLevel(anchor)) { return; } } case TextDirection.ltr: - switch (anchor._parent!._orientation) { + switch (anchor._parent?._orientation) { case Axis.horizontal: - if (_moveToPreviousTopLevel(anchor)) { + case null: + if (_moveToPreviousFocusableTopLevel(anchor)) { return; } case Axis.vertical: if (buttonIsFocused) { - if (_moveToPreviousTopLevel(anchor)) { + if (_moveToPreviousFocusableTopLevel(anchor)) { return; } } else { @@ -2481,9 +2535,10 @@ class _MenuDirectionalFocusAction extends DirectionalFocusAction { case Axis.vertical: switch (Directionality.of(context)) { case TextDirection.rtl: - switch (anchor._parent!._orientation) { + switch (anchor._parent?._orientation) { case Axis.horizontal: - if (_moveToPreviousTopLevel(anchor)) { + case null: + if (_moveToPreviousFocusableTopLevel(anchor)) { return; } case Axis.vertical: @@ -2497,7 +2552,7 @@ class _MenuDirectionalFocusAction extends DirectionalFocusAction { return; } } else { - if (_moveToNextTopLevel(anchor)) { + if (_moveToNextFocusableTopLevel(anchor)) { return; } } @@ -2513,24 +2568,18 @@ class _MenuDirectionalFocusAction extends DirectionalFocusAction { // otherwise the anti-hysteresis code will interfere with moving to the // correct node. if (currentMenu.widget.childFocusNode != null) { - final FocusTraversalPolicy? policy = FocusTraversalGroup.maybeOf(primaryFocus!.context!); if (currentMenu.widget.childFocusNode!.nearestScope != null) { + final FocusTraversalPolicy? policy = FocusTraversalGroup.maybeOf(primaryFocus!.context!); policy?.invalidateScopeData(currentMenu.widget.childFocusNode!.nearestScope!); } - return false; } return false; } - bool _moveToNextTopLevel(_MenuAnchorState currentMenu) { - final _MenuAnchorState? sibling = currentMenu._topLevel._nextSibling; - if (sibling == null) { - // Wrap around to the first top level. - currentMenu._topLevel._parent!._anchorChildren.first._focusButton(); - } else { - sibling._focusButton(); - } - return true; + bool _moveToNextFocusableTopLevel(_MenuAnchorState currentMenu) { + final _MenuAnchorState? sibling = currentMenu._topLevel._nextFocusableSibling; + sibling?._focusButton(); + return true; } bool _moveToParent(_MenuAnchorState currentMenu) { @@ -2547,23 +2596,17 @@ class _MenuDirectionalFocusAction extends DirectionalFocusAction { // otherwise the anti-hysteresis code will interfere with moving to the // correct node. if (currentMenu.widget.childFocusNode != null) { - final FocusTraversalPolicy? policy = FocusTraversalGroup.maybeOf(primaryFocus!.context!); if (currentMenu.widget.childFocusNode!.nearestScope != null) { + final FocusTraversalPolicy? policy = FocusTraversalGroup.maybeOf(primaryFocus!.context!); policy?.invalidateScopeData(currentMenu.widget.childFocusNode!.nearestScope!); } - return false; } return false; } - bool _moveToPreviousTopLevel(_MenuAnchorState currentMenu) { - final _MenuAnchorState? sibling = currentMenu._topLevel._previousSibling; - if (sibling == null) { - // Already on the first one, wrap around to the last one. - currentMenu._topLevel._parent!._anchorChildren.last._focusButton(); - } else { - sibling._focusButton(); - } + bool _moveToPreviousFocusableTopLevel(_MenuAnchorState currentMenu) { + final _MenuAnchorState? sibling = currentMenu._topLevel._previousFocusableSibling; + sibling?._focusButton(); return true; } @@ -2903,7 +2946,7 @@ class _MenuAcceleratorLabelState extends State { super.initState(); if (_platformSupportsAccelerators) { _showAccelerators = _altIsPressed(); - HardwareKeyboard.instance.addHandler(_handleKeyEvent); + HardwareKeyboard.instance.addHandler(_listenToKeyEvent); } _updateDisplayLabel(); } @@ -2917,7 +2960,7 @@ class _MenuAcceleratorLabelState extends State { _shortcutRegistryEntry = null; _shortcutRegistry = null; _anchor = null; - HardwareKeyboard.instance.removeHandler(_handleKeyEvent); + HardwareKeyboard.instance.removeHandler(_listenToKeyEvent); } super.dispose(); } @@ -2952,16 +2995,13 @@ class _MenuAcceleratorLabelState extends State { ).isNotEmpty; } - bool _handleKeyEvent(KeyEvent event) { + bool _listenToKeyEvent(KeyEvent event) { assert(_platformSupportsAccelerators); - final bool altIsPressed = _altIsPressed(); - if (altIsPressed != _showAccelerators) { - setState(() { - _showAccelerators = altIsPressed; - _updateAcceleratorShortcut(); - }); - } - // Just listening, does't ever handle a key. + setState(() { + _showAccelerators = _altIsPressed(); + _updateAcceleratorShortcut(); + }); + // Just listening, so it doesn't ever handle a key. return false; } @@ -2979,7 +3019,7 @@ class _MenuAcceleratorLabelState extends State { // 4) Is part of an anchor that either doesn't have a submenu, or doesn't // have any submenus currently open (only the "deepest" open menu should // have accelerator shortcuts registered). - if (_showAccelerators && _acceleratorIndex != -1 && _binding?.onInvoke != null && !(_binding!.hasSubmenu && (_anchor?._isOpen ?? false))) { + if (_showAccelerators && _acceleratorIndex != -1 && _binding?.onInvoke != null && (!_binding!.hasSubmenu || !(_anchor?._isOpen ?? false))) { final String acceleratorCharacter = _displayLabel[_acceleratorIndex].toLowerCase(); _shortcutRegistryEntry = _shortcutRegistry?.addAll( { @@ -3452,7 +3492,7 @@ class _MenuPanelState extends State<_MenuPanel> { } } -// A widget that defines the menu drawn inside of the overlay entry. +// A widget that defines the menu drawn in the overlay. class _Submenu extends StatelessWidget { const _Submenu({ required this.anchor, @@ -3552,6 +3592,7 @@ class _Submenu extends StatelessWidget { hitTestBehavior: HitTestBehavior.deferToChild, child: FocusScope( node: anchor._menuScopeNode, + skipTraversal: true, child: Actions( actions: >{ DirectionalFocusIntent: _MenuDirectionalFocusAction(), @@ -3559,16 +3600,12 @@ class _Submenu extends StatelessWidget { }, child: Shortcuts( shortcuts: _kMenuTraversalShortcuts, - child: Directionality( - // Copy the directionality from the button into the overlay. - textDirection: textDirection, - child: _MenuPanel( - menuStyle: menuStyle, - clipBehavior: clipBehavior, - orientation: anchor._orientation, - crossAxisUnconstrained: crossAxisUnconstrained, - children: menuChildren, - ), + child: _MenuPanel( + menuStyle: menuStyle, + clipBehavior: clipBehavior, + orientation: anchor._orientation, + crossAxisUnconstrained: crossAxisUnconstrained, + children: menuChildren, ), ), ), diff --git a/packages/flutter/lib/src/rendering/viewport_offset.dart b/packages/flutter/lib/src/rendering/viewport_offset.dart index 071bcedf02e..a58f8720d9f 100644 --- a/packages/flutter/lib/src/rendering/viewport_offset.dart +++ b/packages/flutter/lib/src/rendering/viewport_offset.dart @@ -257,7 +257,7 @@ abstract class ViewportOffset extends ChangeNotifier { /// Whether a viewport is allowed to change [pixels] implicitly to respond to /// a call to [RenderObject.showOnScreen]. /// - /// [RenderObject.showOnScreen] is for example used to bring a text field + /// [RenderObject.showOnScreen] is, for example, used to bring a text field /// fully on screen after it has received focus. This property controls /// whether the viewport associated with this offset is allowed to change the /// offset's [pixels] value to fulfill such a request. diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart index 3136150149f..53455c7397a 100644 --- a/packages/flutter/lib/src/widgets/focus_traversal.dart +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -189,7 +189,8 @@ abstract class FocusTraversalPolicy with Diagnosticable { }) { node.requestFocus(); Scrollable.ensureVisible( - node.context!, alignment: alignment ?? 1.0, + node.context!, + alignment: alignment ?? 1, alignmentPolicy: alignmentPolicy ?? ScrollPositionAlignmentPolicy.explicit, duration: duration ?? Duration.zero, curve: curve ?? Curves.ease, @@ -467,7 +468,6 @@ abstract class FocusTraversalPolicy with Diagnosticable { groups[key]!.members.addAll(sortedMembers); } - // Traverse the group tree, adding the children of members in the order they // appear in the member lists. final List sortedDescendants = []; @@ -504,9 +504,9 @@ abstract class FocusTraversalPolicy with Diagnosticable { // The scope.traversalDescendants will not contain currentNode if it // skips traversal or not focusable. assert( - difference.length == 1 && difference.contains(currentNode), - 'Sorted descendants contains different nodes than FocusScopeNode.traversalDescendants would. ' - 'These are the different nodes: ${difference.where((FocusNode node) => node != currentNode)}', + difference.isEmpty || (difference.length == 1 && difference.contains(currentNode)), + 'Difference between sorted descendants and FocusScopeNode.traversalDescendants contains ' + 'something other than the current skipped node. This is the difference: $difference', ); return true; } diff --git a/packages/flutter/lib/src/widgets/overlay.dart b/packages/flutter/lib/src/widgets/overlay.dart index d95e8281548..3cafb4f3d72 100644 --- a/packages/flutter/lib/src/widgets/overlay.dart +++ b/packages/flutter/lib/src/widgets/overlay.dart @@ -1414,7 +1414,7 @@ class OverlayPortalController { : _zOrderIndex != null; } - /// Conventience method for toggling the current [isShowing] status. + /// Convenience method for toggling the current [isShowing] status. /// /// This method should typically not be called while the widget tree is being /// rebuilt. diff --git a/packages/flutter/lib/src/widgets/scroll_position.dart b/packages/flutter/lib/src/widgets/scroll_position.dart index e1ec77eae2b..1a23ce970e5 100644 --- a/packages/flutter/lib/src/widgets/scroll_position.dart +++ b/packages/flutter/lib/src/widgets/scroll_position.dart @@ -795,9 +795,13 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { Curve curve = Curves.ease, ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, RenderObject? targetRenderObject, - }) { + }) async { assert(object.attached); - final RenderAbstractViewport viewport = RenderAbstractViewport.of(object); + final RenderAbstractViewport? viewport = RenderAbstractViewport.maybeOf(object); + // If no viewport is found, return. + if (viewport == null) { + return; + } Rect? targetRect; if (targetRenderObject != null && targetRenderObject != object) { @@ -842,12 +846,12 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { } if (target == pixels) { - return Future.value(); + return; } if (duration == Duration.zero) { jumpTo(target); - return Future.value(); + return; } return animateTo(target, duration: duration, curve: curve); diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 86c350401d4..9393be46d3f 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -461,11 +461,12 @@ class Scrollable extends StatefulWidget { }) { final List> futures = >[]; - // The `targetRenderObject` is used to record the first target renderObject. - // If there are multiple scrollable widgets nested, we should let - // the `targetRenderObject` as visible as possible to improve the user experience. - // Otherwise, let the outer renderObject as visible as possible maybe cause - // the `targetRenderObject` invisible. + // The targetRenderObject is used to record the first target renderObject. + // If there are multiple scrollable widgets nested, the targetRenderObject + // is made to be as visible as possible to improve the user experience. If + // the targetRenderObject is already visible, then let the outer + // renderObject be as visible as possible. + // // Also see https://github.com/flutter/flutter/issues/65100 RenderObject? targetRenderObject; ScrollableState? scrollable = Scrollable.maybeOf(context); @@ -481,7 +482,7 @@ class Scrollable extends StatefulWidget { ); futures.addAll(newFutures); - targetRenderObject = targetRenderObject ?? context.findRenderObject(); + targetRenderObject ??= context.findRenderObject(); context = scrollable.context; scrollable = Scrollable.maybeOf(context); } diff --git a/packages/flutter/test/material/dropdown_menu_test.dart b/packages/flutter/test/material/dropdown_menu_test.dart index 6285802d7ff..3cbd44428da 100644 --- a/packages/flutter/test/material/dropdown_menu_test.dart +++ b/packages/flutter/test/material/dropdown_menu_test.dart @@ -61,7 +61,7 @@ void main() { final Finder menuMaterial = find.ancestor( of: find.widgetWithText(TextButton, TestMenu.mainMenu0.label), matching: find.byType(Material), - ).last; + ).at(1); Material material = tester.widget(menuMaterial); expect(material.color, themeData.colorScheme.surface); expect(material.shadowColor, themeData.colorScheme.shadow); @@ -143,7 +143,7 @@ void main() { final Finder menuMaterial = find.ancestor( of: find.byType(SingleChildScrollView), matching: find.byType(Material), - ); + ).first; final Size menuSize = tester.getSize(menuMaterial); expect(menuSize, const Size(180.0, 304.0)); @@ -161,7 +161,7 @@ void main() { final Finder updatedMenu = find.ancestor( of: find.byType(SingleChildScrollView), matching: find.byType(Material), - ); + ).first; final double updatedMenuWidth = tester.getSize(updatedMenu).width; expect(updatedMenuWidth, 200.0); }); @@ -192,7 +192,7 @@ void main() { final Finder menuMaterial = find.ancestor( of: find.byType(SingleChildScrollView), matching: find.byType(Material), - ); + ).first; final double menuWidth = tester.getSize(menuMaterial).width; expect(menuWidth, closeTo(180.5, 0.1)); @@ -210,7 +210,7 @@ void main() { final Finder updatedMenu = find.ancestor( of: find.byType(SingleChildScrollView), matching: find.byType(Material), - ); + ).first; final double updatedMenuWidth = tester.getSize(updatedMenu).width; expect(updatedMenuWidth, 200.0); }); @@ -644,8 +644,8 @@ void main() { final Finder menuMaterial = find.ancestor( of: find.widgetWithText(MenuItemButton, TestMenu.mainMenu0.label), matching: find.byType(Material), - ).last; - expect(menuMaterial, findsOneWidget); + ); + expect(menuMaterial, findsNWidgets(3)); // didChangeMetrics tester.view.physicalSize = const Size(700.0, 700.0); diff --git a/packages/flutter/test/material/dropdown_menu_theme_test.dart b/packages/flutter/test/material/dropdown_menu_theme_test.dart index 279f60256a2..6224440f57e 100644 --- a/packages/flutter/test/material/dropdown_menu_theme_test.dart +++ b/packages/flutter/test/material/dropdown_menu_theme_test.dart @@ -80,7 +80,7 @@ void main() { final Finder menuMaterial = find.ancestor( of: find.widgetWithText(TextButton, 'Item 0'), matching: find.byType(Material), - ).last; + ).at(1); Material material = tester.widget(menuMaterial); expect(material.color, themeData.colorScheme.surface); expect(material.shadowColor, themeData.colorScheme.shadow); @@ -159,7 +159,7 @@ void main() { final Finder menuMaterial = find.ancestor( of: find.widgetWithText(TextButton, 'Item 0'), matching: find.byType(Material), - ).last; + ).at(1); Material material = tester.widget(menuMaterial); expect(material.color, Colors.grey); expect(material.shadowColor, Colors.brown); @@ -262,7 +262,7 @@ void main() { final Finder menuMaterial = find.ancestor( of: find.widgetWithText(TextButton, 'Item 0'), matching: find.byType(Material), - ).last; + ).at(1); Material material = tester.widget(menuMaterial); expect(material.color, Colors.yellow); expect(material.shadowColor, Colors.green); @@ -383,7 +383,7 @@ void main() { final Finder menuMaterial = find.ancestor( of: find.widgetWithText(TextButton, 'Item 0'), matching: find.byType(Material), - ).last; + ).at(1); Material material = tester.widget(menuMaterial); expect(material.color, Colors.limeAccent); expect(material.shadowColor, Colors.deepOrangeAccent); diff --git a/packages/flutter/test/material/menu_anchor_test.dart b/packages/flutter/test/material/menu_anchor_test.dart index fb15af9d891..ada50f9c011 100644 --- a/packages/flutter/test/material/menu_anchor_test.dart +++ b/packages/flutter/test/material/menu_anchor_test.dart @@ -93,9 +93,11 @@ void main() { textDirection: textDirection, child: Column( children: [ - GestureDetector(onTap: () { - onPressed?.call(TestMenu.outsideButton); - }, child: Text(TestMenu.outsideButton.label)), + GestureDetector( + onTap: () { + onPressed?.call(TestMenu.outsideButton); + }, + child: Text(TestMenu.outsideButton.label)), MenuAnchor( childFocusNode: focusNode, controller: controller, @@ -279,10 +281,12 @@ void main() { ); // menu bar(horizontal menu) - Finder menuMaterial = find.ancestor( - of: find.byType(TextButton), - matching: find.byType(Material), - ).first; + Finder menuMaterial = find + .ancestor( + of: find.byType(TextButton), + matching: find.byType(Material), + ) + .first; Material material = tester.widget(menuMaterial); expect(opened, isEmpty); @@ -292,10 +296,12 @@ void main() { expect(material.elevation, 3.0); expect(material.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))); - Finder buttonMaterial = find.descendant( - of: find.byType(TextButton), - matching: find.byType(Material), - ).first; + Finder buttonMaterial = find + .descendant( + of: find.byType(TextButton), + matching: find.byType(Material), + ) + .first; material = tester.widget(buttonMaterial); expect(material.color, Colors.transparent); expect(material.elevation, 0.0); @@ -308,10 +314,12 @@ void main() { await tester.tap(find.text(TestMenu.mainMenu1.label)); await tester.pump(); - menuMaterial = find.ancestor( - of: find.widgetWithText(TextButton, TestMenu.subMenu10.label), - matching: find.byType(Material), - ).first; + menuMaterial = find + .ancestor( + of: find.widgetWithText(TextButton, TestMenu.subMenu10.label), + matching: find.byType(Material), + ) + .first; material = tester.widget(menuMaterial); expect(opened.last, equals(TestMenu.mainMenu1)); @@ -321,10 +329,12 @@ void main() { expect(material.elevation, 3.0); expect(material.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))); - buttonMaterial = find.descendant( - of: find.widgetWithText(TextButton, TestMenu.subMenu10.label), - matching: find.byType(Material), - ).first; + buttonMaterial = find + .descendant( + of: find.widgetWithText(TextButton, TestMenu.subMenu10.label), + matching: find.byType(Material), + ) + .first; material = tester.widget(buttonMaterial); expect(material.color, Colors.transparent); expect(material.elevation, 0.0); @@ -361,10 +371,12 @@ void main() { ); // menu bar(horizontal menu) - Finder menuMaterial = find.ancestor( - of: find.widgetWithText(TextButton, TestMenu.mainMenu5.label), - matching: find.byType(Material), - ).first; + Finder menuMaterial = find + .ancestor( + of: find.widgetWithText(TextButton, TestMenu.mainMenu5.label), + matching: find.byType(Material), + ) + .first; Material material = tester.widget(menuMaterial); expect(opened, isEmpty); @@ -374,10 +386,12 @@ void main() { expect(material.elevation, 3.0); expect(material.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))); - Finder buttonMaterial = find.descendant( - of: find.widgetWithText(TextButton, TestMenu.mainMenu5.label), - matching: find.byType(Material), - ).first; + Finder buttonMaterial = find + .descendant( + of: find.widgetWithText(TextButton, TestMenu.mainMenu5.label), + matching: find.byType(Material), + ) + .first; material = tester.widget(buttonMaterial); expect(material.color, Colors.transparent); expect(material.elevation, 0.0); @@ -388,10 +402,12 @@ void main() { await tester.tap(find.text(TestMenu.mainMenu2.label)); await tester.pump(); - menuMaterial = find.ancestor( - of: find.widgetWithText(TextButton, TestMenu.subMenu20.label), - matching: find.byType(Material), - ).first; + menuMaterial = find + .ancestor( + of: find.widgetWithText(TextButton, TestMenu.subMenu20.label), + matching: find.byType(Material), + ) + .first; material = tester.widget(menuMaterial); expect(material.color, themeData.colorScheme.surface); @@ -400,10 +416,12 @@ void main() { expect(material.elevation, 3.0); expect(material.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))); - buttonMaterial = find.descendant( - of: find.widgetWithText(TextButton, TestMenu.subMenu20.label), - matching: find.byType(Material), - ).first; + buttonMaterial = find + .descendant( + of: find.widgetWithText(TextButton, TestMenu.subMenu20.label), + matching: find.byType(Material), + ) + .first; material = tester.widget(buttonMaterial); expect(material.color, Colors.transparent); expect(material.elevation, 0.0); @@ -1137,7 +1155,6 @@ void main() { expect(closed, equals([TestMenu.mainMenu1])); }); - testWidgetsWithLeakTracking('Menus close and consume tap when open and tapped outside', (WidgetTester tester) async { await tester.pumpWidget( buildTestApp(consumesOutsideTap: true, onPressed: onPressed, onOpen: onOpen, onClose: onClose), @@ -1639,8 +1656,12 @@ void main() { child: const Text('Show menu'), ); }, - onOpen: () { rootOpened = true; }, - onClose: () { rootOpened = false; }, + onOpen: () { + rootOpened = true; + }, + onClose: () { + rootOpened = false; + }, menuChildren: createTestMenus( onPressed: onPressed, onOpen: onOpen, @@ -2483,9 +2504,9 @@ void main() { equals(const [ Rect.fromLTRB(4.0, 0.0, 112.0, 48.0), Rect.fromLTRB(112.0, 0.0, 220.0, 48.0), - Rect.fromLTRB(220.0, 0.0, 328.0, 48.0), - Rect.fromLTRB(328.0, 0.0, 506.0, 48.0), Rect.fromLTRB(112.0, 104.0, 326.0, 152.0), + Rect.fromLTRB(220.0, 0.0, 328.0, 48.0), + Rect.fromLTRB(328.0, 0.0, 506.0, 48.0) ]), ); }); @@ -2530,9 +2551,9 @@ void main() { equals(const [ Rect.fromLTRB(688.0, 0.0, 796.0, 48.0), Rect.fromLTRB(580.0, 0.0, 688.0, 48.0), - Rect.fromLTRB(472.0, 0.0, 580.0, 48.0), - Rect.fromLTRB(294.0, 0.0, 472.0, 48.0), Rect.fromLTRB(474.0, 104.0, 688.0, 152.0), + Rect.fromLTRB(472.0, 0.0, 580.0, 48.0), + Rect.fromLTRB(294.0, 0.0, 472.0, 48.0) ]), ); }); @@ -2575,9 +2596,9 @@ void main() { equals(const [ Rect.fromLTRB(4.0, 0.0, 112.0, 48.0), Rect.fromLTRB(112.0, 0.0, 220.0, 48.0), - Rect.fromLTRB(220.0, 0.0, 328.0, 48.0), - Rect.fromLTRB(328.0, 0.0, 506.0, 48.0), Rect.fromLTRB(86.0, 104.0, 300.0, 152.0), + Rect.fromLTRB(220.0, 0.0, 328.0, 48.0), + Rect.fromLTRB(328.0, 0.0, 506.0, 48.0) ]), ); }); @@ -2620,9 +2641,9 @@ void main() { equals(const [ Rect.fromLTRB(188.0, 0.0, 296.0, 48.0), Rect.fromLTRB(80.0, 0.0, 188.0, 48.0), + Rect.fromLTRB(0.0, 104.0, 214.0, 152.0), Rect.fromLTRB(-28.0, 0.0, 80.0, 48.0), - Rect.fromLTRB(-206.0, 0.0, -28.0, 48.0), - Rect.fromLTRB(0.0, 104.0, 214.0, 152.0) + Rect.fromLTRB(-206.0, 0.0, -28.0, 48.0) ]), ); }); @@ -3284,8 +3305,7 @@ void main() { children: [ TestSemantics( rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), - flags: [SemanticsFlag.hasEnabledState, - SemanticsFlag.hasExpandedState], + flags: [SemanticsFlag.hasEnabledState, SemanticsFlag.hasExpandedState], label: 'ABC', textDirection: TextDirection.ltr, ), @@ -3305,11 +3325,10 @@ void main() { MaterialApp( home: Center( child: SubmenuButton( - onHover: (bool value) {}, style: SubmenuButton.styleFrom(fixedSize: const Size(88.0, 36.0)), menuChildren: [ MenuItemButton( - style: SubmenuButton.styleFrom(fixedSize: const Size(120.0, 36.0)), + style: MenuItemButton.styleFrom(fixedSize: const Size(120.0, 36.0)), child: const Text('Item 0'), onPressed: () {}, ), @@ -3331,47 +3350,57 @@ void main() { TestSemantics( id: 1, rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), - children: [ + children: [ TestSemantics( id: 2, rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), - children: [ + children: [ TestSemantics( id: 3, rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), - flags: [SemanticsFlag.scopesRoute], - children: [ + flags: [SemanticsFlag.scopesRoute], + children: [ TestSemantics( id: 4, - flags: [SemanticsFlag.hasExpandedState, SemanticsFlag.isExpanded, SemanticsFlag.isFocused, SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, - SemanticsFlag.isFocusable], + flags: [ + SemanticsFlag.isFocused, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.hasExpandedState, + SemanticsFlag.isExpanded, + ], actions: [SemanticsAction.tap], label: 'ABC', rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), - ) - ] - ) - ] + ), + ], + ), + ], ), TestSemantics( - id: 6, - rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 64.0), - children: [ - TestSemantics( - id: 7, + id: 6, + rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 64.0), + children: [ + TestSemantics( + id: 7, + rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0), + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + id: 8, + label: 'Item 0', rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0), - flags: [SemanticsFlag.hasImplicitScrolling], - children: [ - TestSemantics( - id: 8, - label: 'Item 0', - rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0), - flags: [SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, SemanticsFlag.isFocusable], - actions: [SemanticsAction.tap], - ), + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, ], - ), - ], + actions: [SemanticsAction.tap], + ), + ], + ), + ], ), ], ), @@ -3392,27 +3421,32 @@ void main() { TestSemantics( id: 1, rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), - children: [ + children: [ TestSemantics( - id: 2, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), - children: [ - TestSemantics( - id: 3, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), - flags: [SemanticsFlag.scopesRoute], - children: [ - TestSemantics( - id: 4, - flags: [SemanticsFlag.hasExpandedState, SemanticsFlag.isFocused, SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, - SemanticsFlag.isFocusable], - actions: [SemanticsAction.tap], - label: 'ABC', - rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), - ) - ] - ) - ] + id: 2, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + children: [ + TestSemantics( + id: 3, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 4, + flags: [ + SemanticsFlag.hasExpandedState, + SemanticsFlag.isFocused, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: [SemanticsAction.tap], + label: 'ABC', + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + ), + ], + ), + ], ), ], ), @@ -3437,7 +3471,7 @@ void main() { final ThemeData themeData = ThemeData( textTheme: const TextTheme( labelLarge: menuTextStyle, - ) + ), ); await tester.pumpWidget( MaterialApp( @@ -3456,10 +3490,12 @@ void main() { ); // Test menu button text style uses the TextTheme.labelLarge. - Finder buttonMaterial = find.descendant( - of: find.byType(TextButton), - matching: find.byType(Material), - ).first; + Finder buttonMaterial = find + .descendant( + of: find.byType(TextButton), + matching: find.byType(Material), + ) + .first; Material material = tester.widget(buttonMaterial); expect(material.textStyle?.fontSize, menuTextStyle.fontSize); expect(material.textStyle?.fontStyle, menuTextStyle.fontStyle); @@ -3471,10 +3507,12 @@ void main() { await tester.pump(); // Test menu item text style uses the TextTheme.labelLarge. - buttonMaterial = find.descendant( - of: find.widgetWithText(TextButton, TestMenu.subMenu10.label), - matching: find.byType(Material), - ).first; + buttonMaterial = find + .descendant( + of: find.widgetWithText(TextButton, TestMenu.subMenu10.label), + matching: find.byType(Material), + ) + .first; material = tester.widget(buttonMaterial); expect(material.textStyle?.fontSize, menuTextStyle.fontSize); expect(material.textStyle?.fontStyle, menuTextStyle.fontStyle);