AutoSuggest TextBox в WPF: подсказки из базы на VB.NET

РазделWPFУровеньСредний Чтение9 минОпубликовано ТегиWPFVB.NETADO.NETUX

В ASP.NET автодополнение даёт AutoCompleteExtender из AJAX Control Toolkit, а в WPF готового элемента нет. Соберём свой: TextBox, всплывающий список из DataTable, задержка без лишних потоков и управление с клавиатуры.

Главное. Автоподсказки в WPF собираются из TextBox, Popup и ListBox: данные фильтруются через DataView.RowFilter с экранированием ввода, а задержка делается DispatcherTimer в потоке интерфейса, без ручных потоков и Invoke.

Задача и общий план

Поле с подсказками экономит время пользователя и уменьшает число опечаток: оператор начинает набирать название компании, а под полем появляется список подходящих вариантов. В веб-проектах на ASP.NET такую функцию обычно дают AutoCompleteExtender или jQuery UI, в WPF же стандартного элемента AutoSuggest долго не было, и его собирали из простых деталей.

Наш вариант состоит из четырёх частей:

  • DataTable с вариантами, которую загружаем из базы один раз при открытии окна;
  • фильтр через DataView.RowFilter, который срабатывает на каждое изменение текста;
  • ListBox внутри Popup, который всплывает под полем и не сдвигает остальную разметку;
  • обработка клавиш и щелчков: стрелка вниз, Enter, Escape и выбор мышью.
Загружать все строки заранее разумно, пока вариантов несколько тысяч. Если их сотни тысяч, фильтруйте на сервере запросом с LIKE и ограничением TOP, а запрос отправляйте с задержкой после ввода.

Разметка: TextBox и Popup со списком

Раньше подсказки часто показывали обычным ListBox, который просто становился видимым под полем. Такой список сдвигает соседние элементы или обрезается границами контейнера. Popup рисуется в отдельном слое поверх окна, поэтому ему не мешают ни Grid, ни ScrollViewer. Свойство PlacementTarget привязывает его к полю ввода, Placement=Bottom размещает прямо под ним.

Window1.xamlXML
<StackPanel Margin="12">
  <TextBlock Text="Компания:" />
  <TextBox x:Name="SearchBox" Width="300" HorizontalAlignment="Left" />
  <!-- StaysOpen=False: закрывается при щелчке мимо -->
  <Popup x:Name="SuggestPopup" PlacementTarget="{Binding ElementName=SearchBox}"
         Placement="Bottom" StaysOpen="False" Width="300">
    <ListBox x:Name="SuggestList" MaxHeight="200" ItemsSource="{Binding}">
      <ListBox.ItemTemplate>
        <DataTemplate>
          <StackPanel Orientation="Horizontal">
            <TextBlock Text="{Binding CompanyName}" FontWeight="SemiBold" />
            <TextBlock Text="{Binding City}" Foreground="Gray"
                       Margin="8,0,0,0" />
          </StackPanel>
        </DataTemplate>
      </ListBox.ItemTemplate>
    </ListBox>
  </Popup>
</StackPanel>

Фильтрация через DataView.RowFilter

Таблицу клиентов загружаем при открытии окна и держим в поле класса. При каждом изменении текста запрос в базу не идёт: мы лишь меняем строку фильтра у DataView. Синтаксис RowFilter похож на условие WHERE, и оператор LIKE с символом * в конце даёт поиск по началу строки.

Пользовательский ввод нельзя подставлять в фильтр как есть. Апостроф сломает выражение, а символы *, %, [ и ] в LIKE имеют особый смысл. Их нужно экранировать: апостроф удваивается, а спецсимволы заключаются в квадратные скобки. Это та же логика, что и параметризация SQL, только для выражений DataColumn.

ФильтрVB.NET
Private _customers As DataTable
Private _view As DataView

' Экранирование ввода для выражения LIKE в RowFilter
Private Shared Function EscapeLike(s As String) As String
    Dim sb As New System.Text.StringBuilder()
    For Each ch In s
        Select Case ch
            Case "*"c, "%"c, "["c, "]"c : sb.Append("[").Append(ch).Append("]")
            Case "'"c : sb.Append("''")
            Case Else : sb.Append(ch)
        End Select
    Next
    Return sb.ToString()
End Function

Private Sub ApplyFilter()
    Dim text = SearchBox.Text.Trim()
    If text.Length < 2 Then ' не показываем подсказки по одной букве
        SuggestPopup.IsOpen = False
        Return
    End If
    _view.RowFilter = "CompanyName LIKE '" & EscapeLike(text) & "*'"
    SuggestList.DataContext = _view
    SuggestPopup.IsOpen = _view.Count > 0
End Sub

Задержка без потоков: DispatcherTimer

В ранних реализациях скрытие и показ подсказок с задержкой делали через отдельный поток с Thread.Sleep. Это сразу приводит к исключению о межпоточном доступе: элементы WPF принадлежат потоку интерфейса, и обращаться к ListBox из фонового потока нельзя. Ошибку обходили через Dispatcher.Invoke с делегатом, и код становился запутанным.

Проще и надёжнее DispatcherTimer. Его событие Tick выполняется в потоке интерфейса, поэтому с элементами можно работать напрямую. Мы используем его для приёма «debounce»: каждый ввод символа перезапускает таймер, и фильтр применяется только через 250 мс после последнего нажатия. Быстрый набор не дёргает список на каждой букве.

Загрузка и таймерVB.NET
Private ReadOnly _timer As New Threading.DispatcherTimer With {
    .Interval = TimeSpan.FromMilliseconds(250)}

Private Sub Window1_Loaded(sender As Object, e As RoutedEventArgs) _
        Handles Me.Loaded
    _customers = LoadCustomers() ' SELECT CompanyName, City FROM Customers
    _view = New DataView(_customers) With {.Sort = "CompanyName"}
    AddHandler _timer.Tick, Sub()
                                _timer.Stop()
                                ApplyFilter()
                            End Sub
End Sub

Private Sub SearchBox_TextChanged(sender As Object,
        e As TextChangedEventArgs) Handles SearchBox.TextChanged
    _timer.Stop()  ' перезапуск: ждём паузу в наборе
    _timer.Start()
End Sub

Управление с клавиатуры и выбор подсказки

Подсказки, до которых можно добраться только мышью, раздражают. Минимальный набор клавиш: стрелка вниз переводит фокус в список на первый вариант, Enter подтверждает выбор, Escape закрывает список и возвращает фокус в поле. Обработчик PreviewKeyDown перехватывает нажатия раньше самого TextBox, поэтому стрелка не уходит на перемещение курсора.

Клавиши и выборVB.NET
Private Sub SearchBox_PreviewKeyDown(sender As Object, e As KeyEventArgs) _
        Handles SearchBox.PreviewKeyDown
    If e.Key = Key.Down AndAlso SuggestPopup.IsOpen Then
        SuggestList.SelectedIndex = 0
        Dim item = TryCast(SuggestList.ItemContainerGenerator _
                           .ContainerFromIndex(0), ListBoxItem)
        If item IsNot Nothing Then item.Focus()
        e.Handled = True
    ElseIf e.Key = Key.Escape Then
        SuggestPopup.IsOpen = False
    End If
End Sub

Private Sub SuggestList_KeyDown(sender As Object, e As KeyEventArgs) _
        Handles SuggestList.KeyDown
    If e.Key = Key.Enter Then AcceptSelection()
    If e.Key = Key.Escape Then SuggestPopup.IsOpen = False : SearchBox.Focus()
End Sub

Private Sub SuggestList_MouseUp(sender As Object, e As MouseButtonEventArgs) _
        Handles SuggestList.MouseUp
    AcceptSelection()
End Sub

Private Sub AcceptSelection()
    Dim row = TryCast(SuggestList.SelectedItem, DataRowView)
    If row Is Nothing Then Return
    _timer.Stop()
    SearchBox.Text = CStr(row("CompanyName"))
    SearchBox.CaretIndex = SearchBox.Text.Length
    SuggestPopup.IsOpen = False
    SearchBox.Focus()
End Sub
После программной установки SearchBox.Text снова сработает TextChanged и запустит таймер. Поэтому в AcceptSelection мы закрываем Popup, а в ApplyFilter полезно не открывать его, если текст совпадает с выбранным значением.

Что улучшить в боевом коде

Пример намеренно компактный. Перед тем как отдавать его пользователям, стоит учесть несколько деталей:

  • искать не только по началу строки, но и по вхождению: LIKE '*текст*' в RowFilter работает, но на больших таблицах медленнее;
  • подсвечивать совпавшую часть названия, разбив TextBlock на Run-элементы;
  • вынести поле с подсказками в UserControl с зависимыми свойствами ItemsSource и SelectedItem, чтобы использовать его в разных окнах;
  • для доступности задать AutomationProperties.Name у поля и списка, чтобы экранный диктор озвучивал подсказки.

Как это делают сегодня

Сегодня часто хватает встроенного ComboBox с IsEditable=True и IsTextSearchEnabled, а для полноценного автодополнения есть готовые элементы в WPF UI (AutoSuggestBox) и в коммерческих наборах. В WinUI 3 AutoSuggestBox встроен в платформу. Когда варианты приходят с сервера, запрос делают асинхронно через await с CancellationToken, отменяя предыдущий при каждом новом символе.

Вопросы и ответы

Почему фильтр падает, если ввести апостроф?

Апостроф завершает строковый литерал в выражении RowFilter. Удваивайте его, а символы *, %, [ и ] заключайте в квадратные скобки, как в функции EscapeLike.

Почему при скрытии списка из потока возникает исключение?

Элементы WPF доступны только из потока интерфейса. Используйте DispatcherTimer, событие которого выполняется в нужном потоке, или Dispatcher.Invoke.

Сколько вариантов можно держать в памяти?

Несколько десятков тысяч строк DataView фильтрует быстро. При большем объёме переносите фильтр в SQL-запрос с LIKE и TOP и выполняйте его асинхронно.

Читайте также