Introduction
This article is an extension of my previous article Embedding a DataGridView in a ComboBox.
After I wrote the article explaining techniques of how a DataGridView
could be used inside a ComboBox
control, I found an amazing open source alternative - ObjectListView. It has many advanced built in features including one that is of special value - generic fast search and filtering. This feature made it a perfect candidate for embedding in a ComboBox
and the result looks as follows:
Background
This article is based on the ideas presented in these articles:
First, a custom ToolStripControlHost
is created, then it is used to create custom ComboBox
. I do not proceed in creating a custom DataGridViewColumn
because it would be a shame to use such a brilliant control as an ObjectListView
inside a DataGridView
and ObjectListView
has a generic mechanism to add any editing control including this one.
Using the Code
To use the provided control, you'd need to create a new UserControl
deriving it from the provided InfoListControl
class. The control represents a view of the objects that you want to show in the dropdown (some columns showing properties of the objects). The purpose behind the control is to allow a developer to configure an ObjectListView
(bind columns to properties of a specific datasource type, set various visual properties, etc.) in a VS designer. Therefore, you are going to need an InfoListControl
for each object type that you are going to use. If you create the inherited UserControl
using standard Visual Studio methods, you won't need to write a line of code. After creating your inherited control, you will be presented with an ObjectListView
designer view. Here, you can configure all you need: set databindings (AspectName
for a column), configure column headers, visual appearance, etc. You can find a detailed description here.
After you create your InfoListControl
for a particular datasource type, you do all the control setup in a few lines of code:
Dim cntr As AccListComboBox
dim view As InfoListControl = new yourInfoListControlInstance
view.DataSource = GetDataSource(whatever)
view .ValueMember = ""
view .AcceptSingleClick = True
cntr.AddDataListView(view)
cntr.EmptyValueString = "0"
Inside the InfoListControl
, I used a flavor of ObjectListView
- DataListView
, because it supports databinding out of the box. However, in case you need non standard databinding (e.g. DataTable
, hierarchical structure), you could easily implement your own control to support it.
IMPORTANT
The current version of the ObjectListView
has a bug that causes exceptions when the ObjectListViewComboBox
is used as an edit control for ObjectListView
itself. Therefore, you need to make the following correction in the ObjectListView
source code (property ItemBeingEdited
of the class CellEditKeyEngine
) and compile it by yourself (i.e., do not use precompiled binaries):
protected OLVListItem ItemBeingEdited {
get {
OLVListItem olvi = (this.ListView == null ||
this.ListView.CellEditEventArgs == null) ?
null : this.ListView.CellEditEventArgs.ListViewItem;
if (olvi != null && olvi.Index < 0)
{
if (olvi.RowObject == null)
return olvi;
for (int i = 0; i < this.ListView.Items.Count; i++)
{
if (this.ListView.GetItem(i).RowObject == olvi.RowObject)
{
olvi = this.ListView.GetItem(i);
break;
}
}
}
return olvi;
}
}
Points of Interest
Creating InfoListControl
The essential part of the control is a ObjectListViewToolStrip
class that inherits ToolStripControlHost
. Basically, a ToolStripControlHost
class can handle any control including an ObjectListView
by itself however, in this case a developer would need to create an ObjectListView
programmatically (not very user friendly). Therefore, I decided to create a dedicated user control that on the one hand holds all the ObjectListView
specific logic neatly encapsulated and on the other hand provides full designer support.
Actually in the InfoListControl
, I did not use a generic ObjectListView
but its descendent - DataListView
(because I needed databinding support). The encapsulation of the actual control provides an easy way to rewrite the control for a generic ObjectListView
or a TreeListView
(another descendant of the ObjectListView
).
InfoListControl
contains an instance of a DataListView
added by the VS designer and provides a simple public
constructor as well as four pretty much self explanatory properties with backing fields:
Private _AcceptSingleClick As Boolean = False
Private _ValueMember As String = ""
Private _FilterString As String = ""
Public Property AcceptSingleClick() As Boolean
Get
Return _AcceptSingleClick
End Get
Set(ByVal value As Boolean)
_AcceptSingleClick = value
End Set
End Property
Public Property ValueMember() As String
Get
Return _ValueMember
End Get
Set(ByVal value As String)
If value Is Nothing Then value = ""
_ValueMember = value
End Set
End Property
</see> that wraps a value object list.
Public Property DataSource() As Object
Get
Return baseDataListView.DataSource
End Get
Set(ByVal value As Object)
baseDataListView.DataSource = value
End Set
End Property
Public Property FilterString() As String
Get
Return _FilterString
End Get
Set(ByVal value As String)
If value Is Nothing Then value = ""
If value <> _FilterString Then
_FilterString = value
baseDataListView.AdditionalFilter = _
TextMatchFilter.Contains(baseDataListView, _FilterString)
End If
End Set
End Property
Public Sub New()
InitializeComponent()
baseDataListView.DefaultRenderer = New HighlightTextRenderer( _
TextMatchFilter.Contains(baseDataListView, New String() {""}))
baseDataListView.SelectColumnsMenuStaysOpen = True
End Sub
The only points of interest here are calls to the ObjectListView
methods required for setting a filter graphic renderer and the filter (string
) itself.
Next, the InfoListControl
shall implement event to pass user selection to the parent ToolStripControlHost
:
Friend Delegate Sub ValueSelectedEventHandler(ByVal sender As Object, ByVal e As ValueChangedEventArgs)
Friend Event ValueSelected As ValueSelectedEventHandler
Protected Sub OnValueSelected(ByVal e As ValueChangedEventArgs)
RaiseEvent ValueSelected(Me, e)
End Sub
Protected Sub OnValueSelected(ByVal currentObject As Object, ByVal isCanceled As Boolean)
If _ValueMember Is Nothing OrElse String.IsNullOrEmpty(_ValueMember) _
OrElse currentObject Is Nothing Then
RaiseEvent ValueSelected(Me, New ValueChangedEventArgs(currentObject, isCanceled))
Else
If baseDataListView.GetItemCount() < 1 OrElse baseDataListView.GetItem(0). _
RowObject.GetType().GetProperty(_ValueMember.Trim, BindingFlags.Public _
OrElse BindingFlags.Instance) Is Nothing Then
RaiseEvent ValueSelected(Me, New ValueChangedEventArgs(Nothing, isCanceled))
Else
RaiseEvent ValueSelected(Me, New ValueChangedEventArgs( _
GetValueMemberValue(currentObject), isCanceled))
End If
End If
End Sub
Public Class ValueChangedEventArgs
Inherits EventArgs
Private _SelectedValue As Object = Nothing
Private _SelectionCanceled As Boolean = False
Public ReadOnly Property SelectedValue() As Object
Get
Return _SelectedValue
End Get
End Property
Public ReadOnly Property SelectionCanceled() As Boolean
Get
Return _SelectionCanceled
End Get
End Property
Friend Sub New(ByVal newValue As Object, ByVal isCanceled As Boolean)
_SelectedValue = newValue
_SelectionCanceled = isCanceled
End Sub
End Class
The point of interest here is the OnValueSelected
method overload that raises the event and passes the selected object to the event args. The purpose of the method is to filter the user selection input and (in case the ValueMember
property is set) to return not the object by itself but the value of the required property. A value of the required property is fetched using a simple helper method GetValueMemberValue
that uses a simple reflection in a Try...Catch
block to avoid runtime exceptions.
And finally the InfoListControl
needs to handle a user input (either by mouse or by keyboard):
Private Sub baseDataListView_CellClick(ByVal sender As Object, _
ByVal e As CellClickEventArgs) Handles baseDataListView.CellClick
If Not e.Model Is Nothing AndAlso (e.ClickCount = 2 OrElse _
(e.ClickCount = 1 AndAlso _AcceptSingleClick)) Then
OnValueSelected(e.Model, False)
End If
End Sub
Private Sub baseDataListView_KeyDown(ByVal sender As Object, _
ByVal e As System.Windows.Forms.KeyEventArgs) Handles baseDataListView.KeyDown
If e.KeyData = Keys.Enter AndAlso Not baseDataListView.SelectedItem Is Nothing _
AndAlso Not baseDataListView.SelectedItem.RowObject Is Nothing Then
OnValueSelected(baseDataListView.SelectedItem.RowObject, False)
e.Handled = True
ElseIf e.KeyData = Keys.Back Then
If _FilterString <> "" Then
_FilterString = _FilterString.Substring(0, _FilterString.Length - 1)
baseDataListView.AdditionalFilter = _
TextMatchFilter.Contains(baseDataListView, _FilterString)
End If
e.Handled = True
ElseIf e.KeyData = Keys.Delete Then
If _FilterString <> "" Then
_FilterString = ""
baseDataListView.AdditionalFilter = _
TextMatchFilter.Contains(baseDataListView, _FilterString)
End If
e.Handled = True
ElseIf e.KeyData = Keys.Escape Then
OnValueSelected(Nothing, True)
e.Handled = True
End If
End Sub
Private Sub baseDataListView_KeyPress(ByVal sender As Object, _
ByVal e As System.Windows.Forms.KeyPressEventArgs) Handles baseDataListView.KeyPress
If Not Char.IsControl(e.KeyChar) AndAlso (Char.IsLetterOrDigit(e.KeyChar) _
OrElse Char.IsPunctuation(e.KeyChar)) Then
_FilterString = _FilterString & e.KeyChar
baseDataListView.AdditionalFilter = _
TextMatchFilter.Contains(baseDataListView, _FilterString)
End If
End Sub
The code is self explanatory. The standard event handles evaluates user input and either raises ValueSelected
event to indicate user selection or modify filter string
:
- Letters, digits or punctuation mark keys are added to the filter
string
- BackSpace key removes last
char
in the filter string
- Delete key clears the filter
string
- Enter key raises
ValueSelected
event with the current selected object - Escape key raises
ValueSelected
event with a null
object and cancellation flag set to true
- Single mouse click raises
ValueSelected
event with the clicked object if the AcceptSingleClick
is set to TRUE
- Double mouse click raises
ValueSelected
event with the clicked object
From the external point of view (a point of view of the parent ToolStripControlHost
control), the InfoListControl
is a Control
that exposes 4 custom properties and 1 custom event. The contained DataListView
control is also accessible from an external object however normally it will only be configured using a VS designer.
Creating ObjectListViewToolsStrip
The essential part of the control is a ObjectListViewToolStrip
class that inherits ToolStripControlHost
. It is an internal class (only used internally by the ObjectListViewComboBox
) and handles the InfoListControl
:
- provides a proxy property to the
FilterString
property of the encapsulated InfoListControl
- provides a proxy access method
GetDataSource
to the DataSource
property of the encapsulated InfoListControl
- handles
ValueSelected
event of the encapsulated InfoListControl
and encapsulates the current selected value (SelectedValue
) and the cancellation flag (SelectionCanceled
) - provides standard properties for the drop down size (
MinDropDownWidth
and DropDownHeight
)
Specific ObjectListViewToolStrip
methods are the constructor and the methods that wire the events of the encapsulated InfoListControl
:
Public Sub New(ByVal listView As InfoListControl)
MyBase.New(listView)
Me.AutoSize = False
Me._MinDropDownWidth = listView.Width
Me._DropDownHeight = listView.Height
End Sub
Private Sub OnDataListViewValueSelected(ByVal sender As Object, _
ByVal e As InfoListControl.ValueChangedEventArgs)
_SelectedValue = e.SelectedValue
_SelectionCanceled = e.SelectionCanceled
DirectCast(Me.Owner, ToolStripDropDown).Close(ToolStripDropDownCloseReason.ItemClicked)
End Sub
Protected Overrides Sub OnSubscribeControlEvents(ByVal c As Control)
MyBase.OnSubscribeControlEvents(c)
Dim nDataListView As InfoListControl = DirectCast(c, InfoListControl)
AddHandler nDataListView.ValueSelected, AddressOf OnDataListViewValueSelected
End Sub
Protected Overrides Sub OnUnsubscribeControlEvents(ByVal c As Control)
MyBase.OnUnsubscribeControlEvents(c)
Dim nDataListView As InfoListControl = DirectCast(c, InfoListControl)
RemoveHandler nDataListView.ValueSelected, AddressOf OnDataListViewValueSelected
End Sub
Protected Overrides Sub OnBoundsChanged()
MyBase.OnBoundsChanged()
If Not Control Is Nothing Then
DirectCast(Control, InfoListControl).Size = Me.Size
End If
End Sub
The constructor sets the encapsulated InfoListControl
and turns off autosizing leaving the size control to the overridden OnBoundsChanged
method. For unknown reason, built in autosizing methods fail when used inside a ToolStripControlHost
.
Methods OnSubscribeControlEvents
and OnUnsubscribeControlEvents
wires the ValueSelected
event to the OnDataListViewValueSelected
handler. Which in turn persists the ValueSelected
event arguments within SelectedValue
and SelectionCanceled
properties and closes the selection dropdown.
Creating ObjectListViewComboBox
The control itself inherits ComboBox
. It exposes the following public
properties and methods:
HasAttachedInfoList
- Indicates whether an InfoListControl
is already assigned to the control InstantBinding
- Whether to update datasource instantly when the user selects a value (as opposed on validation) SelectedValue
- A currently selected value object or a value of the value object InfoListControl.ValueMember
property (if set) EmptyValueString
- A SelectedValue string
expression (ToString
) that should be displayed as an empty string
(e.g. if you set EmptyValueString="0"
then an integer value 0
will be displayed as an empty string
) FilterString
- A currently applied filter string (a proxy property) InfoListControlDataSource
- A datasource of the nested InfoListControl
(a proxy property) AddDataListView
- Adds a new InfoListControl
to the control, i.e. initializes the control. InfoListControl
cannot be replaced afterwards.
ObjectListViewComboBox
class has a private
variable myDropDown As ToolStripDropDown
, which acts as a container for ObjectListViewToolStrip
. The instance of ObjectListViewToolStrip
itself is created by the AddDataListView
method:
Public Sub AddDataListView(ByVal dataView As InfoListControl)
If Not myListView Is Nothing Then Throw New InvalidOperationException( _
"Error. DataListView is already assigned to the ObjectListViewComboBox.")
myListView = New ObjectListViewToolStrip(dataView)
If myDropDown Is Nothing OrElse myDropDown.IsDisposed Then
myDropDown = New ToolStripDropDown()
myDropDown.AutoSize = False
myDropDown.GripStyle = ToolStripGripStyle.Visible
AddHandler myDropDown.Closed, AddressOf ToolStripDropDown_Closed
Else
myDropDown.Items.Clear()
End If
myDropDown.Items.Add(myListView)
myDropDown.Width = Math.Max(Me.Width, myListView.MinDropDownWidth)
myDropDown.Height = myListView.Height
End Sub
ObjectListViewComboBox
handles showing the dropdown by overriding WndProc
and intercepting messages. The current implementation of this method is copied from CodeProject article Flexible ComboBox and EditingControl and should be changed in case manual entry support is needed because it captures clicks in all the areas of combobox
thus preventing text entry.
Private Const WM_LBUTTONDOWN As UInt32 = &H201
Private Const WM_LBUTTONDBLCLK As UInt32 = &H203
Private Const WM_KEYF4 As UInt32 = &H134
Protected Overrides Sub WndProc(ByRef m As Message)
If m.Msg = WM_KEYF4 Then
Me.Focus()
Me.myDropDown.Refresh()
If Not Me.myDropDown.Visible Then
ShowDropDown()
Else
myDropDown.Close()
End If
Return
End If
If m.Msg = WM_LBUTTONDBLCLK OrElse m.Msg = WM_LBUTTONDOWN Then
If Not Me.myDropDown.Visible Then
ShowDropDown()
Else
myDropDown.Close()
End If
Return
End If
MyBase.WndProc(m)
End Sub
ObjectListViewComboBox
method that actually shows the dropdown essentially deals with the dropdown sizing, positioning and selecting the appropriate DataListView
row (which holds the current SelectedValue
):
Private Sub ShowDropDown()
If Not myDropDown Is Nothing AndAlso Not Me.myListView Is Nothing Then
If Not myDropDown.Items.Contains(Me.myListView) Then
myDropDown.Items.Clear()
myDropDown.Items.Add(Me.myListView)
End If
myDropDown.Width = Math.Max(Me.Width, Me.myListView.MinDropDownWidth)
myListView.Size = myDropDown.Size
myListView.SetSelectedValue(_SelectedValue)
myDropDown.Show(Me, CalculatePoz)
SendKeys.Send("{down}")
End If
End Sub
Private Function CalculatePoz() As Point
Dim point As New Point(0, Me.Height)
If (Me.PointToScreen(New Point(0, 0)).Y + Me.Height + Me.myListView.Height) _
> Screen.PrimaryScreen.WorkingArea.Height Then
point.Y = -Me.myListView.Height - 7
End If
Return point
End Function
ObjectListViewComboBox
handles setting the current value by overloading SelectedValue
property (to bypass native ComboBox
logic) and providing custom setter method that enables setting value object by ValueMember
.
Private Sub ToolStripDropDown_Closed(ByVal sender As Object, _
ByVal e As ToolStripDropDownClosedEventArgs)
If e.CloseReason = ToolStripDropDownCloseReason.ItemClicked _
AndAlso Not myListView Is Nothing AndAlso Not myListView.SelectionCanceled Then
If Not MyBase.Focused Then MyBase.Focus()
SetValue(myListView.SelectedValue)
If _InstantBinding Then
For Each b As Binding In MyBase.DataBindings
b.WriteValue()
Next
End If
End If
End Sub
Private Sub SetValue(ByVal value As Object)
If value Is Nothing Then
Me.Text = ""
ElseIf Not _EmptyValueString Is Nothing AndAlso _
Not String.IsNullOrEmpty(_EmptyValueString.Trim) AndAlso _
value.ToString.ToLower.Trim = _EmptyValueString.Trim.ToLower Then
Me.Text = ""
Else
Me.Text = value.ToString
End If
_SelectedValue = value
MyBase.OnSelectedValueChanged(New EventArgs)
End Sub
As you can see from the code above, ObjectListViewComboBox
also has a custom property InstantBinding
implemented. It is not necessary by itself, but in some cases, it is good to have bindings updated not on validated but on actual value change.
That is pretty much all, enjoy the ObjectListView
!