Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

WPF AJAX Style ComboBox

0.00/5 (No votes)
24 Jan 2008 1  
An article describing the WPF AJAX style ComboBox custom control. The control demonstrates implementing custom RoutedEvents. There is also a section on coding RoutedEvents using VB.NET.

Introduction

This article describes a WPF AJAX style ComboBox custom control. The control demonstrates implementing custom RoutedEvents. There is also a section on coding RoutedEvents using VB.NET.

I've have been using AJAX enabled DropDownList controls in my ASP.NET projects for quite some time. You'll find these cool controls being utilized all over the web. Basically, the controls dynamically load themselves based on some user input, as opposed to standard DropDownList controls that are populated by the developer in markup or code and typically have a fixed item set.

The main reason a custom control is necessary is that the standard behavior of the ComboBox is to generate a SelectionChanged event every time the selection changes. In our case, this event wouldn't work because users expect to use their arrow keys to navigate through the records before selecting them. That would raise the SelectionChanged event every time they pressed the up or down arrow key. Our custom control changes the ComboBox by adding two custom RoutedEvents to enable the required behavior.

I have used this control over the Internet to get data through a VPN tunnel, and also from a publicly exposed Web Service. The source database had over 100,000 rows in the custom table. The control worked great.

Functional Requirements

  • Allow user to enter some text, press <ENTER>, and the ComboBox will populate itself.
  • Once the item list is visible, allow user to enter text, and the ComboBox will find the matching rows.
  • Allow users to use the arrow keys to navigate through the displayed records.
  • Allow user to press <ESC> to clear the ComboBox.
  • Allow user to select a record with the mouse, or by pressing the <ENTER> or <TAB> keys.
  • Author control so that developers can use any data source.
  • Author control so that developers can customize the display of data.

Demo Program

Running the Program

The above program has two sections: Default ComboBox and AJAX Style ComboBox.

The top section demonstrates the default behavior of a standard WPF ComboBox when its item selection is changed. Open the program and use the arrow keys to change the selection. The TextBlock on the right changes each time the arrow keys or mouse is used to change the selection.

The bottom section demonstrates our AJAX style ComboBox. Requirements of our program include, user should be able to enter some text and press <ENTER> which will cause the ComboBox to be filled with data. Once populated, the user can either navigate records with the arrow keys or the mouse. If using the keyboard, the user will navigate to the record they want using the arrow keys, and press <ENTER> or <TAB> to select the desired record. If using the mouse, just click on the record they want to select.

After running the program, you can see the difference in behavior between the two ComboBoxes.

Again, this is the main reason we need a custom control, because you would not want to go back to the database for a record every time the user changes selection with the arrow keys. Also, we need an event to listen for to load records when required.

AJAX Style Custom Control Code

When writing a custom control, there are two approaches. One is to add a new Custom Control to your project. Using this method, you will author your code and control template. The approach we are using is to add a new class to the project and simply inherit from ComboBox. In this case, we only need to author the code and not the control template. This control will use the WPF ComboBox default control template, with no action required on our part.

Imports System.ComponentModel

''' <summary>
''' An extended WPF ComboBox that works like the ASP.NET 
''' AJAX enabled ComboBoxes
''' </summary>
''' <remarks></remarks>
Public Class AjaxStyleComboBox
    Inherits ComboBox

#Region " Private Declarations "

    Private _bolKeyBoardActivity As Boolean = False
    Private _intMinimunSearchTextLength As Integer = 1

#End Region

#Region " Properties "

    ''' <summary>
    ''' What is the minimum number of characters the 
    ''' user must enter before they are allowed to 
    ''' request a record search
    ''' </summary>
    <Description("What is the minimum number of characters" & _
        "the user must enter before they are allowed to request" & _
        " a record search"), Category("Custom"), DefaultValue(1)> _
    Public Property MinimunSearchTextLength() As Integer
        Get
            Return _intMinimunSearchTextLength
        End Get
        Set(ByVal Value As Integer)
            _intMinimunSearchTextLength = Value
        End Set
    End Property

#End Region

#Region " RoutedEvents "

    ''' <summary>
    ''' Fires when a record is selected. This replaces the 
    ''' ComboBox SelectedChanged event
    ''' </summary>
    Public Shared ReadOnly LoadItemsSourceRoutedEvent As RoutedEvent _
        = EventManager.RegisterRoutedEvent( _
        "LoadItemsSource", RoutingStrategy.Bubble, _
        GetType(LoadItemsSourceRoutedEventHandler), _
        GetType(AjaxStyleComboBox))

    ''' <summary>
    ''' Fires when the combo box needs its items loaded
    ''' </summary>
    Public Shared ReadOnly RecordSelectedRoutedEvent As RoutedEvent _
      = EventManager.RegisterRoutedEvent( _
      "RecordSelected", RoutingStrategy.Bubble, _
      GetType(RecordSelectedRoutedEventHandler), _
      GetType(AjaxStyleComboBox))

    Public Custom Event LoadItemsSource As _
      LoadItemsSourceRoutedEventHandler

        AddHandler(ByVal value As _
          LoadItemsSourceRoutedEventHandler)

            Me.AddHandler( _
              AjaxStyleComboBox.LoadItemsSourceRoutedEvent, value)
        End AddHandler

        RemoveHandler(ByVal value As _
          LoadItemsSourceRoutedEventHandler)

            Me.RemoveHandler( _
              AjaxStyleComboBox.LoadItemsSourceRoutedEvent, value)
        End RemoveHandler

        RaiseEvent(ByVal sender As Object, _
          ByVal e As LoadItemsSourceRoutedEventArgs)

            Me.RaiseEvent(e)
        End RaiseEvent
    End Event

    Public Custom Event RecordSelected As _
        RecordSelectedRoutedEventHandler

        AddHandler(ByVal value As _
            RecordSelectedRoutedEventHandler)

            Me.AddHandler( _
                AjaxStyleComboBox.RecordSelectedRoutedEvent, value)
        End AddHandler

        RemoveHandler(ByVal value As _
            RecordSelectedRoutedEventHandler)

            Me.RemoveHandler( _
                AjaxStyleComboBox.RecordSelectedRoutedEvent, value)
        End RemoveHandler

        RaiseEvent(ByVal sender As Object, _
            ByVal e As RecordSelectedRoutedEventArgs)

            Me.RaiseEvent(e)
        End RaiseEvent
    End Event

#End Region

#Region " Methods "

    ''' <summary>
    ''' Raises the RecordSelected event if the user presses 
    ''' ENTER or TAB
    ''' </summary>
    Protected Overrides Sub OnPreviewKeyDown(ByVal e As _
        System.Windows.Input.KeyEventArgs)
        _bolKeyBoardActivity = True

        If Me.IsDropDownOpen = True _
            AndAlso Me.SelectedIndex > -1 _
            AndAlso (e.Key = Key.Enter _
                OrElse e.Key = Key.Tab) Then

            OnRecordSelected(New RecordSelectedRoutedEventArgs( _
                AjaxStyleComboBox.RecordSelectedRoutedEvent, Me, _
                Me.SelectedValue))

            Me.IsDropDownOpen = False
            If Not e.Key = Key.Tab Then
                e.Handled = True
            End If
        ElseIf e.Key = Key.Escape Then
            Me.ItemsSource = Nothing
            Me.Text = String.Empty
            Me.IsDropDownOpen = False
            e.Handled = True
        Else
            MyBase.OnPreviewKeyDown(e)
        End If
    End Sub

    ''' <summary>
    ''' Raises the LoadItemsSource event when the user 
    ''' has entered a search string and pressed ENTER
    ''' </summary>
    Protected Overrides Sub OnKeyDown(ByVal e As _
        System.Windows.Input.KeyEventArgs)
        _bolKeyBoardActivity = True

        'this code only runs if the DropDown is not open
        ' because if its open the OnPreviewKeyDown code 
        'will handle the ENTER key
        If Me.Text.Trim.Length >= Me.MinimunSearchTextLength _
            AndAlso e.Key = Key.Enter Then

            OnLoadItemsSource(New LoadItemsSourceRoutedEventArgs( _
                AjaxStyleComboBox.LoadItemsSourceRoutedEvent, Me, _
                Me.Text))

            Me.IsDropDownOpen = True
            e.Handled = True
        Else
            MyBase.OnKeyDown(e)
        End If
    End Sub

    Protected Overrides Sub OnPreviewMouseLeftButtonDown( _
        ByVal e As System.Windows.Input.MouseButtonEventArgs)

        _bolKeyBoardActivity = False
        MyBase.OnPreviewMouseLeftButtonDown(e)
    End Sub

    ''' <summary>
    ''' Raises the RecordSelected event if the user selects 
    ''' a record with their mouse.
    ''' </summary>
    Protected Overrides Sub OnSelectionChanged( _
        ByVal e As System.Windows.Controls.SelectionChangedEventArgs)
        MyBase.OnSelectionChanged(e)

        If _bolKeyBoardActivity = False Then

            OnRecordSelected(New RecordSelectedRoutedEventArgs( _
                AjaxStyleComboBox.RecordSelectedRoutedEvent, Me, _
                Me.SelectedValue))

        End If
    End Sub

    Protected Overridable Sub OnRecordSelected( _
        ByVal e As RecordSelectedRoutedEventArgs)
        RaiseEvent RecordSelected(Me, e)
    End Sub

    Protected Overridable Sub OnLoadItemsSource( _
        ByVal e As LoadItemsSourceRoutedEventArgs)
        RaiseEvent LoadItemsSource(Me, e)
    End Sub

#End Region

End Class

How the Control Works

The control operates in two different states: when IsDropDownOpen is False (popup is not visible) and when IsDropDownOpen is True (popup is visible).

When IsDropDownOpen is False, the following occurs:

  • User begins keying characters into the control.
  • OnPreviewKeyDown inspects each character. If the <ESC> key is pressed, the ItemsSource is removed and the text is cleared from the control.
  • If the key is not handled by OnPreviewKeyDown, OnKeyDown inspects each character, and if the search text is at least as long as the MinimunSearchTextLength property and the <ENTER> key is pressed, the LoadItemsSource RoutedEvent is raised, requesting the program to provide data to the ComboBox.

When IsDropDownOpen is True, the following occurs:

  • User keys characters into the control.
  • OnPreviewKeyDown inspects each character. If the <ESC> key is pressed, the ItemsSource is removed and the text is cleared from the control. If the <ENTER> or <TAB> key is pressed, the RecordSelected RoutedEvent is raised, informing the program to take the required action.
  • If the key is not handled by OnPreviewKeyDown, the base ComboBox control takes over key processing and will look up items in the ComboBox as the user types. Try this: open the program, enter "smi" in the AJAX style ComboBox, and then press <ENTER>. The text will change to "Smith", and the ComboBox will open, displaying the records. Now, press the <END> key and type "-". This will select the record with the last name of "Smith-Bates". This functionality is built into the ComboBox when the IsEditable property is True. You can read up on this in the MDSN help topic: ComboBox.IsEditable Property.
  • If the user uses their mouse and clicks on a ComboBoxItem, the RecordSelected RoutedEvent is raised, informing the program to take the required action.

That is really all this control does. It simply process keystrokes and raises the appropriate events.

MinimunSearchTextLength Property

The MinimunSearchTextLength property controls how many characters the user must enter before they can begin a search. If you had 100,000 names in your database, you may want to limit the returned rows by requiring a certain number of characters be entered. You can also limit the number of rows returned by the SQL statement you use. For example, using the TOP predicate in your SELECT statement.

Now is a good time to mention an additional way to limit the rows returned. Let's say that your database is rather large and you want to limit the rows returned to customers in a certain state as well as last name. Super easy. Place an additional ComboBox with states in it, and use the state and the partial last name in your look up SQL statement or Stored Procedure parameters. You can see how simple it can be to provide additional features to this solution. This solution is not a one size fits all search solution. A WPF ComboBox is probably not a choice for large database record paging. So, if it fits, use it; if not, bust out the search form with a ListView.

The control exposes two RoutedEvents: LoadItemsSource and RecordSelected.

LoadItemsSource RoutedEvent

Just like the standard ASP.NET AJAX enabled DropDownList, this control raises a LoadItemsSource RoutedEvent when the control needs items. It is the consuming program's responsibility to take the search string and fill the ComboBox with search results.

RecordSelected RoutedEvent

When the user selects a record, this control raises the RecordSelected RoutedEvent. The consuming program should listen for this event and take the appropriate action. Normally, this means loading a form with data.

VB.NET Notes on RoutedEvent

For programmers new to RoutedEvents, confusion can occur because VB.NET RoutedEvent methods have the same names as some statements in the VB.NET language. Also, since most of the RoutedEvent examples are written in C#, VB.NET programmers don't have a one for one example when declaring their own custom RoutedEvents since the VB.NET syntax is different. I'll try to explain all this below.

Public Shared ReadOnly RecordSelectedRoutedEvent As RoutedEvent _
  = EventManager.RegisterRoutedEvent( _
  "RecordSelected", RoutingStrategy.Bubble, _
  GetType(RecordSelectedRoutedEventHandler), _
  GetType(AjaxStyleComboBox))

Public Custom Event LoadItemsSource As _
  LoadItemsSourceRoutedEventHandler

   AddHandler(ByVal value As _
     LoadItemsSourceRoutedEventHandler)

      Me.AddHandler( _
        AjaxStyleComboBox.LoadItemsSourceRoutedEvent, value)

      'same as the above line of code
      '[AddHandler]( _
      '  AjaxStyleComboBox.LoadItemsSourceRoutedEvent, value)

   End AddHandler

   RemoveHandler(ByVal value As _
      LoadItemsSourceRoutedEventHandler)

      Me.RemoveHandler( _
        AjaxStyleComboBox.LoadItemsSourceRoutedEvent, value)
   End RemoveHandler

   RaiseEvent(ByVal sender As Object, _
     ByVal e As LoadItemsSourceRoutedEventArgs)

      Me.RaiseEvent(e)

      'same as the above line of code
      '[RaiseEvent](e)
   End RaiseEvent
End Event

There are two pieces to creating a custom RoutedEvent: a RoutedEvent declaration, and the corresponding Custom Event code.

The RoutedEvent declaration is straightforward. Using the EventManger.RegisterRoutedEvent method, describe your event to WPF.

When it comes to the Handler Type parameter, you'll need to provide the delegate type for your RoutedEventHandler. If you do not require any special properties in your RoutedEventArgs, then you should use the RoutedEventHandler delegate supplied by the .NET 3.0 framework. If you need to supply special properties in your RoutedEventArgs, you will need to derive a class from RoutedEventArgs and supply your own delegate. In the included project download, there are two extended RoutedEventArgs classes and the corresponding delegates.

Custom Event

The above VB.NET Custom Event has three pieces: AddHandler, RemoveHandler, and RaiseEvent. The same code in C# only has the AddHandler and RemoveHandler sections, and does not use the word "Custom." The Visual Studio editor will template the Custom Event code for you after you enter the first line.

AddHandler and RemoveHandler

In the above example, AddHandler is written in three different ways. The first, AddHandler(ByVal value As LoadItemsSourceRoutedEventHandler), is a VB.NET language statement. In the Visual Studio editor, "AddHander" appears in blue. Notice that the AddHandler statement here has a different syntax than the AddHandler statement used to add handlers for standard events.

Me.AddHandler(AjaxStyleComboBox.LoadItemsSourceRoutedEvent, value) is a method inherited from the UIElement class.

[AddHandler](AjaxStyleComboBox.LoadItemsSourceRoutedEvent, value) is a method inherited from the UIElement class, and is the same as Me.AddHandler.

You can see that it is important to understand what is going on. C# does not have this problem.

RaiseEvent

RaiseEvent has the same naming issues as the above AddHandler and RemoveHander. In addition, when raising a custom RoutedEvent, the developer can do it in two different places.

First, the developer can make use of the Custom Event RaiseEvent by using the following syntax in their program:

OnRecordSelected(New RecordSelectedRoutedEventArgs( _ 
     AjaxStyleComboBox.RecordSelectedRoutedEvent, Me, Me.SelectedValue))
...

Protected Overridable Sub OnRecordSelected( _ 
  ByVal e As RecordSelectedRoutedEventArgs)

  RaiseEvent RecordSelected(Me, e)
End Sub

The same RoutedEvent can also be raised like this:

Me.RaiseEvent(New RecordSelectedRoutedEventArgs( _
  AjaxStyleComboBox.RecordSelectedRoutedEvent, _
  Me, Me.SelectedValue))

Your development team should decide which pattern to use and stick with it. The first is probably preferred because the OnRecordSelected method can be overridden by consumers of your control. If you use the second method, the consumers of the control won't have a method to override, or worse, your developer ignores the pattern and calls the event directly.

XAML Markup

<Window.Resources>

<DataTemplate x:Key="searchByContactLastName" >
  <StackPanel Orientation="Horizontal">
    <StackPanel.Resources>
      <Style TargetType="{x:Type TextBlock}">
          <Setter Property="Margin" Value="5,2,0,0"/>
          <Setter Property="Width" Value="100"/>
      </Style>
    </StackPanel.Resources>
    <TextBlock Text="{Binding Path=LastName}" Margin="0,2,0,0"/>
    <TextBlock Text="{Binding Path=FirstName}"/>
    <TextBlock Text="{Binding Path=City}"/>
    <TextBlock Text="{Binding Path=State}"/>
    <TextBlock Text="{Binding Path=ContactId}" Width="50"/>
  </StackPanel>
</DataTemplate>        

<ItemsPanelTemplate x:Key="cboVirtualizingPanelTemplate">
    <VirtualizingStackPanel IsItemsHost="True" IsVirtualizing="True" />
</ItemsPanelTemplate>

</Window.Resources>

Other XAML markup...

<WPFComboBoxAjaxStyle:AjaxStyleComboBox 
    HorizontalAlignment="Stretch" 
    x:Name="cboLastNameSearch" 
    VerticalAlignment="Top" 
    Grid.ColumnSpan="1" 
    Grid.Row="8"
    IsEditable="True"
    SelectedValuePath = "ContactId"
    TextSearch.TextPath = "LastName"
    ItemsPanel="{StaticResource cboVirtualizingPanelTemplate}" 
    ItemTemplate="{DynamicResource searchByContactLastName}" 
    IsSynchronizedWithCurrentItem="True" 
    MaxDropDownHeight="200"
    MinimunSearchTextLength="2"/>

The XAML markup is rather simple, and familiar to most.

DataTemplate

The power of this solution is really exploited here. You can have one or more DataTemplates defined for your AJAX style ComboBox. For example, maybe you will allow users to look up customers by their last name, first name, or invoice number. All that is required is to swap out the DataTemplate, and the ComboBox takes on a whole new look with respect to the display of data.

ItemsPanelTemplate

For ComboBoxes that will have more than a few records, it is important for performance reasons to set the ItemsPanelTemplate to a VirutalizingStackPanel. See the MSDN topic, "VirutalizingStackPanel Class".

You will also need to set the IsItemsHost property to True. This indicates that this panel is a container for user interface (UI) items that are generated by an ItemsControl. See the MSDN topic, "Panel.IsItemsHost Property".

AjaxStyleComboBox

I'll cover the properties that are critical for this solution to work.

I have mentioned the IsEditable property. Setting this to True will allow you to enter text in the ComboBox's TextBox.

The SelectedValuePath property tells the ComboBox what value to place in the SelectValue property when a record is selected. If you are using multiple DataTemplates, you may have to change this property if the new DataTemplate key is different from the previous DataTemplate key.

The TextSearch.TextPath attached property tells the ComboBox which property in the items collection will be displayed in the ComboBox TextBox. If you are using multiple DataTemplates, you will have to change this property to match the new path you are performing your look by. Example, "LastName", "FirstName", etc.

Window1 Code-Behind

''' <summary>
''' this is a custom event fired by our AjaxStyleComboBox
''' it's requesting new data so we provide it
''' </summary>
Private Sub cboLastNameSearch_LoadItemsSource( _
    ByVal sender As Object, ByVal e As LoadItemsSourceRoutedEventArgs) _
    Handles cboLastNameSearch.LoadItemsSource

    Me.cboLastNameSearch.ItemsSource = _
        DataAccessLayer.GetContactSearchResults(e.SearchString)
End Sub

''' <summary>
''' this is a custom event fired by our AjaxStyleComboBox
''' a record has been selected, now it's up to the local
''' code to act upon a record selection
''' </summary>
Private Sub cboLastNameSearch_RecordSelected( _
    ByVal sender As Object, ByVal e As RecordSelectedRoutedEventArgs) _
    Handles cboLastNameSearch.RecordSelected

    Me.gridContactForm.DataContext = _
        DataAccessLayer.GetContact(CType(e.Value, Integer))
End Sub

Here is where a custom control really shines. Almost no code in the Window code-behind file. Two event handlers with one line of code each. How simple!

Using the AJAX Style ComboBox in Your Projects

The image below shows a directory in the included solution with four files. When using this control, you will need to include these four files in your project.

Closing

Hope this article can help someone learn a little more about WPF.

History

  • 11 November 2007: Initial release.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here