jld3103
2 years ago
3 changed files with 32 additions and 507 deletions
@ -1,493 +0,0 @@
|
||||
// Copyright 2014 The Flutter Authors. All rights reserved. |
||||
// Use of this source code is governed by a BSD-style license that can be |
||||
// found in the LICENSE file. |
||||
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/scheduler.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
|
||||
class CustomAutocomplete<T extends Object> extends StatelessWidget { |
||||
const CustomAutocomplete({ |
||||
required this.optionsBuilder, |
||||
this.displayWidgetForOption = CustomRawAutocomplete.defaultWidgetForOption, |
||||
this.fieldViewBuilder = _defaultFieldViewBuilder, |
||||
this.onSelected, |
||||
this.optionsMaxHeight = 200.0, |
||||
this.optionsViewBuilder, |
||||
this.initialValue, |
||||
super.key, |
||||
}); |
||||
|
||||
final AutocompleteOptionToWidget<T> displayWidgetForOption; |
||||
|
||||
final AutocompleteFieldViewBuilder fieldViewBuilder; |
||||
|
||||
final AutocompleteOnSelected<T>? onSelected; |
||||
|
||||
final AutocompleteOptionsBuilder<T> optionsBuilder; |
||||
|
||||
final AutocompleteOptionsViewBuilder<T>? optionsViewBuilder; |
||||
|
||||
final double optionsMaxHeight; |
||||
|
||||
final TextEditingValue? initialValue; |
||||
|
||||
static Widget _defaultFieldViewBuilder( |
||||
final BuildContext context, |
||||
final TextEditingController textEditingController, |
||||
final FocusNode focusNode, |
||||
final VoidCallback onFieldSubmitted, |
||||
) => |
||||
_CustomAutocompleteField( |
||||
focusNode: focusNode, |
||||
textEditingController: textEditingController, |
||||
onFieldSubmitted: onFieldSubmitted, |
||||
); |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => CustomRawAutocomplete<T>( |
||||
displayWidgetForOption: displayWidgetForOption, |
||||
fieldViewBuilder: fieldViewBuilder, |
||||
initialValue: initialValue, |
||||
optionsBuilder: optionsBuilder, |
||||
optionsViewBuilder: optionsViewBuilder ?? |
||||
( |
||||
final context, |
||||
final onSelected, |
||||
final options, |
||||
) => |
||||
_CustomAutocompleteOptions<T>( |
||||
displayWidgetForOption: displayWidgetForOption, |
||||
onSelected: onSelected, |
||||
options: options, |
||||
maxOptionsHeight: optionsMaxHeight, |
||||
), |
||||
onSelected: onSelected, |
||||
); |
||||
} |
||||
|
||||
// The default Material-style Autocomplete text field. |
||||
class _CustomAutocompleteField extends StatelessWidget { |
||||
const _CustomAutocompleteField({ |
||||
required this.focusNode, |
||||
required this.textEditingController, |
||||
required this.onFieldSubmitted, |
||||
}); |
||||
|
||||
final FocusNode focusNode; |
||||
|
||||
final VoidCallback onFieldSubmitted; |
||||
|
||||
final TextEditingController textEditingController; |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => TextFormField( |
||||
controller: textEditingController, |
||||
focusNode: focusNode, |
||||
onFieldSubmitted: (final value) { |
||||
onFieldSubmitted(); |
||||
}, |
||||
); |
||||
} |
||||
|
||||
// The default Material-style Autocomplete options. |
||||
class _CustomAutocompleteOptions<T extends Object> extends StatelessWidget { |
||||
const _CustomAutocompleteOptions({ |
||||
required this.displayWidgetForOption, |
||||
required this.onSelected, |
||||
required this.options, |
||||
required this.maxOptionsHeight, |
||||
super.key, |
||||
}); |
||||
|
||||
final AutocompleteOptionToWidget<T> displayWidgetForOption; |
||||
|
||||
final AutocompleteOnSelected<T> onSelected; |
||||
|
||||
final Iterable<T> options; |
||||
final double maxOptionsHeight; |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => Align( |
||||
alignment: Alignment.topLeft, |
||||
child: Material( |
||||
elevation: 4, |
||||
child: ConstrainedBox( |
||||
constraints: BoxConstraints(maxHeight: maxOptionsHeight), |
||||
child: ListView.builder( |
||||
padding: EdgeInsets.zero, |
||||
shrinkWrap: true, |
||||
itemCount: options.length, |
||||
itemBuilder: (final context, final index) { |
||||
final option = options.elementAt(index); |
||||
return InkWell( |
||||
onTap: () { |
||||
onSelected(option); |
||||
}, |
||||
child: Builder( |
||||
builder: (final context) { |
||||
final highlight = AutocompleteHighlightedOption.of(context) == index; |
||||
if (highlight) { |
||||
SchedulerBinding.instance.addPostFrameCallback((final timeStamp) async { |
||||
await Scrollable.ensureVisible(context, alignment: 0.5); |
||||
}); |
||||
} |
||||
return Container( |
||||
color: highlight ? Theme.of(context).focusColor : null, |
||||
padding: const EdgeInsets.all(16), |
||||
child: displayWidgetForOption(option), |
||||
); |
||||
}, |
||||
), |
||||
); |
||||
}, |
||||
), |
||||
), |
||||
), |
||||
); |
||||
} |
||||
|
||||
typedef AutocompleteOptionToWidget<T extends Object> = Widget Function(T option); |
||||
|
||||
class CustomRawAutocomplete<T extends Object> extends StatefulWidget { |
||||
const CustomRawAutocomplete({ |
||||
required this.optionsViewBuilder, |
||||
required this.optionsBuilder, |
||||
this.displayWidgetForOption = defaultWidgetForOption, |
||||
this.fieldViewBuilder, |
||||
this.focusNode, |
||||
this.onSelected, |
||||
this.textEditingController, |
||||
this.initialValue, |
||||
super.key, |
||||
}) : assert( |
||||
fieldViewBuilder != null || (key != null && focusNode != null && textEditingController != null), |
||||
'Pass in a fieldViewBuilder, or otherwise create a separate field and pass in the FocusNode, TextEditingController, and a key. Use the key with RawAutocomplete.onFieldSubmitted.', |
||||
), |
||||
// ignore: prefer_asserts_with_message |
||||
assert((focusNode == null) == (textEditingController == null)), |
||||
assert( |
||||
!(textEditingController != null && initialValue != null), |
||||
'textEditingController and initialValue cannot be simultaneously defined.', |
||||
); |
||||
|
||||
final AutocompleteFieldViewBuilder? fieldViewBuilder; |
||||
|
||||
final FocusNode? focusNode; |
||||
|
||||
final AutocompleteOptionsViewBuilder<T> optionsViewBuilder; |
||||
|
||||
final AutocompleteOptionToWidget<T> displayWidgetForOption; |
||||
|
||||
final AutocompleteOnSelected<T>? onSelected; |
||||
|
||||
final AutocompleteOptionsBuilder<T> optionsBuilder; |
||||
|
||||
final TextEditingController? textEditingController; |
||||
|
||||
final TextEditingValue? initialValue; |
||||
|
||||
static void onFieldSubmitted<T extends Object>(final GlobalKey key) { |
||||
(key.currentState! as _CustomRawAutocompleteState<T>)._onFieldSubmitted(); |
||||
} |
||||
|
||||
static Widget defaultWidgetForOption(final dynamic option) => Text(option.toString()); |
||||
|
||||
@override |
||||
State<CustomRawAutocomplete<T>> createState() => _CustomRawAutocompleteState<T>(); |
||||
} |
||||
|
||||
class _CustomRawAutocompleteState<T extends Object> extends State<CustomRawAutocomplete<T>> { |
||||
final GlobalKey _fieldKey = GlobalKey(); |
||||
final LayerLink _optionsLayerLink = LayerLink(); |
||||
late TextEditingController _textEditingController; |
||||
late FocusNode _focusNode; |
||||
late final Map<Type, Action<Intent>> _actionMap; |
||||
late final _CustomAutocompleteCallbackAction<AutocompletePreviousOptionIntent> _previousOptionAction; |
||||
late final _CustomAutocompleteCallbackAction<AutocompleteNextOptionIntent> _nextOptionAction; |
||||
late final _CustomAutocompleteCallbackAction<DismissIntent> _hideOptionsAction; |
||||
Iterable<T> _options = Iterable<T>.empty(); |
||||
T? _selection; |
||||
bool _userHidOptions = false; |
||||
String _lastFieldText = ''; |
||||
final ValueNotifier<int> _highlightedOptionIndex = ValueNotifier<int>(0); |
||||
|
||||
static const Map<ShortcutActivator, Intent> _shortcuts = <ShortcutActivator, Intent>{ |
||||
SingleActivator(LogicalKeyboardKey.arrowUp): AutocompletePreviousOptionIntent(), |
||||
SingleActivator(LogicalKeyboardKey.arrowDown): AutocompleteNextOptionIntent(), |
||||
}; |
||||
|
||||
// The OverlayEntry containing the options. |
||||
OverlayEntry? _floatingOptions; |
||||
|
||||
// True iff the state indicates that the options should be visible. |
||||
bool get _shouldShowOptions => !_userHidOptions && _focusNode.hasFocus && _selection == null && _options.isNotEmpty; |
||||
|
||||
// Called when _textEditingController changes. |
||||
Future<void> _onChangedField() async { |
||||
final value = _textEditingController.value; |
||||
final options = await widget.optionsBuilder( |
||||
value, |
||||
); |
||||
_options = options; |
||||
_updateHighlight(_highlightedOptionIndex.value); |
||||
if (_selection != null && value.text != _selection!.toString()) { |
||||
_selection = null; |
||||
} |
||||
|
||||
// Make sure the options are no longer hidden if the content of the field |
||||
// changes (ignore selection changes). |
||||
if (value.text != _lastFieldText) { |
||||
_userHidOptions = false; |
||||
_lastFieldText = value.text; |
||||
} |
||||
_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. |
||||
void _onFieldSubmitted() { |
||||
if (_options.isEmpty || _userHidOptions) { |
||||
return; |
||||
} |
||||
_select(_options.elementAt(_highlightedOptionIndex.value)); |
||||
} |
||||
|
||||
// Select the given option and update the widget. |
||||
void _select(final T nextSelection) { |
||||
if (nextSelection == _selection) { |
||||
return; |
||||
} |
||||
_selection = nextSelection; |
||||
final selectionString = nextSelection.toString(); |
||||
_textEditingController.value = TextEditingValue( |
||||
selection: TextSelection.collapsed(offset: selectionString.length), |
||||
text: selectionString, |
||||
); |
||||
_updateActions(); |
||||
_updateOverlay(); |
||||
widget.onSelected?.call(_selection!); |
||||
} |
||||
|
||||
void _updateHighlight(final int newIndex) { |
||||
_highlightedOptionIndex.value = _options.isEmpty ? 0 : newIndex % _options.length; |
||||
} |
||||
|
||||
void _highlightPreviousOption(final AutocompletePreviousOptionIntent intent) { |
||||
if (_userHidOptions) { |
||||
_userHidOptions = false; |
||||
_updateActions(); |
||||
_updateOverlay(); |
||||
return; |
||||
} |
||||
_updateHighlight(_highlightedOptionIndex.value - 1); |
||||
} |
||||
|
||||
void _highlightNextOption(final AutocompleteNextOptionIntent intent) { |
||||
if (_userHidOptions) { |
||||
_userHidOptions = false; |
||||
_updateActions(); |
||||
_updateOverlay(); |
||||
return; |
||||
} |
||||
_updateHighlight(_highlightedOptionIndex.value + 1); |
||||
} |
||||
|
||||
Object? _hideOptions(final DismissIntent intent) { |
||||
if (!_userHidOptions) { |
||||
_userHidOptions = true; |
||||
_updateActions(); |
||||
_updateOverlay(); |
||||
return null; |
||||
} |
||||
return Actions.invoke(context, intent); |
||||
} |
||||
|
||||
void _setActionsEnabled(final 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() { |
||||
_setActionsEnabled(_focusNode.hasFocus && _selection == null && _options.isNotEmpty); |
||||
} |
||||
|
||||
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((final timeStamp) { |
||||
_floatingOptionsUpdateScheduled = false; |
||||
_updateOverlay(); |
||||
}); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
_floatingOptions?.remove(); |
||||
if (_shouldShowOptions) { |
||||
final newFloatingOptions = OverlayEntry( |
||||
builder: (final context) => CompositedTransformFollower( |
||||
link: _optionsLayerLink, |
||||
showWhenUnlinked: false, |
||||
targetAnchor: Alignment.bottomLeft, |
||||
child: AutocompleteHighlightedOption( |
||||
highlightIndexNotifier: _highlightedOptionIndex, |
||||
child: Builder( |
||||
builder: (final context) => widget.optionsViewBuilder( |
||||
context, |
||||
_select, |
||||
_options, |
||||
), |
||||
), |
||||
), |
||||
), |
||||
); |
||||
Overlay.of(context, rootOverlay: true)!.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(final TextEditingController? old, final TextEditingController? current) { |
||||
if ((old == null && current == null) || old == current) { |
||||
return; |
||||
} |
||||
if (old == null) { |
||||
_textEditingController |
||||
..removeListener(_onChangedField) |
||||
..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(final FocusNode? old, final FocusNode? current) { |
||||
if ((old == null && current == null) || old == current) { |
||||
return; |
||||
} |
||||
if (old == null) { |
||||
_focusNode |
||||
..removeListener(_onChangedFocus) |
||||
..dispose(); |
||||
_focusNode = current!; |
||||
} else if (current == null) { |
||||
_focusNode.removeListener(_onChangedFocus); |
||||
_focusNode = FocusNode(); |
||||
} else { |
||||
_focusNode.removeListener(_onChangedFocus); |
||||
_focusNode = current; |
||||
} |
||||
_focusNode.addListener(_onChangedFocus); |
||||
} |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
_textEditingController = widget.textEditingController ?? TextEditingController.fromValue(widget.initialValue); |
||||
_textEditingController.addListener(_onChangedField); |
||||
_focusNode = widget.focusNode ?? FocusNode(); |
||||
_focusNode.addListener(_onChangedFocus); |
||||
_previousOptionAction = |
||||
_CustomAutocompleteCallbackAction<AutocompletePreviousOptionIntent>(onInvoke: _highlightPreviousOption); |
||||
_nextOptionAction = _CustomAutocompleteCallbackAction<AutocompleteNextOptionIntent>(onInvoke: _highlightNextOption); |
||||
_hideOptionsAction = _CustomAutocompleteCallbackAction<DismissIntent>(onInvoke: _hideOptions); |
||||
_actionMap = <Type, Action<Intent>>{ |
||||
AutocompletePreviousOptionIntent: _previousOptionAction, |
||||
AutocompleteNextOptionIntent: _nextOptionAction, |
||||
DismissIntent: _hideOptionsAction, |
||||
}; |
||||
_updateActions(); |
||||
_updateOverlay(); |
||||
} |
||||
|
||||
@override |
||||
void didUpdateWidget(final CustomRawAutocomplete<T> oldWidget) { |
||||
super.didUpdateWidget(oldWidget); |
||||
_updateTextEditingController( |
||||
oldWidget.textEditingController, |
||||
widget.textEditingController, |
||||
); |
||||
_updateFocusNode(oldWidget.focusNode, widget.focusNode); |
||||
_updateActions(); |
||||
_updateOverlay(); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
_textEditingController.removeListener(_onChangedField); |
||||
if (widget.textEditingController == null) { |
||||
_textEditingController.dispose(); |
||||
} |
||||
_focusNode.removeListener(_onChangedFocus); |
||||
if (widget.focusNode == null) { |
||||
_focusNode.dispose(); |
||||
} |
||||
_floatingOptions?.remove(); |
||||
_floatingOptions = null; |
||||
super.dispose(); |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => Container( |
||||
key: _fieldKey, |
||||
child: Shortcuts( |
||||
shortcuts: _shortcuts, |
||||
child: Actions( |
||||
actions: _actionMap, |
||||
child: CompositedTransformTarget( |
||||
link: _optionsLayerLink, |
||||
child: widget.fieldViewBuilder == null |
||||
? const SizedBox.shrink() |
||||
: widget.fieldViewBuilder!( |
||||
context, |
||||
_textEditingController, |
||||
_focusNode, |
||||
_onFieldSubmitted, |
||||
), |
||||
), |
||||
), |
||||
), |
||||
); |
||||
} |
||||
|
||||
class _CustomAutocompleteCallbackAction<T extends Intent> extends CallbackAction<T> { |
||||
_CustomAutocompleteCallbackAction({ |
||||
required super.onInvoke, |
||||
this.enabled = true, |
||||
}); |
||||
|
||||
bool enabled; |
||||
|
||||
@override |
||||
bool isEnabled(covariant final T intent) => enabled; |
||||
|
||||
@override |
||||
bool consumesKey(covariant final T intent) => enabled; |
||||
} |
Loading…
Reference in new issue