AutoCompleteBox without cycling navigation
December 14, 2008
An occasional request I hear about the AutoCompleteBox control in the toolkit is about the cyclical navigation in the list box of suggestions. If you're browsing through a list of items, pressing the down arrow, eventually you'll reach the bottom - and then be cycled back to the top of the list.
This cycling behavior is consistent with many of the auto complete controls that users interact with most often: Internet Explorer, Mozilla Firefox, Windows search, and Google Suggest. Auto complete controls that default to not cycling through include the ASP.NET AJAX Toolkit, Yahoo's YUI component, and OS X.
In this post, I'm going to walk you through creating a derived AutoCompleteBox control that has a "UseCyclicNavigation" property that enables or disables this behavior.
Overview
To accomplish this work, we'll:
- Create a new class library for the custom control
- Create a new selection adapter that derives from ListBox, and modify the navigation logic
- Create a derived auto complete control
- Setup the default style and template for the control in generic.xaml
This is an intermediate tutorial, I'll be glossing over a lot of the smaller parts. The source is attached to the post for download. Feel free to leave any questions that you may have.
Brief background
One of the things that we've been trying to do is keep the auto complete control simple enough that it meets the needs of most folks, without being inundated with a lot of properties that are only applicable to very specific, limited scenarios. This was tracked in CodePlex issue #891 for the toolkit. We've decided not to add this functionality to the standard shipping control at this time, especially given that the ability to customize the selection adapter is out there.
Creating a new custom control class library
Open up Visual Studio with the Silverlight Tools installed and create a new Visual C# "Silverlight Class Library" project. I named my project "CustomAutoComplete".
- Make sure that you've downloaded the source code release of the Silverlight Toolkit December 2008 release (alternatively, you can grab the source files we're borrowing from the source code browser online)
- Add a reference to the Microsoft.Windows.Controls.dll binary
Making a new ISelectionAdapter
A selection adapter is a concept that the auto complete control uses for handing off the suggestion navigation, presentation, and selection to another control.
Instead of requiring that the auto complete control always use a ListBox to work, we opted for a design with the ISelectionAdapter interface. This means that you can create a wrapper for any control, regardless of whether the control derives from Selector or not. This allows you to use DataGrid, for instance.
I copied the contents of the SelectorSelectionAdapter.cs file in the Source\Controls\AutoCompleteBox\ directory of the toolkit source download.
Although a good starting place for our adapter, we are going to switch from the "wrapper" style of selection adapter and instead make ours an actual control by deriving directly from the Silverlight ListBox control.
This is not particularly important for this scenario, but I'll be re-using this project for some other posts where it does make a difference.
Changes I've made: named the file and class ListBoxSelectionAdapter. Derived from ListBox:
namespace CustomAutoCompleteBox { /// <summary> /// A selection adapter that is also a ListBox, for use with the /// AutoCompleteBox control. /// </summary> public partial class ListBoxSelectionAdapter : ListBox, ISelectionAdapter {
I added a simple CLR property of type 'bool' that will track whether cyclic navigation is being used:
/// <summary> /// Gets or sets a value that indicates whether cyclic navigation /// should be used or not. When on, and at the bottom of the list, /// a user pressing down will receive no response. The shipping /// AutoCompleteBox implementation always cycles. /// </summary> public bool UseCyclicNavigation { get; set; }
And then modifying the increment and decrement methods to take the cyclic property into account:
/// <summary> /// Increment the selected index, or wrap. /// </summary> protected void SelectedIndexIncrement() { if (UseCyclicNavigation) { // Standard control implementation SelectedIndex = SelectedIndex + 1 >= Items.Count ? -1 : SelectedIndex + 1; } else { // Limiting implementation SelectedIndex = SelectedIndex + 1 >= Items.Count ? Items.Count - 1 : SelectedIndex + 1; } } /// <summary> /// Decrement the SelectedIndex, or wrap around, inside the nested /// SelectionAdapter's control. /// </summary> protected void SelectedIndexDecrement() { int index = SelectedIndex; if (index >= 0) { SelectedIndex--; } else if (index == -1 && UseCyclicNavigation) { // Only when cycling navigation is on SelectedIndex = Items.Count - 1; } }
Derive CustomizedAutoCompleteBox from AutoCompleteBox
Now that the selection adapter is ready, we should create our own AutoCompleteBox control. We'll derive from the toolkit control and add a new dependency property for the cyclic navigation.
First off, create the new code file, CustomizedAutoCompleteBox.cs:
// (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.Windows; using System.Windows.Controls; using System.Windows.Input; using Microsoft.Windows.Controls; namespace CustomAutoCompleteBox { /// <summary> /// An AutoCompleteBox control implementation that has a few additional /// features, as a demonstration. "Polished" with features such as page up /// and page down support, and cyclic navigation settings for the list box. /// </summary> /// <remarks>This is a demonstration only and is strongly tied to the use of /// a ListBox as the selection adapter.</remarks> public class CustomizedAutoCompleteBox : AutoCompleteBox {
The standard implementation of AutoCompleteBox handles most of the interaction with the SelectionAdapter, but we still need to find the template part in the OnApplyTemplate method, so that we can change the CLR property in the adapter whenever our property value changes.
So, we add a private or internal automatic property for our ListBoxSelectionAdapter:
/// <summary> /// The selection adapter. /// </summary> internal ListBoxSelectionAdapter SelectionAdapterPart { get; set; }
Followed by the dependency property declaration for our 'UseCyclicNavigationProperty'. Things to look for:
- Property declaration
- Default value
- Property changed handler, that interacts with the SelectionAdapterPart property when it is not null
#region public bool UseCyclicNavigation /// <summary> /// Gets or sets a value indicating whether cyclic navigation should be /// used by the control. /// </summary> public bool UseCyclicNavigation { get { return (bool)GetValue(UseCyclicNavigationProperty); } set { SetValue(UseCyclicNavigationProperty, value); } } /// <summary> /// Identifies the UseCyclicNavigation dependency property. /// </summary> public static readonly DependencyProperty UseCyclicNavigationProperty = DependencyProperty.Register( "UseCyclicNavigation", typeof(bool), typeof(CustomizedAutoCompleteBox), new PropertyMetadata(true, OnUseCyclicNavigationPropertyChanged)); /// <summary> /// The UseCyclicNavigation property changed handler. /// </summary> /// <param name="d">The dependency object that had its property /// changed.</param> /// <param name="e">Event arguments.</param> private static void OnUseCyclicNavigationPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { CustomizedAutoCompleteBox source = d as CustomizedAutoCompleteBox; if (source.SelectionAdapterPart != null) { source.SelectionAdapterPart.UseCyclicNavigation = (bool)e.NewValue; } } #endregion public bool UseCyclicNavigation
Next up, we override the OnApplyTemplate method to grab the selection adapter instance and set it to our local SelectionAdapterPart property. It also will set the initial value based on the current dependency property's value.
/// <summary> /// Overrides the on apply template method. /// </summary> public override void OnApplyTemplate() { SelectionAdapterPart = GetTemplateChild("SelectionAdapter") as ListBoxSelectionAdapter; if (SelectionAdapterPart != null) { SelectionAdapterPart.UseCyclicNavigation = UseCyclicNavigation; } base.OnApplyTemplate(); }
We're almost there now!
Create the default template (generic.xaml)
If you provided a custom template for each instance of your CustomizedAutoCompleteBox control, you'd be good to go. But we still want to improve the experience by creating a default style and template, so that you can replace any toolkit AutoCompleteBox instance with a CustomizedAutoCompleteBox control in the basic scenario.
To do this, we must create our generic.xaml file inside the class library:
- Create a new folder in the project (right-click on the project in the Solution Explorer > select Add > then New Folder) called Themes
- Add a new Silverlight use control to the new Themes directory, call it generic.xaml. You can remove any associated generic.xaml.cs file, since there's no code behind for the default style file.
- Change the file to compile as a Resource: right-click on generic.xaml, select Properties; then, in the property grid, change the Build Action to be Resource instead of Page.
Next, open up the file, delete its contents, and replace it with the standard resource dictionary XML content, declare the local XMLNS for your assembly and namespace:
<!-- // (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. --> <ResourceDictionary xmlns="http://schemas.microsoft.com/client/2007" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="clr-namespace:CustomAutoCompleteBox"> </ResourceDictionary>
Now, we just have to add the style for the control. I've copied most of the template from the source download of the toolkit, but did modify the opacity of the list box a little bit. I also place an instance of ListBoxSelectionAdapter, with the x:Name of "SelectionAdapter" (the part name that AutoCompleteBox looks for), where the simple ListBox is in the default template:
<Style TargetType="controls:CustomizedAutoCompleteBox"> <Setter Property="IsTabStop" Value="False" /> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="controls:CustomizedAutoCompleteBox"> <Grid Margin="{TemplateBinding Padding}" Background="{TemplateBinding Background}"> <TextBox IsTabStop="True" x:Name="Text" Style="{TemplateBinding TextBoxStyle}" Margin="0" /> <Popup x:Name="Popup"> <Border x:Name="PopupBorder" HorizontalAlignment="Stretch" Opacity="0.0" BorderThickness="0" CornerRadius="3"> <Border.RenderTransform> <TranslateTransform X="1" Y="1" /> </Border.RenderTransform> <Border HorizontalAlignment="Stretch" Opacity="1.0" Padding="0" BorderThickness="{TemplateBinding BorderThickness}" BorderBrush="{TemplateBinding BorderBrush}" CornerRadius="3"> <Border.RenderTransform> <TransformGroup> <TranslateTransform X="-1" Y="-1" /> </TransformGroup> </Border.RenderTransform> <controls:ListBoxSelectionAdapter x:Name="SelectionAdapter" Background="#99ffffff" ItemContainerStyle="{TemplateBinding ItemContainerStyle}" ItemTemplate="{TemplateBinding ItemTemplate}" /> </Border> </Border> </Popup> <VisualStateManager.VisualStateGroups> <VisualStateGroup x:Name="PopupStates"> <VisualStateGroup.Transitions> <VisualTransition GeneratedDuration="0:0:0.1" To="PopupOpened" /> <VisualTransition GeneratedDuration="0:0:0.2" To="PopupClosed" /> </VisualStateGroup.Transitions> <VisualState x:Name="PopupOpened"> <Storyboard> <DoubleAnimation Storyboard.TargetName="PopupBorder" Storyboard.TargetProperty="Opacity" To="1.0" /> </Storyboard> </VisualState> <VisualState x:Name="PopupClosed"> <Storyboard> <DoubleAnimation Storyboard.TargetName="PopupBorder" Storyboard.TargetProperty="Opacity" To="0.0" /> </Storyboard> </VisualState> </VisualStateGroup> </VisualStateManager.VisualStateGroups> </Grid> </ControlTemplate> </Setter.Value> </Setter> </Style>
That is a lot of code; here's just the selection adapter part to make that clear. Remember, since we derived ListBoxSelectionAdapter directly from ListBox, this XAML is actually talking directly to a ListBox instance when initialized.
<controls:ListBoxSelectionAdapter x:Name="SelectionAdapter" Background="#99ffffff" ItemContainerStyle="{TemplateBinding ItemContainerStyle}" ItemTemplate="{TemplateBinding ItemTemplate}" />
Validating the new experience
Ok, build the project. Hopefully you'll receive no errors, and we'll be ready to verify the new behavior.
I added a regular Silverlight Application (with the auto-generated test page option) called 'Demo', and then:
- Added a reference to Microsoft.Windows.Controls
- Added a project reference to the CustomAutoCompleteBox project (that contains our CustomizedAutoCompleteBox control, the default template, and the ListBoxSelectionAdapter)
Inside the page you're testing, make sure to add the XMLNS statement for your custom library; you can then place your custom control inside. I'm binding it to the data context and setting our new UseCyclicNavigation property in XAML:
<UserControl x:Class="Demo.WithListBoxSelectionAdapter" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:custom="clr-namespace:CustomAutoCompleteBox;assembly=CustomAutoCompleteBox" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <Grid x:Name="LayoutRoot"> <StackPanel> <custom:CustomizedAutoCompleteBox ItemsSource="{Binding}" UseCyclicNavigation="False" x:Name="autoComplete1" /> </StackPanel> </Grid> </UserControl>
In my example, I set the page's DataContext to a bunch of sample data in the Loaded method. The download includes this data, plus the ability to compare the regular AutoCompleteBox control to the new one.
Download and explore this sample app
- View the sample application live
- Download the C# solution's source code (includes the toolkit binary with the standard AutoCompleteBox, 86 KB)
In theory you could also just expose the dependency property on the ListBoxSelectionAdapter (in the sample project), and then re-template every use of auto complete to set your intended value - but this example instead can be swapped into most standard auto complete apps instead.
Hope this helps, and that I didn't lose anybody - there's a lot of unrelated concepts here.