Build a highlighting AutoCompleteBox (like IE8 and Firefox 3) in 5 minutes
November 18, 2008
The auto complete functionality that IE and Firefox provide in the URL address bar is nice: both these browsers (and many other auto complete implementations) highlight the text that you've typed, inside the results display.
If you use the Silverlight Toolkit's AutoCompleteBox control, and pair it with a fun SearchMode like 'Contains', you're able to offer your customers the same experience. The completed sample app has the highlight functionality, looking like this:
Here's what we need to do to build this functionality into an app:
- Create a simple highlighting text block control that has a few properties: Text, HighlightText, HighlightBrush
- Provide a custom data template for the items in the AutoCompleteBox
- Wire up the search text of AutoCompleteBox to the highlight text
In a WPF world, we'd be able to accomplish the third bullet inside XAML, but I'm going to need to do some visual tree hacking to do this in today's example. But it's still quick!
Creating a HighlightingTextBlock
We need to create a control that takes care of managing the highlighting.
The control prepares an array of inline Run instances for its TextBlock, and then can selectively highlight and un-highlight individual Run instances. The relation is one Run instance to one character.
Since TextBlock is sealed in Silverlight, I've created a simple control that derives from Control, and exposes these dependency properties:
- Text: The text to display
- HighlightText: The text to highlight
- HighlightBrush: The brush to use for the foreground color on the highlighted text
- HighlightTextWeight: You can provide a custom weight for highlight- Firefox, for instance, bolds and underlines the highlighted text to make it even more visible.
This control is simple enough, and only took a few minutes to create. It's a little lengthy only because of the regular control goo, like dependency property definitions:
// (c) Copyright Microsoft Corporation. // This source is subject to the Microsoft Public License (Ms-PL). // Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details. // All other rights reserved. using System; using System.Collections.Generic; using System.Windows; using System.Windows.Controls; using System.Windows.Documents; using System.Windows.Media; namespace JeffWilcox.Samples.HighlightingAutoComplete { /// <summary> /// A specialized highlighting text block control. /// </summary> public partial class HighlightingTextBlock : Control { /// <summary> /// The name of the TextBlock part. /// </summary> private string TextBlockName = "Text"; /// <summary> /// Gets or sets the text block reference. /// </summary> private TextBlock TextBlock { get; set; } /// <summary> /// Gets or sets the inlines list. /// </summary> private List<Inline> Inlines { get; set; } #region public string Text /// <summary> /// Gets or sets the contents of the TextBox. /// </summary> public string Text { get { return GetValue(TextProperty) as string; } set { SetValue(TextProperty, value); } } /// <summary> /// Identifies the Text dependency property. /// </summary> public static readonly DependencyProperty TextProperty = DependencyProperty.Register( "Text", typeof(string), typeof(HighlightingTextBlock), new PropertyMetadata(OnTextPropertyChanged)); /// <summary> /// TextProperty property changed handler. /// </summary> /// <param name="d">AutoCompleteBox that changed its Text.</param> /// <param name="e">Event arguments.</param> private static void OnTextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { HighlightingTextBlock source = d as HighlightingTextBlock; if (source.TextBlock != null) { while (source.TextBlock.Inlines.Count > 0) { source.TextBlock.Inlines.RemoveAt(0); } string value = e.NewValue as string; source.Inlines = new List<Inline>(); if (value != null) { for (int i = 0; i < value.Length; i++) { Inline run = new Run { Text = value[i].ToString() }; source.TextBlock.Inlines.Add(run); source.Inlines.Add(run); } source.ApplyHighlighting(); } } } #endregion public string Text #region public string HighlightText /// <summary> /// Gets or sets the highlighted text. /// </summary> public string HighlightText { get { return GetValue(HighlightTextProperty) as string; } set { SetValue(HighlightTextProperty, value); } } /// <summary> /// Identifies the HighlightText dependency property. /// </summary> public static readonly DependencyProperty HighlightTextProperty = DependencyProperty.Register( "HighlightText", typeof(string), typeof(HighlightingTextBlock), new PropertyMetadata(OnHighlightTextPropertyChanged)); /// <summary> /// HighlightText property changed handler. /// </summary> /// <param name="d">AutoCompleteBox that changed its HighlightText.</param> /// <param name="e">Event arguments.</param> private static void OnHighlightTextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { HighlightingTextBlock source = d as HighlightingTextBlock; source.ApplyHighlighting(); } #endregion public string HighlightText #region public Brush HighlightBrush /// <summary> /// Gets or sets the highlight brush. /// </summary> public Brush HighlightBrush { get { return GetValue(HighlightBrushProperty) as Brush; } set { SetValue(HighlightBrushProperty, value); } } /// <summary> /// Identifies the HighlightBrush dependency property. /// </summary> public static readonly DependencyProperty HighlightBrushProperty = DependencyProperty.Register( "HighlightBrush", typeof(Brush), typeof(HighlightingTextBlock), new PropertyMetadata(null, OnHighlightBrushPropertyChanged)); /// <summary> /// HighlightBrushProperty property changed handler. /// </summary> /// <param name="d">HighlightingTextBlock that changed its HighlightBrush.</param> /// <param name="e">Event arguments.</param> private static void OnHighlightBrushPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { HighlightingTextBlock source = d as HighlightingTextBlock; source.ApplyHighlighting(); } #endregion public Brush HighlightBrush #region public FontWeight HighlightFontWeight /// <summary> /// Gets or sets the font weight used on highlighted text. /// </summary> public FontWeight HighlightFontWeight { get { return (FontWeight)GetValue(HighlightFontWeightProperty); } set { SetValue(HighlightFontWeightProperty, value); } } /// <summary> /// Identifies the HighlightFontWeight dependency property. /// </summary> public static readonly DependencyProperty HighlightFontWeightProperty = DependencyProperty.Register( "HighlightFontWeight", typeof(FontWeight), typeof(HighlightingTextBlock), new PropertyMetadata(FontWeights.Normal, OnHighlightFontWeightPropertyChanged)); /// <summary> /// HighlightFontWeightProperty property changed handler. /// </summary> /// <param name="d">HighlightingTextBlock that changed its HighlightFontWeight.</param> /// <param name="e">Event arguments.</param> private static void OnHighlightFontWeightPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { HighlightingTextBlock source = d as HighlightingTextBlock; FontWeight value = (FontWeight)e.NewValue; } #endregion public FontWeight HighlightFontWeight /// <summary> /// Initializes a new HighlightingTextBlock class. /// </summary> public HighlightingTextBlock() { DefaultStyleKey = typeof(HighlightingTextBlock); Loaded += OnLoaded; } /// <summary> /// Loaded method handler. /// </summary> /// <param name="sender">The loaded event.</param> /// <param name="e">The event data.</param> private void OnLoaded(object sender, RoutedEventArgs e) { OnApplyTemplate(); } /// <summary> /// Override the apply template handler. /// </summary> public override void OnApplyTemplate() { base.OnApplyTemplate(); // Grab the template part TextBlock = GetTemplateChild(TextBlockName) as TextBlock; // Re-apply the text value string text = Text; Text = null; Text = text; } /// <summary> /// Apply the visual highlighting. /// </summary> private void ApplyHighlighting() { if (Inlines == null) { return; } string text = Text ?? string.Empty; string highlight = HighlightText ?? string.Empty; StringComparison compare = StringComparison.OrdinalIgnoreCase; int cur = 0; while (cur < text.Length) { int i = highlight.Length == 0 ? -1 : text.IndexOf(highlight, cur, compare); i = i < 0 ? text.Length : i; // Clear while (cur < i && cur < text.Length) { Inlines[cur].Foreground = Foreground; Inlines[cur].FontWeight = FontWeight; cur++; } // Highlight int start = cur; while (cur < start + highlight.Length && cur < text.Length) { Inlines[cur].Foreground = HighlightBrush; Inlines[cur].FontWeight = HighlightFontWeight; cur++; } } } } }
Next, here's the generic.xaml for the control that is stored in the themes\generic.xaml file of the project, as a resource. Note the assembly reference in the xml namespace declaration - you may need to adjust it if you're building the project yourself.
<ResourceDictionary xmlns="http://schemas.microsoft.com/client/2007" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:sample="clr-namespace:JeffWilcox.Samples.HighlightingAutoComplete;assembly=JeffWilcox.Samples.HighlightingAutoComplete.Sample" xmlns:vsm="clr-namespace:System.Windows;assembly=System.Windows"> <Style TargetType="sample:HighlightingTextBlock"> <Setter Property="HighlightBrush" Value="Blue" /> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="sample:HighlightingTextBlock"> <TextBlock x:Name="Text" /> </ControlTemplate> </Setter.Value> </Setter> </Style> </ResourceDictionary>
Setup the AutoCompleteBox item template
Now, with the highlighting text block ready, the ItemTemplate of the AutoCompleteBox control can be set to bind to our new control. The sample xmlns points to the CLR namespace of the control we built, JeffWilcox.Samples.HighlightingAutoComplete:
<controls:AutoCompleteBox x:Name="HighlightingAutoCompleteBox" IsTextCompletionEnabled="False" SearchMode="Contains"> <controls:AutoCompleteBox.ItemTemplate> <DataTemplate> <sample:HighlightingTextBlock Text="{Binding}" HighlightBrush="#FF44A0FF" /> </DataTemplate> </controls:AutoCompleteBox.ItemTemplate> </controls:AutoCompleteBox>
I also set the ItemsSource to use some of the sample data from the toolkit, to load a bunch of names into the control.
Wire up search text updates to the highlight text block instances
Without UI-to-UI/element name binding in Silverlight, I've needed to do some extra code wiring here. Whenever the populated event is fired, I prepare a dispatcher invoke that pokes around the visual tree to grab all the HighlightingTextBlock instances and set the HighlightText property manually.
This works, but ideally would be accomplished using some better binding support. I've also borrowed the VisualTreeExtensions.cs file (Ms-PL) from the Silverlight Toolkit to save time.
// From Page.xaml.cs private void Page_Loaded(object sender, RoutedEventArgs e) { HighlightingAutoCompleteBox.ItemsSource = Employee.AllExecutives; HighlightingAutoCompleteBox.Populated += Populated; } /// <summary> /// Cached Popup reference. /// </summary> private Popup _popup; /// <summary> /// Cached ListBox reference. /// </summary> private ListBox _listBox; /// <summary> /// Attach to the AutoCompleteBox's Populate event. Then, dive in and /// grab references to the internal template parts. Hacky but gets /// around the inability in Silverlight today to bind the SearchText /// property of the AutoCompleteBox control wtih the highlighting text /// block inside the item template. /// </summary> private void Populated(object sender, PopulatedEventArgs e) { Dispatcher.BeginInvoke(() => { if (_popup == null) { _popup = HighlightingAutoCompleteBox .GetLogicalChildrenBreadthFirst() .OfType<Popup>() .FirstOrDefault(); } if (_popup != null && _listBox == null) { FrameworkElement fe = _popup.Child as FrameworkElement; if (fe != null) { _listBox = fe .GetLogicalChildrenBreadthFirst() .OfType<ListBox>() .FirstOrDefault(); } } if (_listBox != null) { foreach (var item in _listBox .GetLogicalChildrenBreadthFirst() .OfType<HighlightingTextBlock>()) { item.HighlightText = HighlightingAutoCompleteBox.SearchText; } } }); }
I'm hooked to the Populated event, since it is always called when the SearchText property changes on the AutoCompleteBox - but if you have a delay before the results are shown, you may need to do some more advanced logic, pulling in the DropDownOpened events and/or a DispatcherTimer. The code above assumes that the list box and other template parts inside of the Popup have already been loaded.
Experience this application
Run the application live (Silverlight 2, 74 KB)
I'm also releasing the source code to this project under the Microsoft Public License, just like the rest of the toolkit. I think it's a little too specialized to find its way into the Silverlight Toolkit.
Download the C# source project (Zip, 59 KB)
Hope this helps!
Updated 3/24/09 Added a new Silverlight 3 Beta post for the same sample.