Autocomplete Options Width (#143249)

| Problem | Before | After |
| --- | --- | --- |
| Width | <img width="797" alt="Screenshot 2024-02-09 at 1 29 09 PM"
src="https://github.com/flutter/flutter/assets/389558/c49fa584-2550-41f6-ab80-6c20d01412b1">
| <img width="794" alt="Screenshot 2024-02-09 at 1 23 59 PM"
src="https://github.com/flutter/flutter/assets/389558/1326f797-9883-4916-9de3-1939e7648d46">
|
| Overflow | ![Screenshot from 2024-06-07
13-39-45](https://github.com/flutter/flutter/assets/389558/8a24c87a-2b5e-4bdc-8347-339d850f5a82)
| ![Screenshot from 2024-06-07
13-38-26](https://github.com/flutter/flutter/assets/389558/735248aa-8969-413b-a6cf-4f9b708f9ea8)
|

Fixes https://github.com/flutter/flutter/issues/78746
Fixes https://github.com/flutter/flutter/issues/92851
Part of https://github.com/flutter/flutter/issues/101620
Fixes https://github.com/flutter/flutter/issues/147483
Fixes https://github.com/flutter/flutter/issues/153274
Part of Google b/317115348
Fixes optionsViewOpenDirection not working, mentioned in
https://github.com/flutter/flutter/pull/143249#issuecomment-2036191457.

### Requirements

* [x] By default, the width of the options matches the width of the
field.
 * [x] Options can be aligned to the start or end for LTR/RTL languages.
 * [x] The optionsViewOpenDirection parameter is respected.
* [x] If the options would vertically exceed the top or bottom of the
screen, they reposition themselves to fit on the screen while covering
the field. At least enough to tap an option. This has accessibility
implications, because sometimes an Autocomplete near the edge of a
narrow screen can be unusable.
* [x] If the Autocomplete is in a ScrollView, then the options move
along with the field during scrolling.
* [x] When the field moves or resizes, the options position and size
change to match. Even if the field is animated.
* [ ] The options layout updates on the same frame that the field layout
changes.

It's probably not possible to check all of these boxes so we'll probably
need to compromise.

 #### Tools that I've used to try to achieve this

 * LayoutBuilder to provide the field constraints[^1].
 * Looking up layout information of a widget via GlobalKey.
 * CompositedTransformFollower/Target.
 * CustomSingleChildLayout.

[^1]: Originally this didn't work due to a bug when using LayoutBuilder
with OverlayPortal (https://github.com/flutter/flutter/pull/147856).
That has now been fixed.

Co-authored-by: Victor Sanni <victorsanniay@gmail.com>
This commit is contained in:
Justin McCandless
2025-01-13 17:29:08 -08:00
committed by GitHub
parent fe598e7d6f
commit 1b0441c18a
4 changed files with 1523 additions and 98 deletions

View File

@@ -134,7 +134,7 @@ class Autocomplete<T extends Object> extends StatelessWidget {
onSelected: onSelected,
options: options,
openDirection: optionsViewOpenDirection,
maxOptionsHeight: optionsMaxHeight,
optionsMaxHeight: optionsMaxHeight,
);
},
onSelected: onSelected,
@@ -176,7 +176,7 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget {
required this.onSelected,
required this.openDirection,
required this.options,
required this.maxOptionsHeight,
required this.optionsMaxHeight,
});
final AutocompleteOptionToString<T> displayStringForOption;
@@ -185,7 +185,7 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget {
final OptionsViewOpenDirection openDirection;
final Iterable<T> options;
final double maxOptionsHeight;
final double optionsMaxHeight;
@override
Widget build(BuildContext context) {
@@ -198,7 +198,7 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget {
child: Material(
elevation: 4.0,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxOptionsHeight),
constraints: BoxConstraints(maxHeight: optionsMaxHeight),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,

View File

@@ -6,18 +6,24 @@
library;
import 'dart:async';
import 'dart:math' show max, min;
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'actions.dart';
import 'basic.dart';
import 'constants.dart';
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;
@@ -213,10 +219,10 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
/// {@template flutter.widgets.RawAutocomplete.optionsViewBuilder}
/// Builds the selectable options widgets from a list of options objects.
///
/// The options are displayed floating below or above the field using a
/// [CompositedTransformFollower] inside of an [Overlay], not at the same
/// place in the widget tree as [RawAutocomplete]. To control whether it opens
/// upward or downward, use [optionsViewOpenDirection].
/// The options are displayed floating below or above the field inside of an
/// [Overlay], not at the same place in the widget tree as [RawAutocomplete].
/// To control whether it opens upward or downward, use
/// [optionsViewOpenDirection].
///
/// In order to track which item is highlighted by keyboard navigation, the
/// resulting options will be wrapped in an inherited
@@ -307,6 +313,10 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> {
final GlobalKey _fieldKey = GlobalKey();
final LayerLink _optionsLayerLink = LayerLink();
/// The box constraints that the field was last built with.
final ValueNotifier<BoxConstraints?> _fieldBoxConstraints = ValueNotifier<BoxConstraints?>(null);
final OverlayPortalController _optionsViewController = OverlayPortalController(
debugLabel: '_RawAutocompleteState',
);
@@ -439,30 +449,22 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
}
Widget _buildOptionsView(BuildContext context) {
final TextDirection textDirection = Directionality.of(context);
final Alignment followerAlignment = switch (widget.optionsViewOpenDirection) {
OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart,
OptionsViewOpenDirection.down => AlignmentDirectional.topStart,
}.resolve(textDirection);
final Alignment targetAnchor = switch (widget.optionsViewOpenDirection) {
OptionsViewOpenDirection.up => AlignmentDirectional.topStart,
OptionsViewOpenDirection.down => AlignmentDirectional.bottomStart,
}.resolve(textDirection);
return CompositedTransformFollower(
link: _optionsLayerLink,
showWhenUnlinked: false,
targetAnchor: targetAnchor,
followerAnchor: followerAlignment,
child: TextFieldTapRegion(
child: AutocompleteHighlightedOption(
return ValueListenableBuilder<BoxConstraints?>(
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,
child: Builder(
builder:
(BuildContext context) => widget.optionsViewBuilder(context, _select, _options),
),
),
),
fieldConstraints: _fieldBoxConstraints.value!,
builder: (BuildContext context) {
return widget.optionsViewBuilder(context, _select, _options);
},
);
},
);
}
@@ -504,6 +506,7 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
widget.focusNode?.removeListener(_updateOptionsViewVisibility);
_internalFocusNode?.dispose();
_highlightedOptionIndex.dispose();
_fieldBoxConstraints.dispose();
super.dispose();
}
@@ -517,25 +520,224 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
_onFieldSubmitted,
) ??
const SizedBox.shrink();
return OverlayPortal.targetsRootOverlay(
controller: _optionsViewController,
overlayChildBuilder: _buildOptionsView,
child: TextFieldTapRegion(
child: SizedBox(
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,
child: Shortcuts(
shortcuts: _shortcuts,
child: Actions(
actions: _actionMap,
child: CompositedTransformTarget(link: _optionsLayerLink, child: fieldView),
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<int> 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),
),
),
),
);
}
}
/// 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<T extends Intent> extends CallbackAction<T> {
_AutocompleteCallbackAction({required super.onInvoke, required this.isEnabledCallback});

View File

@@ -592,6 +592,7 @@ void main() {
await tester.tap(find.byType(RawAutocomplete<String>));
await tester.enterText(find.byType(RawAutocomplete<String>), 'a');
await tester.pump();
expect(find.text('aa').hitTestable(), findsOneWidget);
});
});

File diff suppressed because it is too large Load Diff