
Introduction
This article demos how to create a textbox which can auto-suggest items at runtime based on input, in this case, disk drive folders.
Background
There are a number of auto-complete textbox implementations around; however, some don't support data binding, and others don't support runtime items polling. After some Googling, I decided to write my own instead of continuing to look for one.
My design process
My first design is based on the ComboBox
. I copy the default template and remove the drop down button and develop from that. It doesn't work because the combobox has its own autocomplete mechanism which will change the selection of the textbox when items are changed; it's not designed for items that change at real-time.
The second design is based on a TextBox
. I create the following style:
<Style x:Key="autoCompleteTextBox" TargetType="{x:Type TextBox}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type TextBoxBase}">
<Grid x:Name="root">
<ScrollViewer Margin="0" x:Name="PART_ContentHost"/>
<Popup x:Name="PART_Popup"
AllowsTransparency="true"
Placement="Bottom"
IsOpen="False"
PopupAnimation="{DynamicResource
{x:Static SystemParameters.ComboBoxPopupAnimationKey}}">
<ListBox x:Name="PART_ItemList"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
VerticalContentAlignment="Stretch"
HorizontalContentAlignment="Stretch"
KeyboardNavigation.DirectionalNavigation="Contained" />
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
and then create a custom control and hook the style to it:
<TextBox x:Class="QuickZip.Controls.SelectFolderTextBox"
Style="{DynamicResource autoCompleteTextBox}" >
... </TextBox>
PART_ContentHost
is actually a TextBoxView
; it is required for the TextBox
template (with that name), or the control won't function. The other two parts (PART_Popup
and PART_ItemList
) are defined so I can use them in the custom control:
public partial class SelectFolderTextBox : TextBox
{
Popup Popup { get { return this.Template.FindName("PART_Popup", this) as Popup; } }
ListBox ItemList { get { return
this.Template.FindName("PART_ItemList", this) as ListBox; } }
ScrollViewer Host { get { return
this.Template.FindName("PART_ContentHost", this) as ScrollViewer; } }
UIElement TextBoxView { get {
foreach (object o in LogicalTreeHelper.GetChildren(Host))
return o as UIElement; return null; } }
....
}

If text is changed, the suggestion item list is updated as well:
protected override void OnTextChanged(TextChangedEventArgs e)
{
if (_loaded)
{
try
{
if (lastPath != Path.GetDirectoryName(this.Text))
{
lastPath = Path.GetDirectoryName(this.Text);
string[] paths = Lookup(this.Text);
ItemList.Items.Clear();
foreach (string path in paths)
if (!(String.Equals(path, this.Text,
StringComparison.CurrentCultureIgnoreCase)))
ItemList.Items.Add(path);
}
Popup.IsOpen = true;
ItemList.Items.Filter = p =>
{
string path = p as string;
return path.StartsWith(this.Text, StringComparison.CurrentCultureIgnoreCase)&&
!(String.Equals(path, this.Text, StringComparison.CurrentCultureIgnoreCase));
};
}
catch
{
}
}
}
A number of handlers is then defined:
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
_loaded = true;
this.KeyDown += new KeyEventHandler(AutoCompleteTextBox_KeyDown);
this.PreviewKeyDown += new KeyEventHandler(AutoCompleteTextBox_PreviewKeyDown);
ItemList.PreviewMouseDown +=
new MouseButtonEventHandler(ItemList_PreviewMouseDown);
ItemList.KeyDown += new KeyEventHandler(ItemList_KeyDown);
}
AutoCompleteTextBox_PreviewKeyDown
If the user pressed the down button, the textbox will move the focus to the listbox so the user can choose an item from it; this is placed in PreviewKeyDown
instead of KeyDown
because TextBox
's mechanism will consume the event before it reaches KeyDown
if the button is the down button.
void AutoCompleteTextBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Down && ItemList.Items.Count > 0
&& !(e.OriginalSource is ListBoxItem))
{
ItemList.Focus();
ItemList.SelectedIndex = 0;
ListBoxItem lbi = ItemList.
ItemContainerGenerator.ContainerFromIndex(ItemList.SelectedIndex) as ListBoxItem;
lbi.Focus();
e.Handled = true;
}
}
AutoCompleteTextBox_KeyDown
If the user presses the <Enter> button, the textbox will close the popup and update the binding.
void AutoCompleteTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
Popup.IsOpen = false;
updateSource();
}
}
ItemList_PreviewMouseDown and ItemList_PreviewMouseDown
If the user presses the <Enter> button (or select by mouse), the text textbox will be updated with ListBox.SelectedValue
, and then we update the binding.
void ItemList_KeyDown(object sender, KeyEventArgs e)
{
if (e.OriginalSource is ListBoxItem)
{
ListBoxItem tb = e.OriginalSource as ListBoxItem;
Text = (tb.Content as string);
if (e.Key == Key.Enter)
{
Popup.IsOpen = false;
updateSource();
}
}
}
void ItemList_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.LeftButton == MouseButtonState.Pressed)
{ {
TextBlock tb = e.OriginalSource as TextBlock;
if (tb != null)
{
Text = tb.Text;
updateSource();
Popup.IsOpen = false;
e.Handled = true;
}
}
}
updateSource
is required because I bound the text's UpdateSourceTrigger
as Explicit
; if updateSource
is not called, it won't update the text:
void updateSource()
{
if (this.GetBindingExpression(TextBox.TextProperty) != null)
this.GetBindingExpression(TextBox.TextProperty).UpdateSource();
}
The component is working now, but if you want to add validation as well, read below:
To support validation, a Validation Rule is written
If the path is not found or an exception is raised when looking up, it will return ValidationResult
as false
, and the error will be accessed by using the attached properties Validation.Errors
and Validation.HasError
.
public class DirectoryExistsRule : ValidationRule
{
public static DirectoryExistsRule Instance = new DirectoryExistsRule();
public override ValidationResult Validate(object value,
System.Globalization.CultureInfo cultureInfo)
{
try
{
if (!(value is string))
return new ValidationResult(false, "Invalid Path");
if (!Directory.Exists((string)value))
return new ValidationResult(false, "Path Not Found");
}
catch (Exception ex)
{
return new ValidationResult(false, "Invalid Path");
}
return new ValidationResult(true, null);
}
}
and we change the binding to use the created Validation Rule; note that UpdateSourceTrigger
is Explicit
.
<local:SelectFolderTextBox x:Name="stb"
DockPanel.Dock="Bottom" Margin="4,0,0,0">
<local:SelectFolderTextBox.Text>
<Binding Path="Text" UpdateSourceTrigger="Explicit" >
<Binding.ValidationRules>
<t:DirectoryExistsRule />
</Binding.ValidationRules>
</Binding>
</local:SelectFolderTextBox.Text>
</local:SelectFolderTextBox>
Now the textbox shows a red border if the directory does not exist. As a red border isn't clear enough, we can change the behavior to disable the default red border:
<Style x:Key="autoCompleteTextBox" TargetType="{x:Type TextBox}">
<...>
<Setter Property="Validation.ErrorTemplate">
<Setter.Value>
<ControlTemplate >
<AdornedElementPlaceholder /> -->
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
then change the control template, which will show the dockWarning
when Validation.HasError
:
<ControlTemplate TargetType="{x:Type TextBoxBase}">
<Border Name="Border" CornerRadius="2"
Background="{StaticResource WindowBackgroundBrush}"
BorderBrush="{StaticResource SolidBorderBrush}"
BorderThickness="1" Padding="1" >
<Grid x:Name="root">
<...>
<DockPanel x:Name="dockWarning"
Visibility="Collapsed" LastChildFill="False" >
<Border DockPanel.Dock="Right" BorderBrush="Red" Background="Red"
BorderThickness="2" CornerRadius="2,2,0,0">
<TextBlock x:Name="txtWarning" DockPanel.Dock="Right"
Text="{TemplateBinding ToolTip}" VerticalAlignment="Top"
Background="Red" Foreground="White" FontSize="10" />
<Border.RenderTransform>
<TranslateTransform X="2" Y="{Binding ElementName=dockWarning,
Path=ActualHeight,
Converter={x:Static t:InvertSignConverter.Instance}}"/>
-->
-->
</Border.RenderTransform>
</Border>
</DockPanel>
</Grid>
</Border>
<ControlTemplate.Triggers>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="Validation.HasError" Value="true" />
<Condition SourceName="PART_Popup" Property="IsOpen" Value="False" />
</MultiTrigger.Conditions>
<Setter Property="ToolTip" Value="{Binding
RelativeSource={RelativeSource Self},
Path=(Validation.Errors)[0].ErrorContent}"/>
<Setter TargetName="dockWarning" Property="Visibility" Value="Visible" />
<Setter TargetName="Border" Property="BorderThickness" Value="2" />
<Setter TargetName="Border" Property="Padding" Value="0" />
<Setter TargetName="Border" Property="BorderBrush" Value="Red" />
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
History
- 22-12-08: Initial version.
- 25-12-08: Added ghost image when picking from
ItemList
.
- 25-12-08: Handles PageUp/Down/Up buttons in the textbox.
- 25-12-08: Handles Escape button in the
ItemList
.
- 25-12-08: Disabled the caching system as it doesn't work well.
- 25-12-08: Updated version 1.
- 04-09-09: Disabled the TempVisual component.
- 04-09-09: Popup repositions automatically when window is moved.
- 04-09-09: Popup hides when deactivated (restores when activated).
- 04-09-09: Updated version 2.