Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / HTML

The XList Server Control

4.74/5 (18 votes)
23 Aug 200511 min read 1   1.8K  
A List control that combines ListBox and DropDownList, supports <optgroup> tags, and allows (in IE only) for horizontal scrolling in ListBox mode.

Sample Image - xlist.gif

Introduction

From the time I started playing with HTML, I was annoyed by the lack of a horizontal scrollbar on the <select> element. So I played around with putting the <select> into a <div>, and after fiddling with the JavaScript for a while, I had a solution.

The first problem that came to light was that if you used your arrow keys to cursor down the list, you could disappear off the bottom of the <div>. Nothing made the selected item stay within the visible area of the <div>. I fixed that in the JavaScript as well.

And then came the move to ASP.NET. I wanted to encapsulate my solution in a server control, but I figured that as long as I was going to do that, I might as well correct three of the more annoying aspects of Microsoft's ListBox server control:

  • ListBox and DropDownList, despite being the same control in almost every way, are separate server controls.
  • ListBox (as well as DropDownList) doesn't support the <optgroup> tag, which is part of the <select> element's object model.
  • ListBox (and DropDownList) share a bug that Microsoft calls "by design". You can add Attributes to ListItems, but:
    1. they don't render, and
    2. they don't get saved in ViewState.

    Which begs the question of why they bother with an Attributes property in the ListItem class in the first place. To me, it looks like they started to implement it, but never got around to finishing it.

Combining ListBox and DropDownList

The Microsoft ListControl is used as a basis for ListBox, DropDownList, CheckBoxList and RadioButtonList. I have to assume that since the last two don't use <select> as the HTML element that gets rendered on the Page, they just decided to do all of them separately. Because the difference between ListBox and DropDownList are minor.

As I've done before, I used Lutz Roeder's Reflector to grab the code that makes up the ListBox control. This is necessary, because ListItem is not inheritable, and you'll see later on how we need to make some modifications in that class.

ListBox inherits from ListControl, and so does DropDownList. I checked to see what the differences were between the two, and simply made those differences dependent upon an Enum I created.

VB
Public Enum XListType
   ListBox = 0
   DropDownList = 1
End Enum 'XListType

I created a property in XList based on this Enum:

VB
<DefaultValue(0), Category("Behavior"), Description("XList_XListType")> _
Public Overridable Property XListType() As XListType
   Get
      Dim obj1 As Object = Me.ViewState("XListType")
      If (Not obj1 Is Nothing) Then
         Return CType(obj1, XListType)
      End If
      Return XListType.ListBox
   End Get
   Set(ByVal value As XListType)
      If ((value < XListType.ListBox) OrElse (value > _
                          XListType.DropDownList)) Then
         Throw New ArgumentOutOfRangeException("value")
      End If
      Me.ViewState("XListType") = value
   End Set
End Property 'XListType

You can look at OpenList, which I've included in the source code, to see all the places I made the code conditional on this property. For example, in the AddAttributesToRender override, there was a bit of code having to do with the SelectionMode property, which is only relevant to the ListBox. You can't have multiple selections in a DropDownList.

VB
If Me.XListType = XListType.ListBox Then
   writer.AddAttribute(HtmlTextWriterAttribute.Size, _
          Me.Rows.ToString(NumberFormatInfo.InvariantInfo))
   If (Me.SelectionMode = ListSelectionMode.Multiple) Then
      writer.AddAttribute(HtmlTextWriterAttribute.Multiple, "multiple")
   End If
End If

As you can see, I simply wrapped this piece of code in a conditional statement, so that it only runs if the XList control is in ListBox mode.

There were a few other stumbling blocks. Some of the original code references methods in other classes which are marked Friend. This is fine for Microsoft's controls, since they're always going to be in the same assembly. But my control is in a separate assembly, so I got an error, telling me that the method was inaccessible.

I really didn't want to have to recreate the Page control, which was the first control that caused me this problem. So I used Reflection to handle it.

The OnPreRender override contained this line of code:

VB
Me.Page.RegisterPostBackScript()

I replaced it with this:

VB
Dim methodInfo As methodInfo = _
  Me.Page.GetType.GetMethod("RegisterPostBackScript", _
  BindingFlags.Instance Or BindingFlags.NonPublic)
If Not (methodInfo Is Nothing) Then
   methodInfo.Invoke(Me.Page, New Object() {})
End If

When you're working with custom controls, you may find yourself running into this problem frequently. This is the way around it.

Also, the Microsoft code used the TryCast method over and over. I understand that this will be available in the next version of .NET, but that didn't help me. So I had to convert those instances into code I could actually use.

For example, in the OnDataBinding override, this line appeared:

VB
Dim collection1 As ICollection = _
          TryCast(enumerable1, ICollection)

I replaced it with this:

VB
Dim collection1 As ICollection
If TypeOf enumerable1 Is ICollection Then
   collection1 = CType(enumerable1, ICollection)
Else
   collection1 = Nothing
End If

Because that's essentially what TryCast does. If it can, it converts to the Type you want, and if it can't it returns Nothing.

That's about all I did to combine the two controls. As I said, you can find it as OpenList in the source code.

Adding <optgroup> Support

It isn't actually that big a deal that ListBox and DropDownList are separate controls. But when you're as lazy as I am, the prospect of having to modify both controls when it's the same modification just seemed pointless. And since both ListBox and DropDownList really should support the <optgroup> tag, combining them saved me time and work.

First, though, I had to decide how I was going to do this. In the XTable control, where I added support for <thead>, <tbody> and <tfoot> tags, I decided to add a hierarchy between Table and TableRow, which I called TableRowGroup. I considered doing the same thing here; making XListItems be the children of an OptGroup class, and having an OptGroupCollection class in the XList.

Luckily, I regained my sanity before I did that, and decided merely to add an OptGroup property to the XListItems. This would permit the databinding functionality of the control to continue as before, while having XListItems in separate OptGroups would have made that a real mess. And I could deal with the <optgroup> tags in XList's RenderContents method. So I created this property in XListItem:

VB
<DefaultValue("")> _
Public Property OptGroup() As String
   Get
      Return Me._optGroup
   End Get
   Set(ByVal value As String)
      Me._optGroup = value
      If Me.IsTrackingViewState Then
         Me._misc.Set(4, True)
      End If
   End Set
End Property 'OptGroup

You may wonder what this line is:

VB
Me._misc.Set(4, True)

It seems that Microsoft's coders decided to put a global BitArray variable in their ListItem class. They put Const declarations in the class which identifies what the various elements in this BitArray indicate:

VB
Private Const _SELECTED As Integer = 0
Private Const _MARKED As Integer = 1
Private Const _TEXTISDIRTY As Integer = 2
Private Const _VALUEISDIRTY As Integer = 3

For some reason, they chose not to use these constants, instead just using their numeric values, but it was helpful in letting me know what the items in _misc were about. I added the following:

VB
Private Const _OPTGROUPISDIRTY As Integer = 4

So when the OptGroup property is modified through the Set accessor, it marks the XListItem as having been modified. The Text and Value properties already had this for _misc(2) and _misc(3).

I also added some custom state management code for OptGroup. In LoadViewState and SaveViewState, I replaced the state object with an Object(), and let state(0) replace the state variable that was used for the state of _misc. I used state(1) for the OptGroup property.

In XList's RenderContents override, I added this code immediately before the rendering of each <option> tag:

VB
'render optgroups if they're enabled
If Me.EnableOptGroups Then
   Dim sPrevOptGroup As String
   Dim sOptGroup As String = item1.OptGroup
   'if the optgroup has changed, unless it's the first
   'optgroup, end the previous optgroup
   If Not sOptGroup = sPrevOptGroup And Not num2 = 0 Then
      writer.WriteEndTag("optgroup")
      writer.WriteLine()
   End If
   'if it's the first optgroup, or if the optgroup
   'has changed, start a new optgroup
   If Not sOptGroup = sPrevOptGroup Or num2 = 0 Then
      writer.WriteBeginTag("optgroup")
      writer.WriteAttribute("label", sOptGroup)
      writer.Write(">"c)
      writer.WriteLine()
      sPrevOptGroup = sOptGroup
   End If
End If

When this was done, the XList control supported <optgroup> tags without a problem. But I decided that it might be nice to expand the databinding functionality to use OptGroup as well as Text and Value for populating the control. So I added the following two properties:

VB
<Description("XList_DataOptGroupField"), _
     Category("Data"), DefaultValue("")> _
Public Overridable Property DataOptGroupField() As String
   Get
      Dim obj1 As Object = Me.ViewState("DataOptGroupField")
      If (Not obj1 Is Nothing) Then
         Return CType(obj1, String)
      End If
      Return String.Empty
   End Get
   Set(ByVal value As String)
      Me.ViewState("DataOptGroupField") = value
   End Set
End Property 'DataOptGroupField
<Description("XList_DataOptGroupFormatString"), _
               DefaultValue(""), Category("Data")> _
Public Overridable Property DataOptGroupFormatString() As String
   Get
      Dim obj1 As Object = Me.ViewState("DataOptGroupFormatString")
      If (Not obj1 Is Nothing) Then
         Return CType(obj1, String)
      End If
      Return String.Empty
   End Get
   Set(ByVal value As String)
      Me.ViewState("DataOptGroupFormatString") = value
   End Set
End Property 'DataOptGroupFormatString

I modified the various methods involved with the databinding to include these properties. You can take a look at the source code to see how that was done.

Oh, Where, Oh, Where, Have My Attributes Gone...?

I have no idea why Microsoft decided not to support ListItem Attributes. I use styles like background-color and color in my <select> controls. I even use the label attribute sometimes, to hold extra data from the database. It was extremely annoying to find that Microsoft knows about this lack of functionality, and won't even admit that it's a bug.

So I decided to workaround them. You'd think that the Attributes property would already preserve state, since it looks like this:

VB
<Browsable(False), _
  DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)> _
Public ReadOnly Property Attributes() As System.Web.UI.AttributeCollection
   Get
      If (Me._attributes Is Nothing) Then
         Me._attributes = New _
           System.Web.UI.AttributeCollection(New StateBag(True))
      End If
      Return Me._attributes
   End Get
End Property 'Attributes

After all, what's the point of the New StateBag if it's not going to actually maintain state? But it clearly doesn't, and I had to find another way to go about this. I couldn't just save the Attribute property in SaveViewState and load it in LoadViewState, because AttributeCollection isn't serializable (which may be why it doesn't maintain state to begin with, now that I think about it). So I decided to use an array of Pairs. I probably could have used a two dimensional array of Strings, but I didn't figure it would make much of a difference either way, and I rarely get to play with Pairs.

First, I added a new global constant and changed _misc to BitArray(6):

VB
Private Const _ATTRIBUTESISDIRTY As Integer = 5

Then I added this line to the IAttributeAccessor.SetAttribute method, in order to let attribute changes make the XListItem dirty just like the other properties:

VB
Me._misc.Set(5, True)

After that, it was simply a matter of modifying SaveViewState and LoadViewState, as follows:

VB
Friend Sub LoadViewState(ByVal state As Object)
   Dim arrState As Object() = CType(state, Object())
   If (Not arrState(0) Is Nothing) Then
      If TypeOf arrState(0) Is Pair Then
         Dim pair1 As Pair = CType(arrState(0), Pair)
         If (Not pair1.First Is Nothing) Then
            Me.Text = CType(pair1.First, String)
         End If
         Me.Value = CType(pair1.Second, String)
      Else
         Me.Text = CType(arrState(0), String)
      End If
   End If
   'custom state management for OptGroup
   If Not arrState(1) Is Nothing Then
      Me.OptGroup = CType(arrState(1), String)
   End If
   'custom state management for Attributes
   If Not arrState(2) Is Nothing Then
      If TypeOf arrState(2) Is Pair() Then
         Dim colAttributes As Pair() = CType(arrState(2), Pair())
         For i As Integer = 0 To colAttributes.Length - 1
            Me.Attributes.Add(colAttributes(i).First.ToString, _
                                colAttributes(i).Second.ToString)
         Next i
      End If
   End If
End Sub 'LoadViewState

I loaded the Pair array into the Attributes property of the XListItem.

VB
Friend Function SaveViewState() As Object
   Dim arrState(2) As Object
   If (Me._misc.Get(2) AndAlso Me._misc.Get(3)) Then
      arrState(0) = New Pair(Me.Text, Me.Value)
   ElseIf Me._misc.Get(2) Then
      arrState(0) = Me.Text
   ElseIf Me._misc.Get(3) Then
      arrState(0) = New Pair(Nothing, Me.Value)
   Else
      arrState(0) = Nothing
   End If
   'custom state management for OptGroup
   arrState(1) = Me.OptGroup
   ''custom state management for Attributes
   If Me.Attributes.Count > 0 Then
      ReDim _attributes2(Me.Attributes.Count - 1)
      Dim i As Integer = 0
  
      Dim keys As IEnumerator = Me.Attributes.Keys.GetEnumerator
      Dim key As String
      While keys.MoveNext()
         key = CType(keys.Current, String)
         _attributes2(i) = New Pair(key, Me.Attributes.Item(key))
         i += 1
      End While
  
      arrState(2) = _attributes2
   End If
   Return arrState
End Function 'SaveViewState

I copied the Attributes from the XListItem into an array of Pairs, and saved that into XListItem's ViewState.

The changes necessary for ListItem Attributes to maintain their state are trivial and minor, and I can't figure out why the Microsoft controls don't do it.

Horizontal Scrolling

The previous changes are all cross-browser compatible. This one isn't.

The XList control can be used in any browser, but turning on EnableHScroll will keep it from working in most non-IE6+ browsers.

VB
<Category("Appearance"), DefaultValue(False), Description("XList_EnableHScroll")> _
Public Overridable Property EnableHScroll() As Boolean
   Get
      Dim obj1 As Object = Me.ViewState("EnableHScroll")
      If (Not obj1 Is Nothing) Then
         Return CType(obj1, Boolean)
      End If
      Return False
   End Get
   Set(ByVal value As Boolean)
      Me.ViewState("EnableHScroll") = value
   End Set
End Property 'EnableHScroll

The Height and Width properties are not always set in a ListBox. Generally, Height is ignored in favor of Size, which sets the height of the ListBox with a unit equal to the height of a ListItem. When Width is used, it is often set fairly wide, so as not to cut off any ListItems that are too long.

With our scrolling XList, Height and Width are required. So we add default values in the constructor, like so:

VB
If Me.EnableHScroll Then
   If Me.Width.IsEmpty Then Me.Width = Unit.Pixel(100)
   If Me.Height.IsEmpty Then Me.Height = Unit.Pixel(100)
End If

And we override the Height and Width properties to ensure that they are never empty:

VB
<Browsable(True), _
  DesignerSerializationVisibility(DesignerSerializationVisibility.Content)> _
Public Overrides Property Width() As Unit
   Get
      If Me.EnableHScroll AndAlso MyBase.Width.IsEmpty Then
         Return Unit.Pixel(100)
      End If
      Return MyBase.Width
   End Get
   Set(ByVal Value As Unit)
      MyBase.Width = Value
   End Set
End Property 'Width

Putting the <div> around the control means overriding RenderBeginTag, just as we did in the XTable. Again, we make it conditional, since we only need it when EnableHScroll is True. And we modify RenderEndTag in a similar way:

VB
Public Overrides Sub RenderEndTag(ByVal writer _
                            As System.Web.UI.HtmlTextWriter)
   MyBase.RenderEndTag(writer)
   If Me.EnableHScroll Then
      writer.RenderEndTag()
   End If
End Sub 'RenderEndTag

In addition to the standard code I used to modify RenderBeginTag, I added two events to the <select>:

VB
MyBase.Attributes.Add("onchange", "javascript:XList_ShowOption(this);")
MyBase.Attributes.Add("onresize", _
         "javascript:XList_ResizeSelect(this);XList_ShowOption(this);")

These events trigger one or both of the functions that make the control work. XList_ResizeSelect makes the <select> the appropriate size. If the width of the <select> is smaller than the width of the <div>, this function widens the <select> until it fills the width of the viewable space in the <div>. Likewise for the height of the <div>. If the number of <option> elements in the <select> leave a space between the bottom of the <select> and the bottom of the <div>, the function increases the height of the <select> to fit. Otherwise, the size of the <select> is set to the full number of <option> elements in the <select>.

JavaScript
function XList_ResizeSelect(objSelect){
   //check to see if the object is visible
   if (objSelect.offsetHeight == 0){
      return;
   }
   //remove the onresize event so that it doesn't loop forever
   objSelect.onresize = null;
   //make sure it's a listbox and not a dropdown
   objSelect.size = objSelect.options.length < 2 ? 2 : _
                                   objSelect.options.length;
   if (objSelect.offsetHeight < _
       objSelect.parentElement.offsetHeight - scrollbarWidth){
     objSelect.style.height = _
       objSelect.parentElement.offsetHeight - scrollbarWidth / 2;
   }
   objSelect.style.width = "";
   if (objSelect.offsetWidth < objSelect.parentElement.offsetWidth_
                                                    - scrollbarWidth){
      objSelect.style.width = (objSelect.parentElement.offsetWidth_
                                           - scrollbarWidth) + "px";
   } else {
      objSelect.style.width = "auto";
   }
}

A utility function calculates the width of a scrollbar when the page loads. I gleaned that useful technique from Scott Isaac's article on DHTML scrollbars.

I used the resize event to trigger this, because it makes the most sense. If you add or remove an <option> from the <select> on the client side, you should call XList_ResizeSelect for the <select> in your own client code to ensure that the <select> gets resized according to its new contents.

XList_ShowOption is a little more complicated. As I mentioned at the beginning of this article (remember way back then? <grin>), if you use this technique and cursor down past the bottom of the <div>, your selected <option> will disappear from sight. So I needed to find a way to keep the <div> scrolling, so that the selected <option> would stay in view. It would have been nice to be able to use the scrollIntoView method, but that method doesn't apply to the <option> element.

Here's the code that makes the <option> move into view. I kept the calculations all separate to make it easier to follow (and easier to debug, when I was originally working on this):

JavaScript
function XList_ShowOption(objSelect){
   idx = objSelect.selectedIndex
   if (idx == -1){
      return;
   }
   if (objSelect.length == 0){
      return;
   }
   objDiv = objSelect.parentElement;
   HeightOfSelect = objSelect.clientHeight;
   OptionsInSelect = objSelect.options.length;
   HeightOfOption = HeightOfSelect / OptionsInSelect;
   HeightOfDiv = objDiv.clientHeight;
   OptionsInDiv = HeightOfDiv / HeightOfOption;
   OptionTopFromTopOfSelect = HeightOfOption * idx;
   OptionTopFromTopOfDiv = OptionTopFromTopOfSelect - objDiv.scrollTop;
   OptionBottomFromBottomOfDiv = HeightOfDiv - _
               OptionTopFromTopOfDiv - HeightOfOption;
   if (OptionTopFromTopOfDiv < 0) {
      objDiv.scrollTop = OptionTopFromTopOfSelect;
   } else if (OptionBottomFromBottomOfDiv < 0 && _
          OptionBottomFromBottomOfDiv > 0 - HeightOfOption) {
      objDiv.scrollTop = objDiv.scrollTop + HeightOfOption;
   } else if (OptionBottomFromBottomOfDiv < 0) {
      objDiv.scrollTop = OptionTopFromTopOfSelect;
   }
}

These two functions, along with the utility that figures out the width of a scrollbar, get written to the page once, regardless of how many instances of XList there are on the page. I use RegisterClientScriptBlock for that. But there's another thing we need to do with script, and it has to be done for each instance separately, which is what RegisterStartupScript is for.

When the page loads, the <select> needs to be resized. This has to happen for each instance of the control. In addition, if the control has a SelectedIndex greater than -1, the page has to mark the correct <option> as selected, and run XList_ShowOption, to make sure that it's visible. You can see the scripts being registered in the source code.

In the design environment, however, scripts don't run. So I modified the XListDesigner slightly. The GetDesignTimeHtml method calls MyBase.GetDesignTimeHtml at two points. I replaced those calls with a call to a new method I created called GetDesignTimeResize. Here is the modified GetDesignTimeHtml, and the new GetDesignTimeResize:

VB
Public Overrides Function GetDesignTimeHtml() As String
   Dim collection1 As XListItemCollection = Me._xList.Items
   If (collection1.Count > 0) Then
      'Return MyBase.GetDesignTimeHtml
      Return GetDesignTimeResize()
   End If
   If Me.IsDataBound Then
      collection1.Add("bound")
   Else
      collection1.Add("unbound")
   End If
   'Dim text1 As String = MyBase.GetDesignTimeHtml
   Dim text1 As String = GetDesignTimeResize()
   collection1.Clear()
   Return text1
End Function 'GetDesignTimeHtml

Public Overridable Function GetDesignTimeResize() As String
   Dim _xList As XList = CType(Component, XList)
   If _xList.EnableHScroll Then
      Dim str As String = MyBase.GetDesignTimeHtml
      str = Replace(str, "<select", _
            "<select style=width:" & _xList.Width.ToString)
      Dim _itemCount As Integer = _xList.Items.Count
      Dim _selectTagEnd As Integer = InStr(str, ">")
      _selectTagEnd = InStr(_selectTagEnd + 1, str, ">")
      Dim _sizeAttribute As Integer = InStr(str, " size=")
      If _sizeAttribute > 0 And _sizeAttribute < _selectTagEnd Then
         str = Replace(str, "size=""4""", "size=""" & _
               IIf(_itemCount < 2, 2, _itemCount).ToString & """")
      Else
         str = Replace(str, "<select", "<select size=""" _
               & IIf(_itemCount < 2, 2, _itemCount).ToString & """")
      End If
      Return str
   Else
      Return MyBase.GetDesignTimeHtml
   End If
End Function 'GetDesignTimeResize

About the only thing I found left to do was to add a few lines at the end of PreFilterProperties to make sure that the DataOptGroupField is treated the same way as the DataTextField and the DataValueField.

Conclusion

That's pretty much it. The control works in three modes: DropDownList, ListBox, Scrolling ListBox, and in each of these cases, you can have OptGroups rendered or not. So it's sort of like six controls in one.

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