Unverified Commit b6e758ad authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Fixes #138773, port autocomplete to OverlayPortal (#140285)

Fixes #138773, port autocomplete to OverlayPortal
parent b5262f0d
...@@ -126,6 +126,7 @@ class Autocomplete<T extends Object> extends StatelessWidget { ...@@ -126,6 +126,7 @@ class Autocomplete<T extends Object> extends StatelessWidget {
displayStringForOption: displayStringForOption, displayStringForOption: displayStringForOption,
onSelected: onSelected, onSelected: onSelected,
options: options, options: options,
openDirection: optionsViewOpenDirection,
maxOptionsHeight: optionsMaxHeight, maxOptionsHeight: optionsMaxHeight,
); );
}, },
...@@ -166,6 +167,7 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget { ...@@ -166,6 +167,7 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget {
super.key, super.key,
required this.displayStringForOption, required this.displayStringForOption,
required this.onSelected, required this.onSelected,
required this.openDirection,
required this.options, required this.options,
required this.maxOptionsHeight, required this.maxOptionsHeight,
}); });
...@@ -173,14 +175,19 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget { ...@@ -173,14 +175,19 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget {
final AutocompleteOptionToString<T> displayStringForOption; final AutocompleteOptionToString<T> displayStringForOption;
final AutocompleteOnSelected<T> onSelected; final AutocompleteOnSelected<T> onSelected;
final OptionsViewOpenDirection openDirection;
final Iterable<T> options; final Iterable<T> options;
final double maxOptionsHeight; final double maxOptionsHeight;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AlignmentDirectional optionsAlignment = switch (openDirection) {
OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart,
OptionsViewOpenDirection.down => AlignmentDirectional.topStart,
};
return Align( return Align(
alignment: Alignment.topLeft, alignment: optionsAlignment,
child: Material( child: Material(
elevation: 4.0, elevation: 4.0,
child: ConstrainedBox( child: ConstrainedBox(
......
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'actions.dart'; import 'actions.dart';
...@@ -303,16 +302,37 @@ class RawAutocomplete<T extends Object> extends StatefulWidget { ...@@ -303,16 +302,37 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> { class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> {
final GlobalKey _fieldKey = GlobalKey(); final GlobalKey _fieldKey = GlobalKey();
final LayerLink _optionsLayerLink = LayerLink(); final LayerLink _optionsLayerLink = LayerLink();
late TextEditingController _textEditingController; final OverlayPortalController _optionsViewController = OverlayPortalController(debugLabel: '_RawAutocompleteState');
late FocusNode _focusNode;
late final Map<Type, Action<Intent>> _actionMap; TextEditingController? _internalTextEditingController;
late final _AutocompleteCallbackAction<AutocompletePreviousOptionIntent> _previousOptionAction; TextEditingController get _textEditingController {
late final _AutocompleteCallbackAction<AutocompleteNextOptionIntent> _nextOptionAction; return widget.textEditingController
late final _AutocompleteCallbackAction<DismissIntent> _hideOptionsAction; ?? (_internalTextEditingController ??= TextEditingController()..addListener(_onChangedField));
}
FocusNode? _internalFocusNode;
FocusNode get _focusNode {
return widget.focusNode
?? (_internalFocusNode ??= FocusNode()..addListener(_updateOptionsViewVisibility));
}
late final Map<Type, CallbackAction<Intent>> _actionMap = <Type, CallbackAction<Intent>>{
AutocompletePreviousOptionIntent: _AutocompleteCallbackAction<AutocompletePreviousOptionIntent>(
onInvoke: _highlightPreviousOption,
isEnabledCallback: () => _canShowOptionsView,
),
AutocompleteNextOptionIntent: _AutocompleteCallbackAction<AutocompleteNextOptionIntent>(
onInvoke: _highlightNextOption,
isEnabledCallback: () => _canShowOptionsView,
),
DismissIntent: CallbackAction<DismissIntent>(onInvoke: _hideOptions),
};
Iterable<T> _options = Iterable<T>.empty(); Iterable<T> _options = Iterable<T>.empty();
T? _selection; T? _selection;
bool _userHidOptions = false; // Set the initial value to null so when this widget gets focused for the first
String _lastFieldText = ''; // time it will try to run the options view builder.
String? _lastFieldText;
final ValueNotifier<int> _highlightedOptionIndex = ValueNotifier<int>(0); final ValueNotifier<int> _highlightedOptionIndex = ValueNotifier<int>(0);
static const Map<ShortcutActivator, Intent> _shortcuts = <ShortcutActivator, Intent>{ static const Map<ShortcutActivator, Intent> _shortcuts = <ShortcutActivator, Intent>{
...@@ -320,52 +340,41 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> ...@@ -320,52 +340,41 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
SingleActivator(LogicalKeyboardKey.arrowDown): AutocompleteNextOptionIntent(), SingleActivator(LogicalKeyboardKey.arrowDown): AutocompleteNextOptionIntent(),
}; };
// The OverlayEntry containing the options. bool get _canShowOptionsView => _focusNode.hasFocus && _selection == null && _options.isNotEmpty;
OverlayEntry? _floatingOptions;
// True iff the state indicates that the options should be visible. void _updateOptionsViewVisibility() {
bool get _shouldShowOptions { if (_canShowOptionsView) {
return !_userHidOptions && _focusNode.hasFocus && _selection == null && _options.isNotEmpty; _optionsViewController.show();
} else {
_optionsViewController.hide();
}
} }
// Called when _textEditingController changes. // Called when _textEditingController changes.
Future<void> _onChangedField() async { Future<void> _onChangedField() async {
final TextEditingValue value = _textEditingController.value; final TextEditingValue value = _textEditingController.value;
final Iterable<T> options = await widget.optionsBuilder( final Iterable<T> options = await widget.optionsBuilder(value);
value,
);
_options = options; _options = options;
_updateHighlight(_highlightedOptionIndex.value); _updateHighlight(_highlightedOptionIndex.value);
if (_selection != null final T? selection = _selection;
&& value.text != widget.displayStringForOption(_selection!)) { if (selection != null && value.text != widget.displayStringForOption(selection)) {
_selection = null; _selection = null;
} }
// Make sure the options are no longer hidden if the content of the field // Make sure the options are no longer hidden if the content of the field
// changes (ignore selection changes). // changes (ignore selection changes).
if (value.text != _lastFieldText) { if (value.text != _lastFieldText) {
_userHidOptions = false;
_lastFieldText = value.text; _lastFieldText = value.text;
_updateOptionsViewVisibility();
} }
_updateActions();
_updateOverlay();
}
// Called when the field's FocusNode changes.
void _onChangedFocus() {
// Options should no longer be hidden when the field is re-focused.
_userHidOptions = !_focusNode.hasFocus;
_updateActions();
_updateOverlay();
} }
// Called from fieldViewBuilder when the user submits the field. // Called from fieldViewBuilder when the user submits the field.
void _onFieldSubmitted() { void _onFieldSubmitted() {
if (_options.isEmpty || _userHidOptions) { if (_optionsViewController.isShowing) {
return;
}
_select(_options.elementAt(_highlightedOptionIndex.value)); _select(_options.elementAt(_highlightedOptionIndex.value));
} }
}
// Select the given option and update the widget. // Select the given option and update the widget.
void _select(T nextSelection) { void _select(T nextSelection) {
...@@ -378,9 +387,8 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> ...@@ -378,9 +387,8 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
selection: TextSelection.collapsed(offset: selectionString.length), selection: TextSelection.collapsed(offset: selectionString.length),
text: selectionString, text: selectionString,
); );
_updateActions(); widget.onSelected?.call(nextSelection);
_updateOverlay(); _updateOptionsViewVisibility();
widget.onSelected?.call(_selection!);
} }
void _updateHighlight(int newIndex) { void _updateHighlight(int newIndex) {
...@@ -388,190 +396,103 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> ...@@ -388,190 +396,103 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
} }
void _highlightPreviousOption(AutocompletePreviousOptionIntent intent) { void _highlightPreviousOption(AutocompletePreviousOptionIntent intent) {
if (_userHidOptions) { assert(_canShowOptionsView);
_userHidOptions = false; _updateOptionsViewVisibility();
_updateActions(); assert(_optionsViewController.isShowing);
_updateOverlay();
return;
}
_updateHighlight(_highlightedOptionIndex.value - 1); _updateHighlight(_highlightedOptionIndex.value - 1);
} }
void _highlightNextOption(AutocompleteNextOptionIntent intent) { void _highlightNextOption(AutocompleteNextOptionIntent intent) {
if (_userHidOptions) { assert(_canShowOptionsView);
_userHidOptions = false; _updateOptionsViewVisibility();
_updateActions(); assert(_optionsViewController.isShowing);
_updateOverlay();
return;
}
_updateHighlight(_highlightedOptionIndex.value + 1); _updateHighlight(_highlightedOptionIndex.value + 1);
} }
Object? _hideOptions(DismissIntent intent) { Object? _hideOptions(DismissIntent intent) {
if (!_userHidOptions) { if (_optionsViewController.isShowing) {
_userHidOptions = true; _optionsViewController.hide();
_updateActions();
_updateOverlay();
return null; return null;
} } else {
return Actions.invoke(context, intent); return Actions.invoke(context, intent);
} }
void _setActionsEnabled(bool enabled) {
// The enabled state determines whether the action will consume the
// key shortcut or let it continue on to the underlying text field.
// They should only be enabled when the options are showing so shortcuts
// can be used to navigate them.
_previousOptionAction.enabled = enabled;
_nextOptionAction.enabled = enabled;
_hideOptionsAction.enabled = enabled;
} }
void _updateActions() { Widget _buildOptionsView(BuildContext context) {
_setActionsEnabled(_focusNode.hasFocus && _selection == null && _options.isNotEmpty); 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);
bool _floatingOptionsUpdateScheduled = false;
// Hide or show the options overlay, if needed.
void _updateOverlay() {
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) {
if (!_floatingOptionsUpdateScheduled) {
_floatingOptionsUpdateScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
_floatingOptionsUpdateScheduled = false;
_updateOverlay();
}, debugLabel: 'RawAutoComplete.updateOverlay');
}
return;
}
_floatingOptions?.remove();
_floatingOptions?.dispose();
if (_shouldShowOptions) {
final OverlayEntry newFloatingOptions = OverlayEntry(
builder: (BuildContext context) {
return CompositedTransformFollower( return CompositedTransformFollower(
link: _optionsLayerLink, link: _optionsLayerLink,
showWhenUnlinked: false, showWhenUnlinked: false,
targetAnchor: switch (widget.optionsViewOpenDirection) { targetAnchor: targetAnchor,
OptionsViewOpenDirection.up => Alignment.topLeft, followerAnchor: followerAlignment,
OptionsViewOpenDirection.down => Alignment.bottomLeft,
},
followerAnchor: switch (widget.optionsViewOpenDirection) {
OptionsViewOpenDirection.up => Alignment.bottomLeft,
OptionsViewOpenDirection.down => Alignment.topLeft,
},
child: TextFieldTapRegion( child: TextFieldTapRegion(
child: AutocompleteHighlightedOption( child: AutocompleteHighlightedOption(
highlightIndexNotifier: _highlightedOptionIndex, highlightIndexNotifier: _highlightedOptionIndex,
child: Builder( child: Builder(
builder: (BuildContext context) { builder: (BuildContext context) => widget.optionsViewBuilder(context, _select, _options),
return widget.optionsViewBuilder(context, _select, _options); ),
}
)
), ),
), ),
); );
},
);
Overlay.of(context, rootOverlay: true, debugRequiredFor: widget).insert(newFloatingOptions);
_floatingOptions = newFloatingOptions;
} else {
_floatingOptions = null;
}
}
// Handle a potential change in textEditingController by properly disposing of
// the old one and setting up the new one, if needed.
void _updateTextEditingController(TextEditingController? old, TextEditingController? current) {
if ((old == null && current == null) || old == current) {
return;
}
if (old == null) {
_textEditingController.removeListener(_onChangedField);
_textEditingController.dispose();
_textEditingController = current!;
} else if (current == null) {
_textEditingController.removeListener(_onChangedField);
_textEditingController = TextEditingController();
} else {
_textEditingController.removeListener(_onChangedField);
_textEditingController = current;
}
_textEditingController.addListener(_onChangedField);
}
// Handle a potential change in focusNode by properly disposing of the old one
// and setting up the new one, if needed.
void _updateFocusNode(FocusNode? old, FocusNode? current) {
if ((old == null && current == null) || old == current) {
return;
}
if (old == null) {
_focusNode.removeListener(_onChangedFocus);
_focusNode.dispose();
_focusNode = current!;
} else if (current == null) {
_focusNode.removeListener(_onChangedFocus);
_focusNode = FocusNode();
} else {
_focusNode.removeListener(_onChangedFocus);
_focusNode = current;
}
_focusNode.addListener(_onChangedFocus);
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textEditingController = widget.textEditingController ?? TextEditingController.fromValue(widget.initialValue); final TextEditingController initialController = widget.textEditingController
_textEditingController.addListener(_onChangedField); ?? (_internalTextEditingController = TextEditingController.fromValue(widget.initialValue));
_focusNode = widget.focusNode ?? FocusNode(); initialController.addListener(_onChangedField);
_focusNode.addListener(_onChangedFocus); widget.focusNode?.addListener(_updateOptionsViewVisibility);
_previousOptionAction = _AutocompleteCallbackAction<AutocompletePreviousOptionIntent>(onInvoke: _highlightPreviousOption);
_nextOptionAction = _AutocompleteCallbackAction<AutocompleteNextOptionIntent>(onInvoke: _highlightNextOption);
_hideOptionsAction = _AutocompleteCallbackAction<DismissIntent>(onInvoke: _hideOptions);
_actionMap = <Type, Action<Intent>> {
AutocompletePreviousOptionIntent: _previousOptionAction,
AutocompleteNextOptionIntent: _nextOptionAction,
DismissIntent: _hideOptionsAction,
};
_updateActions();
_updateOverlay();
} }
@override @override
void didUpdateWidget(RawAutocomplete<T> oldWidget) { void didUpdateWidget(RawAutocomplete<T> oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
_updateTextEditingController( if (!identical(oldWidget.textEditingController, widget.textEditingController)) {
oldWidget.textEditingController, oldWidget.textEditingController?.removeListener(_onChangedField);
widget.textEditingController, if (oldWidget.textEditingController == null) {
); _internalTextEditingController?.dispose();
_updateFocusNode(oldWidget.focusNode, widget.focusNode); _internalTextEditingController = null;
_updateActions(); }
_updateOverlay(); widget.textEditingController?.addListener(_onChangedField);
}
if (!identical(oldWidget.focusNode, widget.focusNode)) {
oldWidget.focusNode?.removeListener(_updateOptionsViewVisibility);
if (oldWidget.focusNode == null) {
_internalFocusNode?.dispose();
_internalFocusNode = null;
}
widget.focusNode?.addListener(_updateOptionsViewVisibility);
}
} }
@override @override
void dispose() { void dispose() {
_textEditingController.removeListener(_onChangedField); widget.textEditingController?.removeListener(_onChangedField);
if (widget.textEditingController == null) { _internalTextEditingController?.dispose();
_textEditingController.dispose(); widget.focusNode?.removeListener(_updateOptionsViewVisibility);
} _internalFocusNode?.dispose();
_focusNode.removeListener(_onChangedFocus);
if (widget.focusNode == null) {
_focusNode.dispose();
}
_floatingOptions?.remove();
_floatingOptions?.dispose();
_floatingOptions = null;
_highlightedOptionIndex.dispose(); _highlightedOptionIndex.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextFieldTapRegion( final Widget fieldView = widget.fieldViewBuilder?.call(context, _textEditingController, _focusNode, _onFieldSubmitted)
?? const SizedBox.shrink();
return OverlayPortal.targetsRootOverlay(
controller: _optionsViewController,
overlayChildBuilder: _buildOptionsView,
child: TextFieldTapRegion(
child: Container( child: Container(
key: _fieldKey, key: _fieldKey,
child: Shortcuts( child: Shortcuts(
...@@ -580,13 +501,7 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> ...@@ -580,13 +501,7 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
actions: _actionMap, actions: _actionMap,
child: CompositedTransformTarget( child: CompositedTransformTarget(
link: _optionsLayerLink, link: _optionsLayerLink,
child: widget.fieldViewBuilder == null child: fieldView,
? const SizedBox.shrink()
: widget.fieldViewBuilder!(
context,
_textEditingController,
_focusNode,
_onFieldSubmitted,
), ),
), ),
), ),
...@@ -599,16 +514,20 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> ...@@ -599,16 +514,20 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
class _AutocompleteCallbackAction<T extends Intent> extends CallbackAction<T> { class _AutocompleteCallbackAction<T extends Intent> extends CallbackAction<T> {
_AutocompleteCallbackAction({ _AutocompleteCallbackAction({
required super.onInvoke, required super.onInvoke,
this.enabled = true, required this.isEnabledCallback,
}); });
bool enabled; // The enabled state determines whether the action will consume the
// key shortcut or let it continue on to the underlying text field.
// They should only be enabled when the options are showing so shortcuts
// can be used to navigate them.
final bool Function() isEnabledCallback;
@override @override
bool isEnabled(covariant T intent) => enabled; bool isEnabled(covariant T intent) => isEnabledCallback();
@override @override
bool consumesKey(covariant T intent) => enabled; bool consumesKey(covariant T intent) => isEnabled(intent);
} }
/// An [Intent] to highlight the previous option in the autocomplete list. /// An [Intent] to highlight the previous option in the autocomplete list.
......
...@@ -552,9 +552,11 @@ void main() { ...@@ -552,9 +552,11 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
body: Autocomplete<String>( body: Center(
child: Autocomplete<String>(
optionsViewOpenDirection: OptionsViewOpenDirection.up, optionsViewOpenDirection: OptionsViewOpenDirection.up,
optionsBuilder: (TextEditingValue textEditingValue) => <String>['a'], optionsBuilder: (TextEditingValue textEditingValue) => <String>['aa'],
),
), ),
), ),
), ),
...@@ -562,6 +564,10 @@ void main() { ...@@ -562,6 +564,10 @@ void main() {
final OptionsViewOpenDirection actual = tester.widget<RawAutocomplete<String>>(find.byType(RawAutocomplete<String>)) final OptionsViewOpenDirection actual = tester.widget<RawAutocomplete<String>>(find.byType(RawAutocomplete<String>))
.optionsViewOpenDirection; .optionsViewOpenDirection;
expect(actual, equals(OptionsViewOpenDirection.up)); expect(actual, equals(OptionsViewOpenDirection.up));
await tester.tap(find.byType(RawAutocomplete<String>));
await tester.enterText(find.byType(RawAutocomplete<String>), 'a');
expect(find.text('aa').hitTestable(), findsOneWidget);
}); });
}); });
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment