From 342223f2e8052421eae09e9733b9228750785744 Mon Sep 17 00:00:00 2001 From: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:35:24 -0700 Subject: [PATCH] Implement `Autocomplete` layout with the new OverlayPortal childLayoutBuilder API (#165249) Fixes https://github.com/flutter/flutter/issues/160625 This also makes it very easy to implement https://github.com/flutter/flutter/issues/101620. ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [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 --- .../flutter/lib/src/widgets/autocomplete.dart | 315 ++++-------------- .../flutter/lib/src/widgets/shortcuts.dart | 2 +- .../test/widgets/autocomplete_test.dart | 46 +-- 3 files changed, 90 insertions(+), 273 deletions(-) diff --git a/packages/flutter/lib/src/widgets/autocomplete.dart b/packages/flutter/lib/src/widgets/autocomplete.dart index 08363b247c..47b9679b67 100644 --- a/packages/flutter/lib/src/widgets/autocomplete.dart +++ b/packages/flutter/lib/src/widgets/autocomplete.dart @@ -6,11 +6,10 @@ library; import 'dart:async'; -import 'dart:math' show max, min; +import 'dart:math' as math show max; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'actions.dart'; @@ -20,11 +19,9 @@ import 'editable_text.dart'; import 'focus_manager.dart'; import 'framework.dart'; import 'inherited_notifier.dart'; -import 'layout_builder.dart'; import 'overlay.dart'; import 'shortcuts.dart'; import 'tap_region.dart'; -import 'value_listenable_builder.dart'; // Examples can assume: // late BuildContext context; @@ -312,12 +309,6 @@ class RawAutocomplete extends StatefulWidget { } class _RawAutocompleteState extends State> { - final GlobalKey _fieldKey = GlobalKey(); - final LayerLink _optionsLayerLink = LayerLink(); - - /// The box constraints that the field was last built with. - final ValueNotifier _fieldBoxConstraints = ValueNotifier(null); - final OverlayPortalController _optionsViewController = OverlayPortalController( debugLabel: '_RawAutocompleteState', ); @@ -511,23 +502,70 @@ class _RawAutocompleteState extends State> } } - Widget _buildOptionsView(BuildContext context) { - return ValueListenableBuilder( - valueListenable: _fieldBoxConstraints, - builder: (BuildContext context, BoxConstraints? constraints, Widget? child) { - return _RawAutocompleteOptions( - fieldKey: _fieldKey, - optionsLayerLink: _optionsLayerLink, - optionsViewOpenDirection: widget.optionsViewOpenDirection, - overlayContext: context, - textDirection: Directionality.maybeOf(context), - highlightIndexNotifier: _highlightedOptionIndex, - fieldConstraints: _fieldBoxConstraints.value!, - builder: (BuildContext context) { - return widget.optionsViewBuilder(context, _select, _options); - }, - ); - }, + // A big enough height for about one item in the default + // Autocomplete.optionsViewBuilder. The assumption is that the user likely + // wants the list of options to move to stay on the screen rather than get any + // smaller than this. Allows Autocomplete to work when it has very little + // screen height available (as in b/317115348) by positioning itself on top of + // the field, while in other cases to size itself based on the height under + // the field. + static const double _kMinUsableHeight = kMinInteractiveDimension; + + Widget _buildOptionsView(BuildContext context, OverlayChildLayoutInfo layoutInfo) { + if (layoutInfo.childPaintTransform.determinant() == 0.0) { + // The child is not visible. + return const SizedBox.shrink(); + } + final Size fieldSize = layoutInfo.childSize; + final Matrix4 invertTransform = layoutInfo.childPaintTransform.clone()..invert(); + + // This may not work well if the paint transform has rotation in it. + // MatrixUtils.transformRect returns the bounding rect of the rotated overlay + // rect. + final Rect overlayRectInField = MatrixUtils.transformRect( + invertTransform, + Offset.zero & layoutInfo.overlaySize, + ); + + final double optionsViewMaxHeight = switch (widget.optionsViewOpenDirection) { + OptionsViewOpenDirection.up => -overlayRectInField.top, + OptionsViewOpenDirection.down => overlayRectInField.bottom - fieldSize.height, + }; + + final Size optionsViewBoundingBox = Size( + fieldSize.width, + math.max(optionsViewMaxHeight, _kMinUsableHeight), + ); + + final double originY = switch (widget.optionsViewOpenDirection) { + OptionsViewOpenDirection.up => overlayRectInField.top, + OptionsViewOpenDirection.down => overlayRectInField.bottom - optionsViewBoundingBox.height, + }; + + final Matrix4 transform = layoutInfo.childPaintTransform.clone()..translate(0.0, originY); + final Widget child = Builder( + builder: (BuildContext context) => widget.optionsViewBuilder(context, _select, _options), + ); + return Transform( + transform: transform, + child: Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: BoxConstraints.tight(optionsViewBoundingBox), + child: Align( + alignment: switch (widget.optionsViewOpenDirection) { + OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart, + OptionsViewOpenDirection.down => AlignmentDirectional.topStart, + }, + child: TextFieldTapRegion( + child: AutocompleteHighlightedOption( + highlightIndexNotifier: _highlightedOptionIndex, + child: child, + ), + ), + ), + ), + ), ); } @@ -569,7 +607,6 @@ class _RawAutocompleteState extends State> widget.focusNode?.removeListener(_updateOptionsViewVisibility); _internalFocusNode?.dispose(); _highlightedOptionIndex.dispose(); - _fieldBoxConstraints.dispose(); super.dispose(); } @@ -582,225 +619,21 @@ class _RawAutocompleteState extends State> _focusNode, _onFieldSubmitted, ) ?? - const SizedBox.shrink(); - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - // TODO(victorsanni): Also track the width of the field box so that the - // options view maintains the same width as the field if its width - // changes but its constraints remain unchanged. - _fieldBoxConstraints.value = constraints; - return OverlayPortal.targetsRootOverlay( - key: _fieldKey, - controller: _optionsViewController, - overlayChildBuilder: _buildOptionsView, - child: TextFieldTapRegion( - child: Shortcuts( - shortcuts: _shortcuts, - child: Actions( - actions: _actionMap, - child: CompositedTransformTarget(link: _optionsLayerLink, child: fieldView), - ), - ), - ), - ); - }, - ); - } -} - -class _RawAutocompleteOptions extends StatefulWidget { - const _RawAutocompleteOptions({ - required this.fieldKey, - required this.optionsLayerLink, - required this.optionsViewOpenDirection, - required this.overlayContext, - required this.textDirection, - required this.highlightIndexNotifier, - required this.builder, - required this.fieldConstraints, - }); - - final WidgetBuilder builder; - final GlobalKey fieldKey; - - final LayerLink optionsLayerLink; - final OptionsViewOpenDirection optionsViewOpenDirection; - final BuildContext overlayContext; - final TextDirection? textDirection; - final ValueNotifier highlightIndexNotifier; - final BoxConstraints fieldConstraints; - - @override - State<_RawAutocompleteOptions> createState() => _RawAutocompleteOptionsState(); -} - -class _RawAutocompleteOptionsState extends State<_RawAutocompleteOptions> { - VoidCallback? removeCompositionCallback; - Offset fieldOffset = Offset.zero; - - // Get the field offset if the field's position changes when its layer tree - // is composited, which occurs for example if the field is in a scroll view. - Offset _getFieldOffset() { - final RenderBox? fieldRenderBox = - widget.fieldKey.currentContext?.findRenderObject() as RenderBox?; - final RenderBox? overlay = - Overlay.of(widget.overlayContext).context.findRenderObject() as RenderBox?; - return fieldRenderBox?.localToGlobal(Offset.zero, ancestor: overlay) ?? Offset.zero; - } - - void _onLeaderComposition(Layer leaderLayer) { - SchedulerBinding.instance.addPostFrameCallback((Duration duration) { - if (!mounted) { - return; - } - final Offset nextFieldOffset = _getFieldOffset(); - if (nextFieldOffset != fieldOffset) { - setState(() { - fieldOffset = nextFieldOffset; - }); - } - }); - } - - @override - void initState() { - super.initState(); - removeCompositionCallback = widget.optionsLayerLink.leader?.addCompositionCallback( - _onLeaderComposition, - ); - } - - @override - void didUpdateWidget(_RawAutocompleteOptions oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.optionsLayerLink.leader != oldWidget.optionsLayerLink.leader) { - removeCompositionCallback?.call(); - removeCompositionCallback = widget.optionsLayerLink.leader?.addCompositionCallback( - _onLeaderComposition, - ); - } - } - - @override - void dispose() { - removeCompositionCallback?.call(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return CompositedTransformFollower( - link: widget.optionsLayerLink, - followerAnchor: switch (widget.optionsViewOpenDirection) { - OptionsViewOpenDirection.up => Alignment.bottomLeft, - OptionsViewOpenDirection.down => Alignment.topLeft, - }, - // When the field goes offscreen, don't show the options. - showWhenUnlinked: false, - child: CustomSingleChildLayout( - delegate: _RawAutocompleteOptionsLayoutDelegate( - layerLink: widget.optionsLayerLink, - fieldOffset: fieldOffset, - optionsViewOpenDirection: widget.optionsViewOpenDirection, - textDirection: Directionality.of(context), - fieldConstraints: widget.fieldConstraints, - ), - child: TextFieldTapRegion( - child: AutocompleteHighlightedOption( - highlightIndexNotifier: widget.highlightIndexNotifier, - // optionsViewBuilder must be able to look up - // AutocompleteHighlightedOption in its context. - child: Builder(builder: widget.builder), - ), + // Horizontally expand to make sure the options view's width won't be zero. + const SizedBox(width: double.infinity, height: 0.0); + return OverlayPortal.overlayChildLayoutBuilder( + controller: _optionsViewController, + overlayChildBuilder: _buildOptionsView, + child: TextFieldTapRegion( + child: Shortcuts( + shortcuts: _shortcuts, + child: Actions(actions: _actionMap, child: fieldView), ), ), ); } } -/// Positions the options view. -class _RawAutocompleteOptionsLayoutDelegate extends SingleChildLayoutDelegate { - _RawAutocompleteOptionsLayoutDelegate({ - required this.layerLink, - required this.fieldOffset, - required this.optionsViewOpenDirection, - required this.textDirection, - required this.fieldConstraints, - }) : assert(layerLink.leaderSize != null); - - /// Links the options in [RawAutocomplete.optionsViewBuilder] to the field in - /// [RawAutocomplete.fieldViewBuilder]. - final LayerLink layerLink; - - /// The position of the field in [RawAutocomplete.fieldViewBuilder]. - final Offset fieldOffset; - - /// A direction in which to open the options view overlay. - final OptionsViewOpenDirection optionsViewOpenDirection; - - /// The [TextDirection] of this part of the widget tree. - final TextDirection textDirection; - - /// The [BoxConstraints] for the field in [RawAutocomplete.fieldViewBuilder]. - final BoxConstraints fieldConstraints; - - // A big enough height for about one item in the default - // Autocomplete.optionsViewBuilder. The assumption is that the user likely - // wants the list of options to move to stay on the screen rather than get any - // smaller than this. Allows Autocomplete to work when it has very little - // screen height available (as in b/317115348) by positioning itself on top of - // the field, while in other cases to size itself based on the height under - // the field. - static const double _kMinUsableHeight = kMinInteractiveDimension; - - // Limits the child to the space above/below the field, with a minimum, and - // with the same maxWidth constraint as the field has. - @override - BoxConstraints getConstraintsForChild(BoxConstraints constraints) { - final Size fieldSize = layerLink.leaderSize!; - return BoxConstraints( - // The field width may be zero if this is a split RawAutocomplete with no - // field of its own. In that case, don't change the constraints width. - maxWidth: fieldSize.width == 0.0 ? constraints.maxWidth : fieldSize.width, - maxHeight: max(_kMinUsableHeight, switch (optionsViewOpenDirection) { - OptionsViewOpenDirection.down => constraints.maxHeight - fieldOffset.dy - fieldSize.height, - OptionsViewOpenDirection.up => fieldOffset.dy, - }), - ); - } - - // Positions the child above/below the field and aligned with the left/right - // side based on text direction. - @override - Offset getPositionForChild(Size size, Size childSize) { - final Size fieldSize = layerLink.leaderSize!; - final double dx = switch (textDirection) { - TextDirection.ltr => 0.0, - TextDirection.rtl => fieldSize.width - childSize.width, - }; - final double dy = switch (optionsViewOpenDirection) { - OptionsViewOpenDirection.down => min( - fieldSize.height, - size.height - childSize.height - fieldOffset.dy, - ), - OptionsViewOpenDirection.up => size.height - min(childSize.height, fieldOffset.dy), - }; - return Offset(dx, dy); - } - - @override - bool shouldRelayout(_RawAutocompleteOptionsLayoutDelegate oldDelegate) { - if (!fieldOffset.isFinite || !layerLink.leaderSize!.isFinite) { - return false; - } - return layerLink != oldDelegate.layerLink || - fieldOffset != oldDelegate.fieldOffset || - optionsViewOpenDirection != oldDelegate.optionsViewOpenDirection || - textDirection != oldDelegate.textDirection || - fieldConstraints != oldDelegate.fieldConstraints; - } -} - class _AutocompleteCallbackAction extends CallbackAction { _AutocompleteCallbackAction({required super.onInvoke, required this.isEnabledCallback}); diff --git a/packages/flutter/lib/src/widgets/shortcuts.dart b/packages/flutter/lib/src/widgets/shortcuts.dart index e8e4db1d05..181b52d7d8 100644 --- a/packages/flutter/lib/src/widgets/shortcuts.dart +++ b/packages/flutter/lib/src/widgets/shortcuts.dart @@ -204,7 +204,7 @@ abstract class ShortcutActivator { /// /// If provided, trigger keys can be used as a first-pass filter for incoming /// events in order to optimize lookups, as [Intent]s are stored in a [Map] - /// and indexed by trigger keys. It is up to the individual implementors of + /// and indexed by trigger keys. It is up to the individual implementers of /// this interface to decide if they ignore triggers or not. /// /// Subclasses should make sure that the return value of this method does not diff --git a/packages/flutter/test/widgets/autocomplete_test.dart b/packages/flutter/test/widgets/autocomplete_test.dart index e42125624b..8f4d6ede66 100644 --- a/packages/flutter/test/widgets/autocomplete_test.dart +++ b/packages/flutter/test/widgets/autocomplete_test.dart @@ -326,8 +326,6 @@ void main() { // Tap on the text field to open the options. await tester.tap(find.byKey(fieldKey)); - // Two pumps required due to post frame callback. - await tester.pump(); await tester.pump(); expect(find.byKey(optionsKey), findsOneWidget); expect(lastOptions.length, kOptions.length); @@ -446,17 +444,10 @@ void main() { ); } - // Add an extra pump to account for any potential frame delays introduced - // by the post frame callback in the _RawAutocompleteOptions - // implementation. - await tester.pump(); setState(() { alignment = Alignment.topCenter; }); - // One frame for the field to move and one frame for the options to - // follow. - await tester.pump(); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); @@ -596,8 +587,6 @@ void main() { expect(find.byKey(optionsKey), findsNothing); await tester.tap(find.byType(TextField)); - // Two pumps required due to post frame callback. - await tester.pump(); await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget); @@ -1129,10 +1118,6 @@ void main() { final Size fieldSize = tester.getSize(find.byKey(fieldKey)); expect(optionsTopLeft.dy, fieldOffset.dy + fieldSize.height); - // Add an extra pump to account for any potential frame delays introduced by - // the post frame callback in the _RawAutocompleteOptions implementation. - await tester.pump(); - // Move the field (similar to as if the keyboard opened). The options move // to follow the field. setState(() { @@ -2210,9 +2195,6 @@ void main() { final RenderBox optionsBox = tester.renderObject(find.byKey(optionsKey)); expect(optionsBox.size.width, 100.0); - // Add an extra pump to account for any potential frame delays introduced by - // the post frame callback in the _RawAutocompleteOptions implementation. - await tester.pump(); setState(() { width = 200.0; }); @@ -2248,21 +2230,26 @@ void main() { FocusNode focusNode, VoidCallback onSubmitted, ) { - return TextField(key: fieldKey, focusNode: focusNode, controller: textEditingController); + return StatefulBuilder( + builder: (BuildContext context, StateSetter localStateSetter) { + setState = localStateSetter; + return SizedBox( + width: width, + child: TextField( + key: fieldKey, + focusNode: focusNode, + controller: textEditingController, + ), + ); + }, + ); }, ); + await tester.pumpWidget( MaterialApp( home: Scaffold( - body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: StatefulBuilder( - builder: (BuildContext context, StateSetter localStateSetter) { - setState = localStateSetter; - return SizedBox(width: width, child: autocomplete); - }, - ), - ), + body: Padding(padding: const EdgeInsets.symmetric(horizontal: 32.0), child: autocomplete), ), ), ); @@ -2283,9 +2270,6 @@ void main() { expect(fieldBox.size.width, 100.0); expect(optionsBox.size.width, 100.0); - // Add an extra pump to account for any potential frame delays introduced by - // the post frame callback in the _RawAutocompleteOptions implementation. - await tester.pump(); setState(() { width = 200.0; });