Skip to content

Commit 3297454

Browse files
authored
Reland "flutter#143249 Autocomplete options width" (flutter#161695)
Original PR: flutter#143249 Revert PR: flutter#161666 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [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
1 parent f545df5 commit 3297454

File tree

4 files changed

+1688
-263
lines changed

4 files changed

+1688
-263
lines changed

packages/flutter/lib/src/material/autocomplete.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ class Autocomplete<T extends Object> extends StatelessWidget {
134134
onSelected: onSelected,
135135
options: options,
136136
openDirection: optionsViewOpenDirection,
137-
maxOptionsHeight: optionsMaxHeight,
137+
optionsMaxHeight: optionsMaxHeight,
138138
);
139139
},
140140
onSelected: onSelected,
@@ -176,7 +176,7 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget {
176176
required this.onSelected,
177177
required this.openDirection,
178178
required this.options,
179-
required this.maxOptionsHeight,
179+
required this.optionsMaxHeight,
180180
});
181181

182182
final AutocompleteOptionToString<T> displayStringForOption;
@@ -185,7 +185,7 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget {
185185
final OptionsViewOpenDirection openDirection;
186186

187187
final Iterable<T> options;
188-
final double maxOptionsHeight;
188+
final double optionsMaxHeight;
189189

190190
@override
191191
Widget build(BuildContext context) {
@@ -198,7 +198,7 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget {
198198
child: Material(
199199
elevation: 4.0,
200200
child: ConstrainedBox(
201-
constraints: BoxConstraints(maxHeight: maxOptionsHeight),
201+
constraints: BoxConstraints(maxHeight: optionsMaxHeight),
202202
child: ListView.builder(
203203
padding: EdgeInsets.zero,
204204
shrinkWrap: true,

packages/flutter/lib/src/widgets/autocomplete.dart

Lines changed: 239 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,24 @@
66
library;
77

88
import 'dart:async';
9+
import 'dart:math' show max, min;
910

11+
import 'package:flutter/rendering.dart';
12+
import 'package:flutter/scheduler.dart';
1013
import 'package:flutter/services.dart';
1114

1215
import 'actions.dart';
1316
import 'basic.dart';
17+
import 'constants.dart';
1418
import 'editable_text.dart';
1519
import 'focus_manager.dart';
1620
import 'framework.dart';
1721
import 'inherited_notifier.dart';
22+
import 'layout_builder.dart';
1823
import 'overlay.dart';
1924
import 'shortcuts.dart';
2025
import 'tap_region.dart';
26+
import 'value_listenable_builder.dart';
2127

2228
// Examples can assume:
2329
// late BuildContext context;
@@ -213,10 +219,10 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
213219
/// {@template flutter.widgets.RawAutocomplete.optionsViewBuilder}
214220
/// Builds the selectable options widgets from a list of options objects.
215221
///
216-
/// The options are displayed floating below or above the field using a
217-
/// [CompositedTransformFollower] inside of an [Overlay], not at the same
218-
/// place in the widget tree as [RawAutocomplete]. To control whether it opens
219-
/// upward or downward, use [optionsViewOpenDirection].
222+
/// The options are displayed floating below or above the field inside of an
223+
/// [Overlay], not at the same place in the widget tree as [RawAutocomplete].
224+
/// To control whether it opens upward or downward, use
225+
/// [optionsViewOpenDirection].
220226
///
221227
/// In order to track which item is highlighted by keyboard navigation, the
222228
/// resulting options will be wrapped in an inherited
@@ -307,6 +313,10 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
307313
class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> {
308314
final GlobalKey _fieldKey = GlobalKey();
309315
final LayerLink _optionsLayerLink = LayerLink();
316+
317+
/// The box constraints that the field was last built with.
318+
final ValueNotifier<BoxConstraints?> _fieldBoxConstraints = ValueNotifier<BoxConstraints?>(null);
319+
310320
final OverlayPortalController _optionsViewController = OverlayPortalController(
311321
debugLabel: '_RawAutocompleteState',
312322
);
@@ -439,30 +449,22 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
439449
}
440450

441451
Widget _buildOptionsView(BuildContext context) {
442-
final TextDirection textDirection = Directionality.of(context);
443-
final Alignment followerAlignment = switch (widget.optionsViewOpenDirection) {
444-
OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart,
445-
OptionsViewOpenDirection.down => AlignmentDirectional.topStart,
446-
}.resolve(textDirection);
447-
final Alignment targetAnchor = switch (widget.optionsViewOpenDirection) {
448-
OptionsViewOpenDirection.up => AlignmentDirectional.topStart,
449-
OptionsViewOpenDirection.down => AlignmentDirectional.bottomStart,
450-
}.resolve(textDirection);
451-
452-
return CompositedTransformFollower(
453-
link: _optionsLayerLink,
454-
showWhenUnlinked: false,
455-
targetAnchor: targetAnchor,
456-
followerAnchor: followerAlignment,
457-
child: TextFieldTapRegion(
458-
child: AutocompleteHighlightedOption(
452+
return ValueListenableBuilder<BoxConstraints?>(
453+
valueListenable: _fieldBoxConstraints,
454+
builder: (BuildContext context, BoxConstraints? constraints, Widget? child) {
455+
return _RawAutocompleteOptions(
456+
fieldKey: _fieldKey,
457+
optionsLayerLink: _optionsLayerLink,
458+
optionsViewOpenDirection: widget.optionsViewOpenDirection,
459+
overlayContext: context,
460+
textDirection: Directionality.maybeOf(context),
459461
highlightIndexNotifier: _highlightedOptionIndex,
460-
child: Builder(
461-
builder:
462-
(BuildContext context) => widget.optionsViewBuilder(context, _select, _options),
463-
),
464-
),
465-
),
462+
fieldConstraints: _fieldBoxConstraints.value!,
463+
builder: (BuildContext context) {
464+
return widget.optionsViewBuilder(context, _select, _options);
465+
},
466+
);
467+
},
466468
);
467469
}
468470

@@ -504,6 +506,7 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
504506
widget.focusNode?.removeListener(_updateOptionsViewVisibility);
505507
_internalFocusNode?.dispose();
506508
_highlightedOptionIndex.dispose();
509+
_fieldBoxConstraints.dispose();
507510
super.dispose();
508511
}
509512

@@ -517,25 +520,224 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
517520
_onFieldSubmitted,
518521
) ??
519522
const SizedBox.shrink();
520-
return OverlayPortal.targetsRootOverlay(
521-
controller: _optionsViewController,
522-
overlayChildBuilder: _buildOptionsView,
523-
child: TextFieldTapRegion(
524-
child: SizedBox(
523+
return LayoutBuilder(
524+
builder: (BuildContext context, BoxConstraints constraints) {
525+
// TODO(victorsanni): Also track the width of the field box so that the
526+
// options view maintains the same width as the field if its width
527+
// changes but its constraints remain unchanged.
528+
_fieldBoxConstraints.value = constraints;
529+
return OverlayPortal.targetsRootOverlay(
525530
key: _fieldKey,
526-
child: Shortcuts(
527-
shortcuts: _shortcuts,
528-
child: Actions(
529-
actions: _actionMap,
530-
child: CompositedTransformTarget(link: _optionsLayerLink, child: fieldView),
531+
controller: _optionsViewController,
532+
overlayChildBuilder: _buildOptionsView,
533+
child: TextFieldTapRegion(
534+
child: Shortcuts(
535+
shortcuts: _shortcuts,
536+
child: Actions(
537+
actions: _actionMap,
538+
child: CompositedTransformTarget(link: _optionsLayerLink, child: fieldView),
539+
),
531540
),
532541
),
542+
);
543+
},
544+
);
545+
}
546+
}
547+
548+
class _RawAutocompleteOptions extends StatefulWidget {
549+
const _RawAutocompleteOptions({
550+
required this.fieldKey,
551+
required this.optionsLayerLink,
552+
required this.optionsViewOpenDirection,
553+
required this.overlayContext,
554+
required this.textDirection,
555+
required this.highlightIndexNotifier,
556+
required this.builder,
557+
required this.fieldConstraints,
558+
});
559+
560+
final WidgetBuilder builder;
561+
final GlobalKey fieldKey;
562+
563+
final LayerLink optionsLayerLink;
564+
final OptionsViewOpenDirection optionsViewOpenDirection;
565+
final BuildContext overlayContext;
566+
final TextDirection? textDirection;
567+
final ValueNotifier<int> highlightIndexNotifier;
568+
final BoxConstraints fieldConstraints;
569+
570+
@override
571+
State<_RawAutocompleteOptions> createState() => _RawAutocompleteOptionsState();
572+
}
573+
574+
class _RawAutocompleteOptionsState extends State<_RawAutocompleteOptions> {
575+
VoidCallback? removeCompositionCallback;
576+
Offset fieldOffset = Offset.zero;
577+
578+
// Get the field offset if the field's position changes when its layer tree
579+
// is composited, which occurs for example if the field is in a scroll view.
580+
Offset _getFieldOffset() {
581+
final RenderBox? fieldRenderBox =
582+
widget.fieldKey.currentContext?.findRenderObject() as RenderBox?;
583+
final RenderBox? overlay =
584+
Overlay.of(widget.overlayContext).context.findRenderObject() as RenderBox?;
585+
return fieldRenderBox?.localToGlobal(Offset.zero, ancestor: overlay) ?? Offset.zero;
586+
}
587+
588+
void _onLeaderComposition(Layer leaderLayer) {
589+
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
590+
if (!mounted) {
591+
return;
592+
}
593+
final Offset nextFieldOffset = _getFieldOffset();
594+
if (nextFieldOffset != fieldOffset) {
595+
setState(() {
596+
fieldOffset = nextFieldOffset;
597+
});
598+
}
599+
});
600+
}
601+
602+
@override
603+
void initState() {
604+
super.initState();
605+
removeCompositionCallback = widget.optionsLayerLink.leader?.addCompositionCallback(
606+
_onLeaderComposition,
607+
);
608+
}
609+
610+
@override
611+
void didUpdateWidget(_RawAutocompleteOptions oldWidget) {
612+
super.didUpdateWidget(oldWidget);
613+
if (widget.optionsLayerLink.leader != oldWidget.optionsLayerLink.leader) {
614+
removeCompositionCallback?.call();
615+
removeCompositionCallback = widget.optionsLayerLink.leader?.addCompositionCallback(
616+
_onLeaderComposition,
617+
);
618+
}
619+
}
620+
621+
@override
622+
void dispose() {
623+
removeCompositionCallback?.call();
624+
super.dispose();
625+
}
626+
627+
@override
628+
Widget build(BuildContext context) {
629+
return CompositedTransformFollower(
630+
link: widget.optionsLayerLink,
631+
followerAnchor: switch (widget.optionsViewOpenDirection) {
632+
OptionsViewOpenDirection.up => Alignment.bottomLeft,
633+
OptionsViewOpenDirection.down => Alignment.topLeft,
634+
},
635+
// When the field goes offscreen, don't show the options.
636+
showWhenUnlinked: false,
637+
child: CustomSingleChildLayout(
638+
delegate: _RawAutocompleteOptionsLayoutDelegate(
639+
layerLink: widget.optionsLayerLink,
640+
fieldOffset: fieldOffset,
641+
optionsViewOpenDirection: widget.optionsViewOpenDirection,
642+
textDirection: Directionality.of(context),
643+
fieldConstraints: widget.fieldConstraints,
644+
),
645+
child: TextFieldTapRegion(
646+
child: AutocompleteHighlightedOption(
647+
highlightIndexNotifier: widget.highlightIndexNotifier,
648+
// optionsViewBuilder must be able to look up
649+
// AutocompleteHighlightedOption in its context.
650+
child: Builder(builder: widget.builder),
651+
),
533652
),
534653
),
535654
);
536655
}
537656
}
538657

658+
/// Positions the options view.
659+
class _RawAutocompleteOptionsLayoutDelegate extends SingleChildLayoutDelegate {
660+
_RawAutocompleteOptionsLayoutDelegate({
661+
required this.layerLink,
662+
required this.fieldOffset,
663+
required this.optionsViewOpenDirection,
664+
required this.textDirection,
665+
required this.fieldConstraints,
666+
}) : assert(layerLink.leaderSize != null);
667+
668+
/// Links the options in [RawAutocomplete.optionsViewBuilder] to the field in
669+
/// [RawAutocomplete.fieldViewBuilder].
670+
final LayerLink layerLink;
671+
672+
/// The position of the field in [RawAutocomplete.fieldViewBuilder].
673+
final Offset fieldOffset;
674+
675+
/// A direction in which to open the options view overlay.
676+
final OptionsViewOpenDirection optionsViewOpenDirection;
677+
678+
/// The [TextDirection] of this part of the widget tree.
679+
final TextDirection textDirection;
680+
681+
/// The [BoxConstraints] for the field in [RawAutocomplete.fieldViewBuilder].
682+
final BoxConstraints fieldConstraints;
683+
684+
// A big enough height for about one item in the default
685+
// Autocomplete.optionsViewBuilder. The assumption is that the user likely
686+
// wants the list of options to move to stay on the screen rather than get any
687+
// smaller than this. Allows Autocomplete to work when it has very little
688+
// screen height available (as in b/317115348) by positioning itself on top of
689+
// the field, while in other cases to size itself based on the height under
690+
// the field.
691+
static const double _kMinUsableHeight = kMinInteractiveDimension;
692+
693+
// Limits the child to the space above/below the field, with a minimum, and
694+
// with the same maxWidth constraint as the field has.
695+
@override
696+
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
697+
final Size fieldSize = layerLink.leaderSize!;
698+
return BoxConstraints(
699+
// The field width may be zero if this is a split RawAutocomplete with no
700+
// field of its own. In that case, don't change the constraints width.
701+
maxWidth: fieldSize.width == 0.0 ? constraints.maxWidth : fieldSize.width,
702+
maxHeight: max(_kMinUsableHeight, switch (optionsViewOpenDirection) {
703+
OptionsViewOpenDirection.down => constraints.maxHeight - fieldOffset.dy - fieldSize.height,
704+
OptionsViewOpenDirection.up => fieldOffset.dy,
705+
}),
706+
);
707+
}
708+
709+
// Positions the child above/below the field and aligned with the left/right
710+
// side based on text direction.
711+
@override
712+
Offset getPositionForChild(Size size, Size childSize) {
713+
final Size fieldSize = layerLink.leaderSize!;
714+
final double dx = switch (textDirection) {
715+
TextDirection.ltr => 0.0,
716+
TextDirection.rtl => fieldSize.width - childSize.width,
717+
};
718+
final double dy = switch (optionsViewOpenDirection) {
719+
OptionsViewOpenDirection.down => min(
720+
fieldSize.height,
721+
size.height - childSize.height - fieldOffset.dy,
722+
),
723+
OptionsViewOpenDirection.up => size.height - min(childSize.height, fieldOffset.dy),
724+
};
725+
return Offset(dx, dy);
726+
}
727+
728+
@override
729+
bool shouldRelayout(_RawAutocompleteOptionsLayoutDelegate oldDelegate) {
730+
if (!fieldOffset.isFinite || !layerLink.leaderSize!.isFinite) {
731+
return false;
732+
}
733+
return layerLink != oldDelegate.layerLink ||
734+
fieldOffset != oldDelegate.fieldOffset ||
735+
optionsViewOpenDirection != oldDelegate.optionsViewOpenDirection ||
736+
textDirection != oldDelegate.textDirection ||
737+
fieldConstraints != oldDelegate.fieldConstraints;
738+
}
739+
}
740+
539741
class _AutocompleteCallbackAction<T extends Intent> extends CallbackAction<T> {
540742
_AutocompleteCallbackAction({required super.onInvoke, required this.isEnabledCallback});
541743

packages/flutter/test/material/autocomplete_test.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,7 @@ void main() {
592592

593593
await tester.tap(find.byType(RawAutocomplete<String>));
594594
await tester.enterText(find.byType(RawAutocomplete<String>), 'a');
595+
await tester.pump();
595596
expect(find.text('aa').hitTestable(), findsOneWidget);
596597
});
597598
});

0 commit comments

Comments
 (0)