XList 服务器控件






4.74/5 (15投票s)
2005年8月24日
11分钟阅读

128855

1833
一个列表控件,结合了 ListBox 和 DropDownList,支持 <optgroup> 标签,并允许(仅限 IE)在 ListBox 模式下水平滚动。
引言
从我开始接触 HTML 开始,我就一直对 <select> 元素缺乏水平滚动条感到恼火。所以我尝试将 <select> 放到一个 <div> 里,然后花了一些时间调整 JavaScript,就得到了一个解决方案。
第一个浮现出来的问题是,如果你用方向键向下选择列表项,你可能会滚出 <div> 的底部。没有任何东西能让选中的项保持在 <div> 的可见区域内。我也在 JavaScript 中解决了这个问题。
然后就是转向 ASP.NET。我希望将我的解决方案封装成一个服务器控件,但我想,既然我要这么做,我不如顺便修正 Microsoft 的 ListBox
服务器控件中三个更令人恼火的方面:
ListBox
和DropDownList
,尽管在几乎所有方面都是同一个控件,但它们是独立的服务器控件。ListBox
(以及DropDownList
)不支持 <optgroup> 标签,而这是 <select> 元素对象模型的一部分。ListBox
(和DropDownList
)存在一个 Microsoft 称之为“按设计”的 bug。你可以为ListItem
添加Attributes
,但是- 它们不会渲染,并且
- 它们不会保存在 ViewState 中。
这就引出了一个问题,为什么它们一开始要在
ListItem
类中提供一个Attributes
属性。在我看来,它们似乎开始实现了,但没有完成。
合并 ListBox 和 DropDownList
Microsoft 的 ListControl
被用作 ListBox
、DropDownList
、CheckBoxList
和 RadioButtonList
的基础。我不得不推测,由于后两个在页面上渲染时没有使用 <select> 作为 HTML 元素,它们就决定单独处理所有控件。因为 ListBox
和 DropDownList
之间的区别很小。
正如我以前做过的,我使用了 Lutz Roeder 的 Reflector 来获取构成 ListBox
控件的代码。这是必要的,因为 ListItem
不可继承,稍后你会看到我们需要对该类进行一些修改。
ListBox
继承自 ListControl
,DropDownList
也是如此。我检查了两者之间的区别,并简单地让这些区别依赖于我创建的一个 Enum
。
Public Enum XListType
ListBox = 0
DropDownList = 1
End Enum 'XListType
我在 XList
中基于这个 Enum
创建了一个属性。
<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
你可以查看我包含在源代码中的 OpenList
,了解我在何处根据此属性使代码条件化。例如,在 AddAttributesToRender
重写中,有一段代码与 SelectionMode
属性有关,而该属性仅与 ListBox
相关。在 DropDownList
中你无法进行多选。
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
正如你所见,我只是将这段代码包装在一个条件语句中,以便仅当 XList
控件处于 ListBox
模式时运行。
还有一些其他的绊脚石。原始代码中的一些引用在其他类中标记为 Friend
的方法。这对于 Microsoft 的控件来说没问题,因为它们总是在同一个程序集中。但我的控件在单独的程序集中,所以我得到了一个错误,告诉我该方法不可访问。
我真的不想重写 Page
控件,这是第一个引起我这个问题的控件。所以我使用了 Reflection 来处理它。
OnPreRender
重写包含这行代码:
Me.Page.RegisterPostBackScript()
我用这个替换了它:
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
在处理自定义控件时,你可能会经常遇到这个问题。这是解决办法。
此外,Microsoft 的代码反复使用了 TryCast
方法。我明白这将在 .NET 的下一个版本中提供,但这对我没有帮助。所以我不得不将这些实例转换为我实际上可以使用。的代码。
例如,在 OnDataBinding
重写中,出现了这行代码:
Dim collection1 As ICollection = _
TryCast(enumerable1, ICollection)
我用这个替换了它:
Dim collection1 As ICollection
If TypeOf enumerable1 Is ICollection Then
collection1 = CType(enumerable1, ICollection)
Else
collection1 = Nothing
End If
因为这本质上就是 TryCast
所做的。如果可以,它会转换为你想要的 Type
,如果不行,它会返回 Nothing
。
这就是我合并这两个控件所做的大部分工作。正如我所说,你可以在源代码中找到它,名为 OpenList
。
添加 <optgroup> 支持
ListBox
和 DropDownList
是独立控件,这其实不是什么大问题。但对于像我这样懒惰的人来说,为同一个修改而修改两个控件的前景似乎毫无意义。而且,由于 ListBox
和 DropDownList
都应该支持 <optgroup> 标签,合并它们为我节省了时间和精力。
首先,我不得不决定如何实现。在 XTable
控件中,我添加了对 <thead>、<tbody> 和 <tfoot> 标签的支持,我决定在 Table
和 TableRow
之间添加一个层次结构,我称之为 TableRowGroup
。我曾考虑在这里做同样的事情;让 XListItem
成为 OptGroup
类的子项,并在 XList
中有一个 OptGroupCollection
类。
幸运的是,我在那样做之前恢复了理智,决定仅仅在 XListItem
中添加一个 OptGroup
属性。这将允许控件的数据绑定功能像以前一样继续,而让 XListItem
位于单独的 OptGroup
中会让数据绑定变得一团糟。我可以在 XList
的 RenderContents
方法中处理 <optgroup> 标签。所以我创建了这个 XListItem
中的属性:
<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
你可能会想这是什么?
Me._misc.Set(4, True)
看起来 Microsoft 的程序员决定在他们的 ListItem
类中放置一个全局的 BitArray
变量。他们在类中放置了 Const
声明,用于标识这个 BitArray
中各个元素所表示的内容。
Private Const _SELECTED As Integer = 0
Private Const _MARKED As Integer = 1
Private Const _TEXTISDIRTY As Integer = 2
Private Const _VALUEISDIRTY As Integer = 3
出于某种原因,他们选择不使用这些常量,而是直接使用它们的数值,但这有助于我了解 _misc
中的项的含义。我添加了以下内容:
Private Const _OPTGROUPISDIRTY As Integer = 4
因此,当通过 Set
访问器修改 OptGroup
属性时,它会将 XListItem
标记为已修改。Text
和 Value
属性已经为 _misc(2)
和 _misc(3)
提供了此功能。
我还为 OptGroup
添加了一些自定义状态管理代码。在 LoadViewState
和 SaveViewState
中,我将 state
对象替换为 Object()
,并让 state(0)
替换了用于 _misc
状态的 state
变量。我使用 state(1)
来存储 OptGroup
属性。
在 XList
的 RenderContents
重写中,我在渲染每个 <option> 标签之前立即添加了这段代码:
'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
完成这些后,XList
控件就能毫无问题地支持 <optgroup> 标签了。但我认为,将数据绑定功能扩展到使用 OptGroup
以及 Text
和 Value
来填充控件可能会更好。所以我添加了以下两个属性:
<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
我修改了各种涉及数据绑定的方法,以包含这些属性。你可以查看源代码,了解具体是如何实现的。
啊,我的属性们,你们都去哪儿了……?
我不知道为什么 Microsoft 决定不支持 ListItem
的 Attributes
。我在我的 <select> 控件中使用 background-color
和 color
等样式。我甚至有时使用 label
属性来存储数据库中的额外数据。令我非常恼火的是,Microsoft 知道这项功能缺失,甚至不承认这是一个 bug。
所以我决定绕过它。你可能会认为 Attributes
属性已经保留了状态,因为它看起来是这样的:
<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
毕竟,New StateBag
的意义何在,如果它实际上不维护状态?但它显然不这样做,我不得不另辟蹊径。我不能仅仅在 SaveViewState
中保存 Attribute
属性,并在 LoadViewState
中加载它,因为 AttributeCollection
是不可序列化的(这也许是它一开始就不维护状态的原因,现在想想)。所以我决定使用一个 Pair
数组。我本可以用一个二维的 String
数组,但我觉得差别不大,而且我很少有机会玩 Pair
。
首先,我添加了一个新的全局常量,并将 _misc
改为 BitArray(6)
:
Private Const _ATTRIBUTESISDIRTY As Integer = 5
然后,我在 IAttributeAccessor.SetAttribute
方法中添加了这一行,以便让属性更改使 XListItem
变得“脏”,就像其他属性一样:
Me._misc.Set(5, True)
在那之后,就只是修改 SaveViewState
和 LoadViewState
了,如下所示:
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
我将 Pair
数组加载到 XListItem
的 Attributes
属性中。
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
我将 Attributes
从 XListItem
复制到一个 Pair
数组中,并将其保存到 XListItem
的 ViewState 中。
让 ListItem
的 Attributes
保持状态所需的更改微不足道,我实在不明白为什么 Microsoft 的控件不这样做。
水平滚动
之前的更改都是跨浏览器兼容的。这个不是。
XList
控件可以在任何浏览器中使用,但启用 EnableHScroll
会使其在大多数非 IE6+ 浏览器中无法工作。
<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
ListBox
中并不总是设置 Height
和 Width
属性。通常,Height
会被忽略,而由 Size
代替,Size
以 ListItem
的高度为单位设置 ListBox
的高度。当使用 Width
时,它通常设置得相当宽,以免截断任何过长的 ListItem
。
对于我们带滚动的 XList
,需要 Height
和 Width
。所以我们在构造函数中添加了默认值,如下:
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
我们重写了 Height
和 Width
属性,以确保它们永远不为空。
<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
将 <div>
放在控件周围意味着要重写 RenderBeginTag
,就像我们在 XTable
中所做的一样。同样,我们使其条件化,因为只有当 EnableHScroll
为 True
时才需要它。并且我们以类似的方式修改 RenderEndTag
:
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
除了我用于修改 RenderBeginTag
的标准代码外,我还为 <select>
添加了两个事件:
MyBase.Attributes.Add("onchange", "javascript:XList_ShowOption(this);")
MyBase.Attributes.Add("onresize", _
"javascript:XList_ResizeSelect(this);XList_ShowOption(this);")
这些事件触发使控件正常工作的一个或两个函数。XList_ResizeSelect
使 <select>
具有适当的大小。如果 <select>
的宽度小于 <div>
的宽度,该函数会增加 <select>
的宽度,直到它填满 <div>
的可见空间。高度同理。如果 <select>
中的 <option>
元素数量在 <select>
底部和 <div>
底部之间留有空间,该函数会增加 <select>
的高度以适应。否则,<select>
的大小将设置为 <select>
中 <option>
元素的总数。
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";
}
}
一个实用函数在页面加载时计算滚动条的宽度。我从 Scott Isaac 关于 DHTML 滚动条的文章 中借鉴了这项有用技术。
我使用了 resize
事件来触发此操作,因为它最合理。如果你在客户端添加或删除 <select>
中的 <option>
,你应该在自己的客户端代码中调用 XList_ResizeSelect
来确保 <select>
根据其新内容进行调整。大小。
XList_ShowOption
稍微复杂一些。正如我在本文开头提到的(还记得很久以前吗?<grin>),如果你使用此技术并向下滚动到 <div>
的底部,你选择的 <option>
将会消失。所以我需要找到一种方法来使 <div>
滚动,以便选中的 <option>
保持可见。如果能使用 scrollIntoView
方法就好了,但该方法不适用于 <option>
元素。
这是使 <option>
进入视区的代码。我将计算分开以使其更易于理解(并且在我最初处理它时更容易调试):
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;
}
}
这两个函数,以及计算滚动条宽度的实用程序,都会在页面上编写一次,而不管页面上有多少个 XList
实例。我为此使用了 RegisterClientScriptBlock
。但我们还需要对脚本做另一件事,而且它必须为每个实例单独完成,这就是 RegisterStartupScript
的作用。
页面加载时,需要调整 <select>
的大小。这必须为控件的每个实例发生。此外,如果控件的 SelectedIndex
大于 -1,则页面必须将正确的 <option>
标记为选中,并运行 XList_ShowOption
,以确保其可见。你可以在源代码中看到正在注册的脚本。
然而,在设计环境中,脚本不会运行。所以我稍微修改了 XListDesigner
。GetDesignTimeHtml
方法在两个地方调用 MyBase.GetDesignTimeHtml
。我用对新创建的名为 GetDesignTimeResize
的方法的调用替换了这些调用。这是修改后的 GetDesignTimeHtml
和新的 GetDesignTimeResize
:
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
我唯一觉得需要做的事情是在 PreFilterProperties
的末尾添加几行代码,以确保 DataOptGroupField
被视为与 DataTextField
和 DataValueField
相同。
结论
差不多就是这样。该控件有三种模式:DropDownList
、ListBox
、滚动 ListBox
,在每种情况下,你都可以选择是否渲染 OptGroup
。所以它有点像六合一控件。