
AutoSuggest TextBox в WPF: подсказки из базы на VB.NET
В ASP.NET автодополнение даёт AutoCompleteExtender из AJAX Control Toolkit, а в WPF готового элемента нет. Соберём свой: TextBox, всплывающий список из DataTable, задержка без лишних потоков и управление с клавиатуры.
Задача и общий план
Поле с подсказками экономит время пользователя и уменьшает число опечаток: оператор начинает набирать название компании, а под полем появляется список подходящих вариантов. В веб-проектах на ASP.NET такую функцию обычно дают AutoCompleteExtender или jQuery UI, в WPF же стандартного элемента AutoSuggest долго не было, и его собирали из простых деталей.
Наш вариант состоит из четырёх частей:
- DataTable с вариантами, которую загружаем из базы один раз при открытии окна;
- фильтр через DataView.RowFilter, который срабатывает на каждое изменение текста;
- ListBox внутри Popup, который всплывает под полем и не сдвигает остальную разметку;
- обработка клавиш и щелчков: стрелка вниз, Enter, Escape и выбор мышью.
Разметка: TextBox и Popup со списком
Раньше подсказки часто показывали обычным ListBox, который просто становился видимым под полем. Такой список сдвигает соседние элементы или обрезается границами контейнера. Popup рисуется в отдельном слое поверх окна, поэтому ему не мешают ни Grid, ни ScrollViewer. Свойство PlacementTarget привязывает его к полю ввода, Placement=Bottom размещает прямо под ним.
<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.
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 мс после последнего нажатия. Быстрый набор не дёргает список на каждой букве.
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, поэтому стрелка не уходит на перемещение курсора.
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Что улучшить в боевом коде
Пример намеренно компактный. Перед тем как отдавать его пользователям, стоит учесть несколько деталей:
- искать не только по началу строки, но и по вхождению: LIKE '*текст*' в RowFilter работает, но на больших таблицах медленнее;
- подсвечивать совпавшую часть названия, разбив TextBlock на Run-элементы;
- вынести поле с подсказками в UserControl с зависимыми свойствами ItemsSource и SelectedItem, чтобы использовать его в разных окнах;
- для доступности задать AutomationProperties.Name у поля и списка, чтобы экранный диктор озвучивал подсказки.
Как это делают сегодня
Вопросы и ответы
Почему фильтр падает, если ввести апостроф?
Апостроф завершает строковый литерал в выражении RowFilter. Удваивайте его, а символы *, %, [ и ] заключайте в квадратные скобки, как в функции EscapeLike.
Почему при скрытии списка из потока возникает исключение?
Элементы WPF доступны только из потока интерфейса. Используйте DispatcherTimer, событие которого выполняется в нужном потоке, или Dispatcher.Invoke.
Сколько вариантов можно держать в памяти?
Несколько десятков тысяч строк DataView фильтрует быстро. При большем объёме переносите фильтр в SQL-запрос с LIKE и TOP и выполняйте его асинхронно.