When you're working on a Web custom control, all of the magic happens in the Render method. In a nutshell, the Render method outputs the HTML to display the control in a Web browser. It's just like using Response.Write in Classic ASP, except that the render method utilizes an HtmlTextWriter object that the host ASPX page passes to it automatically. Microsoft took care of all of the plumbing for us, so we don't need to worry about writing any extra code in order to consume the control. You just drop the control on a page, and the page and the control figure out how to talk to each other.
Let's examine our Render method:
/// <summary>
/// Writes out the HTML needed to render this control.
/// </summary>
/// <param name="output">The HTML text writer the
we will utilize.
/// This is passed to the control automatically, by the host ASPX
page.</param>
protected override void Render(HtmlTextWriter output)
{
try
{
// No need for ViewState
this.EnableViewState
= false;
// Make sure that the htmlFieldName is set.
GetHtmlFieldName();
// Should the control be visible?
if(this.Visible
== true)
{
// Yes. Render the html.
BuildCategorizedCheckBoxList(output);
}
}
catch(Exception ex)
{
// Something bad happened. Let's tell the
user what that was.
output.Write("Error
building CategorizedCheckBoxList:<br>");
output.Write(ex.Message);
}
}
As you can see, we first disable ViewState support for our control. This will lighten the load of the HTML delivered to the Web browser. Next, we call a method named ReadPostBack, which figures out which checkboxes were checked on the form, if the form was submitted. Finally, if our control is supposed to be visible, we call BuildCategorizedCheckBoxList, which writes the HTML to display the control.
ReadPostBack determines the name that we're assigning to the checkbox fields, and then looks for this field value in the Request.Form collection. If the Web form containing the control posts back, ASP.NET exposes the Form object as a NameValueCollection that contains all of the fields on the form. If our field is not present, it will equal null, otherwise our code within the "if" statement will run.
/// <summary>
/// Retrieves a list of the checkbox values that were selected
(checked), if the form was submitted.
/// This is kind of a poor-man's view state implementation.
/// But unlike view state, it doesn't add anything to the page
weight.
/// </summary>
protected void ReadPostBack()
{
// See what field name we are assigning to the checkboxes
GetHtmlFieldName();
// Were any checkboxes checked?
if(HttpContext.Current.Request.Form[htmlFieldName]
!= null)
{
// Since we assigned the same field name to
all of the checkboxes,
// ASP.NET will give us a comma-delimited
list of the selections.
// First, conver the list to a string
array.
string [] Input =
HttpContext.Current.Request.Form[htmlFieldName].Split(',');
// Then, iterate through the array and add
each value to our ArrayList.
for(int i = 0; i
< Input.Length; i++)
{
selections.Add(Input[i]);
}
}
}
When we build the HTML for the checkbox fields (later), you'll see that we're assigning each field the same name. When ASP.NET populates the Request.Form collection, fields with multiple values are neatly packaged as comma-delimited lists. All that we need to do is convert the delimited list into a string array, and then add each value to our selections variable (which is an ArrayList).
ReadPostBack is also called when the public property, Selections, is accessed. We need to do this because of the sequence of events that fire when a page loads. This permits the hosting page to determine which boxes were checked prior to the control being rendered.
Here is the GetHtmlFieldName method:
/// <summary>
/// Returns the unique field name that we will assogn to the
checkboxes, later.
/// </summary>
protected void GetHtmlFieldName()
{
// Pickup the ID assigned to the control in the consuming ASPX page
htmlFieldName =
this.ID;
}
GetHtmlFieldName sets a protected variable called htmlFieldName, using the ID assigned to our control by the consuming ASPX page for its value. You might be wondering why I'm bothering to pickup this ID, but I do this because I don't want to arbitrarily hard-code the name that I'm going to use when we build the checkbox fields.
If we ensure that the field name is unique, multiple CategorizedCheckBoxList controls can be added to a single web form, and each one will be able to correctly determine which of its checkboxes was checked. Visual Studio.net will ensure (or at least help to ensure) that a programmer doesn't assign the same ID to multiple controls on a page, so the ID property will suit our purpose.
The method, BuildCategorizedCheckBoxList, is a beast. BuildCategorizedCheckBoxList gets its HtmlTextWriter from our Render method, so that it can write directly to the output stream. It does all of the dirty work necessary to emit the HTML for the control. Let's look at what's going on, section by section.
We begin by checking to see if there is any data in the DataTable. If not, the keyword, return, will force us to exit from this method.
/// <summary>
/// Outputs the HTML for this control.
/// </summary>
/// <param name="output"></param>
protected void BuildCategorizedCheckBoxList(HtmlTextWriter
output)
{
// Do we have any data?
if(dataTable == null ||
dataTable.Rows.Count < 1)
{
// There is no data, so there's nothing to
render.
return;
}
Next, we look for the columns that were specified for the category, value, and text. If we find these, we assign the indexes to local variables, which we will use later on. Referencing a table column by name is much slower than referencing by index number, which is why we are running this code.
We deliberately set these column index variables to -1 when they are initialized, because they are all required. If we try to get the value of a column in a DataRow, at the column index -1, the .NET Framework will raise an exception for us.
// First retrieve the column indexes of the specified columns.
// Later, we'll get the values that we need using these indexes.
// This is faster than referencing a column by name.
int CategoryColumnIndex
= -1;
int TextColumnIndex =
-1;
int ValueColumnIndex =
-1;
for(int i = 0; i <
dataTable.Columns.Count; i++)
{
if(dataTextColumn
== dataTable.Columns[i].ColumnName)
{
TextColumnIndex
= i;
}
if(dataValueColumn
== dataTable.Columns[i].ColumnName)
{
ValueColumnIndex
= i;
}
if(dataCategoryColumn
== dataTable.Columns[i].ColumnName)
{
CategoryColumnIndex
= i;
}
}
Next, we determine whether we're supposed to display all of the categories and their checkboxes in the same table. If so, we write the opening table tag, now:
/**********************************/
/* Build the html to display of the items */
/**********************************/
// If the consuming page wants one single, shared table, write the
opening tag, now.
if(this.sharedTable ==
true)
{
output.Write(GetTableTag());
}
GetTableTag is a helper method that builds the HTML for the opening table tag. More accurately, it returns the value of a private variable called tableTag, whose value, if null, is assigned by this method. For the sake of brevity, I will skip over the details of GetTableTag; however, it's worth noting that since strings are immutable data types, you should always use a StringBuilder object in situations where you would otherwise continually append to a string.
Moving along, we need to get a distinct list of the categories contained in the DataTable. First, we create a DataView from the DataTable, and sort by the category column. We create a local variable called LastCategory, which we will use to see if we have hit a new category, as we iterate through the rows in the DataView. Whenever we hit a new category, we add it to our Categories ArrayList.
// Create a string for the "previous" category
string LastCategory =
string.Empty;
// Sort the data by category
DataView Category =
dataTable.DefaultView;
Category.Sort =
this.dataCategoryColumn;
// Assemble a distinct list of the categories found in the data
ArrayList Categories =
new ArrayList();
for(int i = 0; i < Category.Count;
i++)
{
if(LastCategory
!= Category[i][CategoryColumnIndex].ToString())
{
Categories.Add(Category[i][CategoryColumnIndex].ToString());
LastCategory
= Category[i][CategoryColumnIndex].ToString();
}
}
Now that we have a list of the categories, we'll loop through the list and output the HTML for each category (and its corresponding checkboxes). We do this by creating a DataView, and setting a RowFilter that will limit the rows in the view to those that are in the category that we're working with.
// Loop through the categories
for(int i = 0; i <
Categories.Count; i++)
{
// Get the rows for this category only
DataView
CategoryItems = new DataView(dataTable);
CategoryItems.RowFilter
= String.Format("{0}=\'{1}\'", this.dataCategoryColumn,
Categories[i].ToString().Replace("\'","\'\'"));
CategoryItems.Sort
= this.dataTextColumn;
If we're creating a separate HTML table for each category, we output the opening table tag at this point.
// If the consuming page wants a separate
table for each category,
// write the opening tabel tag for the
current category, now.
if(this.sharedTable
== false)
{
output.Write(GetTableTag());
}
Now we need to output the HTML for the current category. The helper method, OutputCategoryRow, renders the HTML needed for this task.
// Add the category heading to the html
OutputCategoryRow(output,
(string)Categories[i]);
Things get a little complicated at this point. We know how many items belong to the current category, and we also know how many columns we're supposed to use to display the checkboxes. But we don't know how many rows it will take to display all of the items.
To determine the number of rows that are needed to display all of the items, we divide the total items in the category by the number of columns that we're going to display. If there was a remainder, we add one to the total rows.
// Calculate the total number of rows based
on the item count and the number of columns
totalItems =
CategoryItems.Count;
totalRows =
totalItems / columns;
// If there was anything left-over as a
result of the division, we need to add another row
if(totalItems %
columns > 0)
{
totalRows++;
}
Next, we enter a loop for the rows. We create a variable to hold the current index number for the item that we're on. The counter starts at zero. Each time we write the HTML for one of the items, we check to see if it was the last item. (If the CurrentItemIndex equals the total items minus one, we just created the column for the last item.)
After we have output the HTML for the last item in the current category, we set the CurrentItemIndex to -1. When the CurrentItemIndex is -1 but there are more columns left to display, we output the HTML for an empty table cell for both the checkbox and the text next to the box.
// Create an integer to hold the index
number for the current item
int
CurrentItemIndex = 0;
// Now loop through the rows
for(int Row = 0;
Row < totalRows; Row++)
{
// Determine the starting index for this
row.
// This is the same calculation that we
would perform to handle paging in a grid.
int Start =
(Row * columns);
// Create an integer to hold the index
number for the current item
int
CurrentItemIndex = Start;
// Start the row
output.Write("\t");
output.Write("<tr
class=\"");
output.Write(this.rowCssClass);
output.Write("\">");
output.Write("\n");
// Column loop
for(int Col
= 0; Col < columns; Col++)
{
// Make sure that we haven't hit a blank
entry.
if(CurrentItemIndex
== -1)
{
// Add an empty cell (two, actually)
AddEmptyCells(output);
}
else
{
// Now add the checkbox and text
OutputCheckBox(output,
htmlFieldName,
CategoryItems[CurrentItemIndex][TextColumnIndex].ToString(),
CategoryItems[CurrentItemIndex][ValueColumnIndex].ToString(),
IsChecked(CategoryItems[CurrentItemIndex][ValueColumnIndex].ToString())
);
// If we have more data left, increment the
current index counter.
if(CurrentItemIndex
< (totalItems - 1) && CurrentItemIndex != -1)
{
// increment the current index
CurrentItemIndex++;
}
else
{
// We're at the end of the items in the
data table.
// Set the value of the current index to
-1, which our rendering code
// ignores (creates empty table cells)
CurrentItemIndex
= -1;
}
}
// Add a line break
output.Write("\n");
}
At this point, we've gone through all of the columns in the row, so it's time to end the row.
// End the row
output.Write("\t");
output.Write("</tr>\n");
}
The loop continues until we've gone through all of the rows. At the end, if we're using a separate table for each category, we end the current category's table, now.
// Table tag
if(this.sharedTable
== false)
{
output.Write("</table>\n");
}
}
The loop continues until we've finally finished all of the categories. If we are using a shared table, we need to render the closing table tag at the end of this loop.
// Finish the table
if(this.sharedTable ==
true)
{
output.Write("</table>\n");
}
/**********************************/
}
Up until this point, I haven't mentioned the helper methods, OutputCheckbox and IsChecked. OutputCheckBox renders the HTML for a table cell containing a checkbox field and another table cell next to it with the corresponding text. I put the checkbox and the text in separate cells just in case the text wraps to a second line. This keeps the alignment of everything nice and neat.
IsChecked is used to determine whether the checkbox should be displayed with or without a "check". Since our variable, Selections, is an ArrayList, this is very easy:
/// <summary>
/// Looks for a match between the current value and the list of
selected values.
/// </summary>
/// <param name="currentValue">The value that we
want to look for.</param>
/// <returns>True if the current value is contained in the
selected list. Otherwise, false.</returns>
protected bool IsChecked(string currentValue)
{
// If we have selections, continue
if(selections != null
&& selections.Count > 0)
{
// Can we find the current value?
if(selections.IndexOf(currentValue)
> -1)
{
// Yes, so this item should be marked as
selected (checked)
return true;
}
else
{
// No, so this item should not be selected
return
false;
}
}
else
{
// Nothing at all was selected, so return
false
return false;
}
}