Introduction
Back when I was doing plain old ASP (ah, those were the days), I fooled around with creating a DHTML tab control like the kind I used to use in VB6. Multiple rows of tabs which rearranged themselves as you clicked them, and that sort of thing.
When I started programming in ASP.NET, I found that things weren't quite as flexible. I wanted the convenience of a tab control, but all I could find were Microsoft's orphaned TabStrip
and MultiPage
controls, which have to be installed on the server, which use .htc files, which have to be dealt with separately, and which really aren't very friendly.
I like friendly controls.
I looked all over the Internet and found nothing even remotely similar to what I was looking for, so I decided to make one myself.
Like the XTable
control, this one is probably not cross-browser compatible. But then, I haven't actually tried it on other browsers, so it might surprise me.
False Starts
The XTabControl
is the third tab control I built. The first two were horrendous, kludgy things. One of the interesting things that I learned had to do with collections.
Microsoft often recommends that people have their collections inherit from CollectionBase
. Don't do that if you want the elements of your collection to be able to contain other controls. That was what I did my first time though, and while the controls on each tab showed up nicely, they weren't in the control hierarchy, and they got ignored on postback. I tried using metaphorical twine and duck tape to force the controls to maintain their state and for their data to come through, and I was marginally successful. But I could not, for love or money, get them to raise their events on the server.
Finally, I decided to cannibalize code that I already knew worked.
That's More Like It
I had used Lutz Roeder's Reflector to obtain an open copy of the code for the standard Microsoft Table
server control, when I was making the XTable
control. I've included this in the source code as OpenTable
.
The way I figured it, controls put into TableCell
s don't have the problem I was running into, so I could use that code as a base for my tab control. Of course, the Table
control has a three-level hierarchy (Table
-> TableRow
-> TableCell
), and I only needed two levels (XTabControl
, XTab
), so I based my control on the TableRow
class.
The architecture was entirely different from what I'd tried before. The TableCellCollection
class, which became my XTabCollection
, was defined as a class of its own, but the TableRow
class, which became my XTable
class, also contained a nested CellControlCollection
, which became my XTabControlCollection
. The terminology got a little confusing, because it sounded as though this was a collection of XTabControl
s, but I figured since it was nested within XTabControl
, it would be clear enough.
XTabCollection
didn't inherit from anything, but did implement IList
, ICollection
and IEnumerable
. XTabControl.XTabControlCollection
inherited from ControlCollection
, which made sure that each XTab
, and all the child controls of each XTab
, would be part of the control hierarchy.
Design and Properties
One of the things that really irked me about the VB6 TabControl
was the inability to change the background color of the tabs. I was determined not to make the same mistake with my control.
Some of the properties were obvious, like Tabs
and SelectedIndex
and SelectedTab
. Others were less so. The non-obvious properties I decided on were:
XTabControl.TabsPerRow
XTabControl.TabHeight
XTabControl.TabFontFamily
XTabControl.TabFontSize
XTab.BackColor
XTab.ForeColor
XTab.InnerWidth
XTab.InnerHeight
The TabsPerRow
property was something I remembered from the VB6 TabControl
. TabHeight
would determine the height of all the XTab
s. This had to be global, because having XTab
s of varying heights would have been, if not impossible, certainly unnecessarily complicated. I probably could have put the TabFontFamily
and TabFontSize
properties on individual XTab
s, but I thought that would be ugly.
Aside from allowing the user to choose the color of each tab, both ForeColor
and BackColor
, I decided that the space available on the XTab
shouldn't be limited by the dimensions of the XTabControl
. By creating InnerWidth
and InnerHeight
properties on each XTab
, it's possible to use more or less space than is available. The demo project demonstrates this, as does the GIF at the beginning of this article.
Of course, the most complex issues were sizing and fitting the tabs into their correct default positions, and allowing them to move as the user clicks them. On a standard tab control, if you click on a tab in a back row, that entire row drops down to be the row immediately above the tab pages, and I needed to emulate this behavior.
For the positioning and sizing of the tabs, I wrote a method which calculated the width required for each tab in full rows (rows which contained the full allowable number of tabs), and that required for each tab in the partial row, if there was one, at the back.
For example, if TabsPerRow
is set to 3, and there are 5 XTab
s in the control, there would be one full row of 3 XTab
s and one partial row of 2 XTab
s. It's fairly math heavy. Then I created a jagged array of Pair
s to hold references to the correct XTab
s. For each Pair
, First
would hold the XTab.Index
value, and Second
would hold the width of that XTab
. During rendering, I simply read out of this array.
Private Sub GetTabRows(ByRef _rowArray As Pair()(), _
ByRef _selectedRow As Integer, _
ByRef _tabPageHeight As Unit)
Dim _allTabCount As Integer = Tabs.Count
Dim _showTabCount As Integer = _allTabCount
Dim _fullRows As Integer
Dim _partRowTabWidth As Unit
Dim _widthValue As Integer = CInt(Width.Value)
If Not HttpContext.Current Is Nothing Then
Dim _visibleTabCount As Integer = 0
For Each _tab As XTab In Tabs
If _tab.Visible Then
_visibleTabCount += 1
End If
Next _tab
_showTabCount = _visibleTabCount
End If
Dim _tabsInFullRow As Integer = TabsPerRow
Dim _fullRowTabWidth As Unit = _
Unit.Pixel(CInt(Math.Floor(_widthValue / _
CDbl(_tabsInFullRow))))
Dim _tabRows As Integer = _
CInt(Math.Ceiling(CDbl(_showTabCount) / _
CDbl(_tabsInFullRow)))
If _tabRows * _tabsInFullRow = _showTabCount Then
_fullRows = _tabRows
Else
_fullRows = _tabRows - 1
End If
Dim _tabsInPartRow As Integer = _showTabCount - _
(_fullRows * _tabsInFullRow)
If _tabsInPartRow > 0 Then
_partRowTabWidth = _
Unit.Pixel(CInt(Math.Floor(_widthValue / _
CDbl(_tabsInPartRow))))
Else
_partRowTabWidth = _fullRowTabWidth
End If
Dim _fullRowRemainder As Integer = _
CInt(_widthValue - _fullRowTabWidth.Value * _tabsInFullRow)
Dim _partRowRemainder As Integer = _
CInt(_widthValue - _partRowTabWidth.Value * _tabsInPartRow)
_tabPageHeight = Unit.Pixel(CInt(Height.Value - _
(_tabRows * TabHeight.Value)))
ReDim _rowArray(_tabRows - 1)
For i As Integer = 0 To _tabRows - 1
If _fullRows < _tabRows And i = 0 Then
ReDim _rowArray(i)(_tabsInPartRow - 1)
For j As Integer = 0 To _tabsInPartRow - 1
If j = 0 Then
_rowArray(i)(j) = New Pair(-5, _
Unit.Pixel(CInt(_partRowTabWidth.Value _
+ _partRowRemainder)))
Else
_rowArray(i)(j) = New Pair(-4, _partRowTabWidth)
End If
Next j
Else
ReDim _rowArray(i)(_tabsInFullRow - 1)
For j As Integer = 0 To _tabsInFullRow - 1
If j = 0 Then
_rowArray(i)(j) = New Pair(-3, _
Unit.Pixel(CInt(_fullRowTabWidth.Value + _
_fullRowRemainder)))
Else
_rowArray(i)(j) = New Pair(-2, _fullRowTabWidth)
End If
Next j
End If
Next i
Dim _tabCollectionCounter As Integer = 0
Dim _tabCounter As Integer = 0
Dim _rowCounter As Integer = _tabRows - 1
Do While _tabCollectionCounter < _allTabCount
If HttpContext.Current Is Nothing Or _
Tabs(_tabCollectionCounter).Visible Then
_rowArray(_rowCounter)(_tabCounter).First = _tabCollectionCounter
If _tabCollectionCounter = SelectedIndex Then
_selectedRow = _rowCounter
End If
_tabCounter += 1
If _tabCounter = _tabsInFullRow Then
_rowCounter -= 1
_tabCounter = 0
End If
End If
_tabCollectionCounter += 1
Loop
End Sub
I wanted the developer to be able to choose whether or not the XTabControl
would postback when a user selects an XTab
. This meant having a client-side way to move from one tab to another. I embedded a VBScript file in my project, which is written to the page when it loads.
function XTabControl_SelectTab(obj)
set objRow = obj.parentElement.parentElement
set objTable = objRow.parentElement
set objMasterPage = objTable.rows(objTable.rows.length-1).cells(0).children(0)
TabControlName = left(obj.name, instrrev(obj.name, "tab_") - 2)
helperControlName = "__" & TabControlName & "_State__"
set objHelper = document.getElementById(helperControlName)
tabIndex = mid(obj.name, instrrev(obj.name, "tab_") + 4)
selectedPanelName = TabControlName + "_panel_" + tabIndex
for each i in objMasterPage.children
if i.name = selectedPanelName then
i.style.display = "inline"
else
i.style.display = "none"
end if
next
objMasterPage.style.backgroundColor = obj.style.backgroundColor
objTable.moveRow objRow.rowIndex, objTable.rows.length-2
for each myTab in objRow.cells(0).children
if myTab.name = obj.name then
myTab.style.borderBottom = "none"
else
myTab.style.borderBottom = "3px inset"
end if
next
if objTable.rows.length > 2 then
for rowIdx = 0 to objTable.rows.length-3
for each myTab in objTable.rows(rowIdx).cells(0).children
myTab.style.borderBottom = "none"
next
next
end if
objHelper.value = tabIndex
set objRow = Nothing
set objTable = Nothing
set objMasterPage = Nothing
set objHelper = Nothing
end function
I decided to use inline CSS for my styles, rather than stylesheets. You can't control what other stylesheets may be on a page, and rather than risk conflicting names of styles, I went with the inline method.
During rendering, I used the following code to determine how the control would behave when clicked:
If _autoPostBack Then
writer.AddAttribute(HtmlTextWriterAttribute.Onclick, _
"jscript:" + ClientHelperID + ".value=" + _tabIdx.ToString _
+ ";" + Page.GetPostBackEventReference(Me, _tabIdx.ToString()), _
False)
writer.AddAttribute("onfocus", "jscript:" + ClientHelperID + _
".value=" + _tabIdx.ToString + ";" + _
Page.GetPostBackEventReference(Me, _tabIdx.ToString()), False)
Else
writer.AddAttribute(HtmlTextWriterAttribute.Onclick, _
"vbscript:XTabControl_SelectTab(me)", False)
writer.AddAttribute("onfocus", _
"vbscript:XTabControl_SelectTab(me)", False)
End If
The HelperID
referred to in this snippet is the hidden control on the page which holds the XTabControl
's SelectedIndex
value. I used this in XTable
to store the scroll positions, and it's cribbed brazenly from the innards of Microsoft's non-supported TabStrip
control, as is a lot of the mechanics surrounding the SelectedIndex
property in general.
Known Issues
One of the more annoying things about VS.NET is that contained child controls can be overlooked. If you add a TextBox
to a TableCell
, for instance, you can no longer edit the TextBox
properties in design view. You can see the TextBox
, but clicking on it only selects the Table
it's in. Similarly, if you add a TextBox
(or any other control, for that matter) to a TableCell
in HTML view, VS.NET will not automatically add a Protected WithEvents
declaration for the TextBox
in the "Web Form Designer Generated Code" region of the code-behind.
This irksome behavior, of course, extends to XTabControl
. When Microsoft fixes this on their end, I hope that it will automatically fix the problem for XTabControl
. Meanwhile, it's nothing you're not probably used to.
I considered trying out the ReadWriteControlDesigner
class, but decided that I wasn't feeling frisky enough. And the way the control is built, using that for my designer might allow users to fiddle with the composition of the control, so I'm wary of trying this. But if anyone out there wants to give it a whirl, I'd be happy to hear what the results were. If they're good, I might do the same for XTable
.