Был у нас в свое время написан компонет DropDown с выпадающим списком, поиском, выделением и прочими фичами. Но что-то он мне все время не нравился, да и за новыми хотелками дизайнеров не успевал — костыль на костыль )) пришлось бы делать. Поэтому было принято решение написать новый компонент, только теперь не писать полностью с нуля, а использовать RawAutocomplete, чтобы он взял на себя подкапотную работу с Overlay. (По сравнению с Autocomplete RawAutocomplete позволяет дополнительно еще прокидывать focusNode и textEditingController, что поможет нам написать более гибкий виджет.)
Свои изыскания я и попытаюсь изложить здесь для себя (чтоб потом не вспоминать, что там и как) и для коллег.
Задача
Некоторые требования, на которых заострим внимание:
- Выпадающий список должен сам «понимать», куда ему расположиться, вниз от поля ввода или наверх. Очень желательно, чтобы при изменении размеров экрана (браузера в вебе) он перестраивался. Факультативно, чтобы и при скролле, если компонент находится в списке, тоже мог бы перестроиться.
- У компонента должен быть режим «выбор одного значения», когда после тапа на элемент, список закрывается, а в поле ввода отображается его представление.
- У компонента должен быть режим «выбор нескольких значений», когда после тапа на элемент, список не закрывается, а позволяет выбирать другие элементы. В поле ввода ничего не отображается, поле ввода используется исключительно в качестве «строки поиска».
- У компонента будет возможность как загрузить сразу полный список, так и подгружать по мере пролистывания (подразумевается, что подтягиваем таким образом с бэка большие списки).
Параметры
optionsBuilder
Обязательный параметр. Функция, асинхронная или нет, возвращающая список для отображения, в зависимости от изменения текстового поля. Срабатывает, когда пользователь вводит (меняет данные) или мы напрямую в textEditingController корректируем value.
// Called when _textEditingController changes.
Future<void> _onChangedField() async {
final TextEditingValue value = _textEditingController.value;
final Iterable<T> options = await widget.optionsBuilder(value);
_options = options;
Мы можем напрямую передавать свой список, но его изменение не вызовет перестройку виджетов, если не перестроить весь виджет, конечно. Что-то типа: optionsBuilder: (_) => _ourOptions,
Простейший вариант использования (из официальной документации):
optionsBuilder: (TextEditingValue textEditingValue) {
return _options.where((String option) {
return option.contains(textEditingValue.text.toLowerCase());
});
},
к содержанию ↑optionsViewBuilder
Обязательный параметр. Функция, которая возвращает виджет. В нее передаются context, коллбек выбора элемента onSelected и «список» объектов, которые нужно отобразить (сформированный в optionsBuilder). Чтобы внутри можно было определить индекс выбранного элемента, билдер оборачивается в AutocompleteHighlightedOption, из которого мы и можем получать нужную информацию: int highlightedIndex = AutocompleteHighlightedOption.of(context);
Список опять же можем «брать» свой из «замыкания», а не из параметров функции.
к содержанию ↑focusNode
Передаваемый внутрь фокус. Слушатель на наш фокус вешается в initState.
FocusNode? _internalFocusNode;
FocusNode get _focusNode {
return widget.focusNode ?? (_internalFocusNode ??= FocusNode()..addListener(_updateOptionsViewVisibility));
}
textEditingController
Передаваемый внутрь контроллер поля ввода.
TextEditingController? _internalTextEditingController;
TextEditingController get _textEditingController {
return widget.textEditingController ?? (_internalTextEditingController ??= TextEditingController()..addListener(_onChangedField));
}
optionsViewOpenDirection
Это просто направление, в котором открывается список: вниз или наверх.
displayStringForOption
Интересный параметр. В исходниках, в комментариях, написано, что обязательный, но если мы не передадим его, то будет использоваться дефолтный
который представляет из себя ToString для нашего выбранного элемента.
Используется в хэндлере изменений текстового поля для сравнения текущего текста и представления для выбранного элемента. а так же для формирования текста при выборе элемента в списке.
к содержанию ↑onSelected
Это просто коллбек, который вызывается при выборе элемента в списке. И он же передается в optionsViewBuilder.
fieldViewBuilder
Здесь мы просто «рисуем» наш текстовый инпут. На вход этой функции передается context, контроллер (наш или созданный при инициализации), фокус и функция, которая отрабатывает завершение ввода в поле, как будто тапнули на текущем элементе.
Если ничего не передаем в fieldViewBuilder, то обязаны через контроллер связать наш RawAutocompleter с «внешним» полем ввода.
к содержанию ↑initialValue
Стартовое значение, которое «раньше выбрано». Игнорируется, если мы передали контроллер, потому что участвует только в создании внутреннего:
final TextEditingController initialController =
widget.textEditingController ?? (_internalTextEditingController = TextEditingController.fromValue(widget.initialValue));
к содержанию ↑Реализация
Общие установки
В отличии от собственного компонента, мы не можем напрямую управлять показом списка через OverlayPortalController. За отображение отвечает вот этот вот код:
Фокус в поле, список не пустой… По факту, мы сможем только занулять _selection… А зануляется оно только в случае:
final T? selection = _selection;
if (selection != null && value.text != widget.displayStringForOption(selection)) {
_selection = null;
}
в _onChangedField, т.е. при изменении текста в контроллере. А при выборе элемента из null «превращается» в выбранный элемент. Таким образом, чтобы показывать список при помещении фокуса в поле ввода или при вводе текста, мы используем «костыль», а именно будем выдавать в displayStringForOption всегда уникальный текст, а потом через контроллер заменять его на нужный нам:
displayStringForOption: (_) => 'Уникальный текст RawAutocomplete',
к содержанию ↑Выбор одного элемента
Выстраиваем следующий процесс.
Первый клик по пустому полю ввода. Фокус есть, _selection = null, список какой-то есть. А точнее, полный. Значит список открывается. Начинаем вводить текст в поле. Отрабатывает _onChangedField, который вызывает optionsBuilder, список уменьшается. Выбираем один из элементов, отрабатывает _select, который помещает в _selection выбранный объект и в текст контроллера записывает наш уникальный текст из displayStringForOption. Вызывается наш onSelected, в котором мы в текст подставляем уже наше представление элемента. Снова отрабатывает _onChangedField и _selection зануляется, потому что наше представление не совпадает с уникальной строкой. Но мы убираем фокус из поля ввода и из-за этого список скрывается.
Новый клик по полю ввода, в нем текстовое представление нашего элемента. Оно используется для фильтрации списка, а так как мы выбирали элемент, то он, как минимум, и покажется, потому что _selection уже null и условия достаточны.
Все ок…
к содержанию ↑Выбор нескольких элементов
Первый клик по пустому полю ввода, все как и в предыдущем случае — список отображается. Только вместо представления нашего элемента подменяем на пустое поле и ничего не выводим. (Набранный таким образом список отображается в другом месте).
Новый клик, отображается весь список, потому что фильтровать не почему, _selection был сброшен ранее.
Остается реализовать хранение в отдельной переменной текущего значения поля ввода, чтобы заменять наш уникальный текст не на пустышку, а на него.