Tuesday, 2 November 2010

Devil in the detail - customising the ASP BulletedList

When you do standard easy programming, you can cover a lot of ground very quickly. Modern tools and class libraries are very useful and do most things very well. When it comes to more specific programming, the devil is very much in the detail. For example, the other day I wanted to replace some hard-coded HTML generation, which made a tabbed divider, with a more standard ASP control. Since our old tab control was based on a <ul>, I found the closest control was an ASP .Net BulletedList.
It was easy at first since by setting various options on the list, I could get it to generate a <ul> with items and using the link button mode, it all looked pretty good. I applied the original CSS styles and I thought I was 99% there.
Oh, I thought, I really need to distinguish the selected item in the list since they represent tabs, I want to style the selected one differently. Since the bulleted list is supposed to be, well, a bulleted list, all the item selection from the base class ListControl has been overridden with NotImplementedExceptions and even if an item is selected by getting the individual ListItem and setting Selected = true, the item is not rendered any differently.
Well, this wasn't a problem since OO allows us to inherit and extend the functionality, I implemented my own version of RenderContents() in a new class, inherited from BulletedList which renders an attribute for class in the LI if it is selected, this was all great - OO is amazing! I then wanted it to remember the selected item between postbacks and to fire the SelectedIndexChanged ListControl event when the tab had changed. This is where it all started kicking off. For a start, since BulletedList has overridden the ListControl SelectedIndex and SelectedValue setters, I could not call the original versions which were fine, I had to use the Red Gate reflector tool to find the original code and duplicate it in my class overriding the overridings! I then found various references to internal and private functions which, again, I needed to duplicate in my code in order to use the code I had copied from the internal workings of the ListControl (I only wanted to make small changes!). I then realised that since the BulletedList already handle the click event and did not fire the SelectedIndexChanged even from ListControl, I would not easily be able to do what I wanted.
In the end, I decided the easiest thing was to inherit directly from ListControl which would give me all the Item selection code and then add in anything from the BulletList class that I needed (fortunately not all of it!) and then I could make my own class handle the postback event when it was clicked and fire the OnSelectedIndexChanged event from ListControl.
I removed any of the display modes and bullet types that I was not using to make it neater and got rid of the start bullet number. I also got rid of the various local variables used to "cache" values for loops. It seems that MS did not consider very well how their code would be specialised in sub-classes which is why there is a hotchpotch of public, protected and private/internal functions meaning that trying to base your code on the original does not work without lots of duplication (unless there are other public utlities to do the same things).
Anyway, if you're interested in the code:

///
/// A specialisation of the list control which is based on link buttons and tab styles
///

public class SelectableTabControl : ListControl, IPostBackEventHandler
{
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
this.CssClass = "tablist";
}

///
/// Handle the data being bound
///

///
/// Ensure a default item is selected
protected override void OnDataBound(EventArgs e)
{
base.OnDataBound(e);
if (SelectedIndex == -1 && Items.Count > 0)
{
SelectedIndex = 0;
}
}

///
/// Add attributes of this control to the HTML
///

/// The HTML output stream
protected override void AddAttributesToRender(HtmlTextWriter writer)
{
string uniqueID = this.UniqueID;
if (uniqueID != null)
{
writer.AddAttribute(HtmlTextWriterAttribute.Name, uniqueID);
}
base.AddAttributesToRender(writer);
}

///
/// Render the HTML for this control
///

/// The HTML output stream
protected override void Render(HtmlTextWriter writer)
{
if (this.Items.Count != 0)
{
base.Render(writer);
}
}

///
/// Prevent this control from being given child controls
///

///
protected override ControlCollection CreateControlCollection()
{
return new EmptyControlCollection(this);
}

///
/// Render the individual elements for this list
///

///
/// The default implementation does not render a class for selected items
protected override void RenderContents(HtmlTextWriter writer)
{
for (int i = 0; i < this.Items.Count; i++)
{
if (this.Items[i].Attributes != null)
{
this.Items[i].Attributes.AddAttributes(writer);
}
writer.AddAttribute(HtmlTextWriterAttribute.Class, this.Items[i].Selected ? "selecteditem" : String.Empty );
writer.RenderBeginTag(HtmlTextWriterTag.Li);
this.RenderBulletText(this.Items[i], i, writer);
writer.RenderEndTag();
}
}

///
/// Return the tag to use for the overall control
///

protected override HtmlTextWriterTag TagKey
{
get { return HtmlTextWriterTag.Ul; }
}


///
/// Render the individual items from the list
///

///
///
///
/// Replacement for base class function which references inaccessible values. This only includes the LinkButton style
protected void RenderBulletText(ListItem item, int index, HtmlTextWriter writer)
{
if (!this.Enabled || !item.Enabled)
{
writer.AddAttribute(HtmlTextWriterAttribute.Disabled, "disabled");
}
else
{
writer.AddAttribute(HtmlTextWriterAttribute.Href, this.GetPostBackEventReference(index.ToString(CultureInfo.InvariantCulture)));
}
if (AccessKey.Length != 0)
{
writer.AddAttribute(HtmlTextWriterAttribute.Accesskey, AccessKey);
}
writer.RenderBeginTag(HtmlTextWriterTag.A);
HttpUtility.HtmlEncode(item.Text, writer);
writer.RenderEndTag();

}

#region Code duplicated from internal .Net classes
///
/// From System.Web.UI.WebControls.BulletedList
///

///
///
private string GetPostBackEventReference(string eventArgument)
{
if (this.CausesValidation && (this.Page.GetValidators(this.ValidationGroup).Count > 0))
{
return ("javascript:" + GetClientValidatedPostback(this, this.ValidationGroup, eventArgument));
}
return this.Page.ClientScript.GetPostBackClientHyperlink(this, eventArgument, true);
}

///
/// From System.Web.UI.Util
///

///
///
///
///
private static string GetClientValidatedPostback(Control control, string validationGroup, string argument)
{
string str = control.Page.ClientScript.GetPostBackEventReference(control, argument, true);
return (GetClientValidateEvent(validationGroup) + str);
}

///
/// From System.Web.UI.Util
///

///
///
private static string GetClientValidateEvent(string validationGroup)
{
if (validationGroup == null)
{
validationGroup = string.Empty;
}
return ("if (typeof(Page_ClientValidate) == 'function') Page_ClientValidate('" + validationGroup + "'); ");
}

///
/// From System.Web.UI.Control but minus supports event validation which is far too esoteric (and inaccessible)
///

///
///
internal void ValidateEvent(string uniqueID, string eventArgument)
{
if ((this.Page != null))
{
this.Page.ClientScript.ValidateEvent(uniqueID, eventArgument);
}
}
#endregion

///
/// Gets whether the All tab is selected (which is always the last one)
///

public bool AllTabSelected
{
get { return SelectedIndex == Items.Count; }
}

#region IPostBackEventHandler Members
///
/// Handle a postback event occuring.
///

/// The value of the event argument
/// This event is raised when the page is posted back and if the postback was caused by this control then the eventArgument is the index of the tab that was clicked
public void RaisePostBackEvent(string eventArgument)
{
ValidateEvent(this.UniqueID, eventArgument);
if (this.CausesValidation)
{
this.Page.Validate(this.ValidationGroup);
}
SetPostDataSelection(Convert.ToInt32(eventArgument));
this.OnSelectedIndexChanged(EventArgs.Empty);
}

#endregion
}
Post a Comment