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 |  |  | 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:
committed by
GitHub
parent
fe598e7d6f
commit
1b0441c18a
@@ -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,
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user