Click here to Skip to main content
15,867,453 members
Articles / Web Development / XHTML

Custom controls in ASP.NET MVC

Rate me:
Please Sign up or sign in to vote.
4.76/5 (31 votes)
8 Jan 2009CPOL8 min read 158.4K   2.9K   95   18
Control library for rendering custom HTML in ASP.NET MVC applications.

Introduction

I work as a software developer at Esendex, the business SMS service provider. We’re currently developing a new version of our web application used by our customers to send and receive SMS messages. This was the driving force behind custom controls in ASP.NET MVC - we weren’t satisfied with HtmlHelper methods, and couldn’t find a fully working alternative. Project “Doyle” is our first ASP.NET MVC application, so we wanted to lay down some foundations. Our initial objective was to design a new page for composing and sending messages. This comprised of a form with three inputs: recipients, originator, and body. We also wanted to have watermark effects on each input and server-side validation. Creating a control library will allow us to re-use additional functionality throughout numerous projects and simplify the development process.

Background

ASP.NET MVC is a relatively new approach for developing web applications. Although the project is still in beta, many are starting to realise the potential, and favour it over traditional web forms. I won’t go into a debate over the pros and cons between them, but my first encounters have been positive. My guess is that both styles will co-exist rather than one becoming more dominant – it’s always good to have the choice, and both have different things to offer.

I assume if you’re reading this article, you already have an understanding of the MVC pattern, so I won’t bother going into too much detail here. Basically, it separates applications into three layers: Models, Views, and Controllers. Models contain business logic and objects, Views render the user interface (UI), and Controllers handle user interaction. Controllers are links between Models and Views, and should be very discrete. Always remember: thin Controllers, fat Models.

If you’ve used web forms in the past, you’ll know that it’s pretty easy to create custom controls. Out of the box, Microsoft provides you with simple wrappers for standard HTML elements. Some of the more advanced ones include tools for navigation, validation, and data representation. Regardless of their complexity, you can make new controls using inheritance. By doing so, they automatically acquire the characteristics of the base control, allowing you to focus on the new stuff. For example, if you wanted a range of text boxes to have a certain feature, you would inherit from System.Web.UI.WebControls.TextBox and implement the modifications:

C#
namespace Esendex.CustomControls
{
    public class MyTextBox : System.Web.UI.WebControls.TextBox
    {
        // Modifications go here...
    }
}

Afterwards, register the namespace of your controls in the Web.config file:

XML
<pages>
    <controls>
        <add tagPrefix="cc" assembly=" 
               Esendex" namespace="Esendex.CustomControls" />
    </controls>
</pages>

Finally, insert the desired controls onto your pages:

ASP.NET
<cc:MyTextBox runat="server" />

ASP.NET MVC doesn’t prohibit the use of server or user controls, but it does frown upon them – many won’t work properly because the web pages (or Views) follow a different lifecycle. To make up for this, a few have been replaced with UI Helpers – methods that return snippets of HTML. These are attached to a static class, HtmlHelper, which is accessible from any View:

ASP.NET
<%= Html.TextBox("name", "Please enter your name...")%>

Output:

HTML
<input id="name" name="name" type="text" value="Please enter your name..." />

You'll notice that inline code is used to render the return string – something that we’ve been trying to get away from for years. To begin with, you might be impressed with this concept, but it soon gets annoying. For example, the TextBox() method has two optional third parameters that both represent additional HTML attributes (the first two are for the name and value, respectively). Alarm bells should be ringing at this point, and questions need to be asked; how do you change the CSS class or set the amount of columns? The answer is to use Anonymous Types in conjunction with the third parameter:

ASP.NET
<%= Html.TextBox("name", "Please enter your name...", 
           new { @class = "styledInput", size = 50 })%>

Output:

HTML
<input class="styledInput" id="name" name="name" 
   size="50" type="text" value="Please enter your name..." /> 

My first problem with this is you have to remember the attribute names – that’s right, no intellisense! Reserved keywords like “class” are also annoying - they have to be prefixed with “@”. Using an Anonymous Type just makes the whole process less intuitive and more complicated for those who are new to ASP.NET MVC.

A great idea

What if you want to extend an existing helper method? The simple answer would be to create an overloaded, or even a new, method that returns some pretty HTML as expected. Try doing so, and I guarantee you’ll get sick and tired of having an endless amount of overloads. Just take a look at the ASP.NET MVC source code if you don’t believe me.

To break free from traditional HtmlHelper methods, I came up with a control library that expands on the ideas of Jeff Handley. He wrote an article that gave me the inspiration, but his examples only include a text box implementation that was missing some fundamental functionality. To summarise, he created a base class that every control would inherit - reusable properties/methods are added as low down as possible to minimise code repetition. The ToString() method is then overridden (at whatever level necessary) and designed to return HTML. The thing I liked the most was the way you instantiated controls within a View:

ASP.NET
<%= new MvcTextBox() { Name = "name" }%> 

Notice the constructor doesn’t have any arguments. Instead, it takes advantage of Object Initializers that allow you to assign fields/properties at creation time. This new feature in .NET 3.5 gives you full intellisense to the object being initialised, with the flexibility of being able to specify only the values that are important – none are compulsory.

Take something and make it better

My approach is loosely based on Jeff’s style, but I’ve made some improvements. Here is the base class that every control inherits:

C#
public abstract class MvcControl
{
    protected IDictionary<string, string> Attributes { get; private set; }

    public string Class
    {
        set { AddClass(value); }
    }

    public virtual string ID
    {
        get { return Attributes.GetValue("id"); }
        set { Attributes.Merge("id", value); }
    }

    protected string InnerHtml { get; set; }

    public object HtmlAttributes { get; set; }

    public string Style
    {
        set { Attributes.Merge("style", value); }
    }

    private string TagName { get; set; }

    private TagRenderMode TagRenderMode { get; set; }

    public string Title
    {
        set { Attributes.Merge("title", value); }
    }

    public MvcControl(string tagName)
        : this(tagName, TagRenderMode.Normal) { }

    public MvcControl(string tagName, TagRenderMode tagRenderMode)
    {
        Attributes = new SortedDictionary<string, 
                            string>(StringComparer.Ordinal);
        TagName = tagName;
        TagRenderMode = tagRenderMode;
    }

    public void AddClass(string className)
    {
        if (string.IsNullOrEmpty(className))
        {
            className = className.Trim();
        }

        string currentClassName;

        if (Attributes.TryGetValue("class", out currentClassName))
        {
            currentClassName = currentClassName.Trim();

            Attributes["class"] = currentClassName + 
                                    " " + className;
        }
        else
        {
            Attributes["class"] = className;
        }
    }

    public void AddEventScript(string eventKey, string script)
    {
        string newScript = script;

        if (string.IsNullOrEmpty(newScript))
        {
            newScript = newScript.Trim();

            if (!newScript.EndsWith("}")
                && !newScript.EndsWith(";"))
            {
                newScript += ";";
            }
        }

        string currentScript;

        if (Attributes.TryGetValue(eventKey, out currentScript))
        {
            currentScript = currentScript.Trim();

            if (!currentScript.EndsWith("}")
                && !currentScript.EndsWith(";"))
            {
                currentScript += ";";
            }

            Attributes[eventKey] = currentScript + " " + newScript;
        }
        else
        {
            Attributes[eventKey] = newScript;
        }
    }

    private TagBuilder GetTagBuilder()
    {
        TagBuilder tagBuilder = new TagBuilder(TagName);
        tagBuilder.MergeAttributes(new RouteValueDictionary(HtmlAttributes));
        tagBuilder.MergeAttributes(Attributes);
        tagBuilder.InnerHtml = InnerHtml;

        return tagBuilder;
    }

    public string Html(ViewContext viewContext)
    {
        if (viewContext == null)
        {
            throw new ArgumentNullException("viewContext");
        }

        StringBuilder html = new StringBuilder();

        Initialise(viewContext);

        TagBuilder tagBuilder = GetTagBuilder();

        using (StringWriter writer = new StringWriter(html))
        {
            writer.Write(tagBuilder.ToString(TagRenderMode));

            RenderCustomHtml(writer, viewContext);
        }

        return html.ToString();
    }

    protected virtual void Initialise(ViewContext viewContext) { }

    protected virtual void RenderCustomHtml(StringWriter writer, 
                           ViewContext viewContext) { }

    protected void SetInnerText(object innerText)
    {
        if (innerText == null)
        {
            SetInnerText(null);
        }

        SetInnerText(innerText.ToString());
    }

    protected void SetInnerText(string innerText)
    {
        InnerHtml = HttpUtility.HtmlEncode(innerText);
    }
}

I’ve added several public properties that map to HTML attributes stored in an IDictionary collection. ID, Class, Style, and Title can be applied to any HTML element, so it makes sense for them to live in the base class. There is no need to have get accessors because developers should only be setting these values.

If a control needs access to ViewContext information, override the Initialise() method. Some controls may also need to render additional HTML (e.g., MvcCheckBox). Overriding the RenderHtml() method gives access to a <code>StringWriter which can be appended accordingly.

Here is a control that renders an HTML label element:

C#
public class MvcLabel : MvcControl
{
    protected string AssociatedControlID
    {
        get { return Attributes["for"]; }
        private set { Attributes["for"] = value; }
    }

    protected string Text
    {
        get { return InnerHtml; }
        private set { InnerHtml = value; }
    }

    public MvcLabel(string associatedControlID, string text)
        : base("label")
    {
        AssociatedControlID = associatedControlID;
        Text = text;
    }
}

The above definition is quite small, but the end result is still impressive. To extend MvcControl, I’ve added two properties: AssociatedControlID and Text. You’ll notice these are assigned in the constructor - this helps to ensure the output HTML is valid to a minimum specification (a normal LABEL tag with a for attribute and some inner HTML).

Controls can be added to a View using the following HtmlHelper extension method that accepts an instance of MvcControl:

C#
public static string MvcControl(this HtmlHelper htmlHelper, MvcControl mvcControl)
{
    if (mvcControl == null)
    {
        throw new ArgumentNullException("mvcControl");
    }

    return mvcControl.Html(htmlHelper.ViewContext);
}

Here is how you add an MvcLabel to a View:

ASP.NET
<%= Html.MvcControl(new MvcLabel("name", "Name"))%> 

Output:

HTML
<label for="name">Name</label> 

For more complicated scenarios, you might want to specify the class and title. Fortunately, you get this functionality for free because MvcLabel inherits MvcControl:

ASP.NET
<%= Html.MvcControl(new MvcLabel("name", "Name")
   { Class = "inputHeading", Title = "Name" })%> 

Output:

HTML
<label class="inputHeading" for="name" title="Name">Name</label> 

You might think that instantiating these controls is more complicated. I’ll admit that my early attempts were simpler, but I soon found the need for information from the current request, and wanted to come up with an efficient solution. ViewContext and ViewData are useful for controls that handle validation and change behaviour accordingly or get populated with data from the model. Both are accessible from a View, but I didn’t want to handle them manually. As a workaround, ViewContext is automatically assigned from HtmlHelper inside the extension method.

I also wanted to restrict developers to a single way of instantiating controls, and the HtmlHelper method does the job (almost). My prediction is that most people will opt for the easy way, but there is a more complex alternative:

ASP.NET
<%= new MvcLabel("name", "Name")
   { Class = "inputHeading", Title = "Name" }.Html(ViewContext)%>

The Html() method will throw an exception if a null value is passed, so I recommend using the HtmlHelper method to maintain consistency and decrease the chance or error.

Last chance to impress

In order to fully compare the two implementations, I thought it only fair to demonstrate my version of MvcTextBox. Many form elements, including a text box, are based on the INPUT tag - the differentiator being the type attribute. For this reason, I created another base class to encapsulate basic functionality:

C#
public abstract class MvcInput : MvcEventAttributes
{
    protected override void Initialise(ViewContext viewContext)
    {
        if (viewContext == null)
        {
            throw new ArgumentNullException("viewContext");
        }

        ViewDataDictionary viewData = viewContext.ViewData;

        if (viewData == null)
        {
            throw new ArgumentNullException("viewData");
        }

        string attemptedValue = viewData.GetModelAttemptedValue(Name);

        if (Type == InputType.CheckBox)
        {
            if (!string.IsNullOrEmpty(attemptedValue))
            {
                bool isChecked;

                string[] attemptedValues = attemptedValue.Split(',');

                if (bool.TryParse(attemptedValues[0], out isChecked))
                {
                    if (isChecked)
                    {
                        Attributes["checked"] = "checked";
                    }
                    else
                    {
                        Attributes.Remove("checked");
                    }
                }
            }
        }
        else if (Type == InputType.RadioButton)
        {
            if (!string.IsNullOrEmpty(attemptedValue))
            {
                string value = Attributes.GetValue("value");

                if (value.Equals(attemptedValue, 
                          StringComparison.InvariantCultureIgnoreCase))
                {
                    Attributes["checked"] = "checked";
                }
                else
                {
                    Attributes.Remove("checked");
                }
            }
        }
        else if (Type != InputType.File)
        {
            if (attemptedValue != null)
            {
                Attributes["value"] = attemptedValue;
            }
            else if (viewData[Name] != null)
            {
                Attributes["value"] = viewData.EvalString(Name);
            }        
}

        ModelState modelState;

        if (viewData.ModelState.TryGetValue(Name, out modelState))
        {
            if (modelState.Errors.Count > 0)
            {
                AddClass(InvalidCssClass);
            }
        }
    }

    protected string Name
    {
        get { return Attributes.GetValue("name"); }
        private set { Attributes.Merge("name", value); }
    }

    public string InvalidCssClass { get; set; }

    protected virtual bool IsIDRequired
    {
        get { return Type != InputType.RadioButton; }
    }

    protected InputType Type
    {
        get { return MvcControlHelper.GetInputTypeEnum(Attributes.GetValue("type")); }
        set { Attributes["type"] = MvcControlHelper.GetInputTypeString(value); }
    }

    public MvcInput(InputType type, string name)
        : base("input", TagRenderMode.SelfClosing)
    {
        if (string.IsNullOrEmpty(name))
        {
            throw new ArgumentException("Value cannot be null or empty.", "name");
        }

        Type = type;

        if (IsIDRequired)
        {
            ID = name;
        }

        InvalidCssClass = "input-validation-error";
        Name = name;
    }
}

The MvcInput class handles form validation, and sets the default values using data passed from the model. By default, the CSS class for an invalid element is “input-validation-error”, but you can specify a custom value.

The only enhancement to MvcTextBox is a watermark feature which sets the value of the text box and clears it when the element gains focus (to enable this effect, the View must reference Watermark.js):

C#
public class MvcTextBox : MvcInput
{
    protected override void RenderHtml(StringWriter writer, 
                            ViewContext viewContext)
    {
        if (writer == null)
        {
            throw new ArgumentNullException("writer");
        }

        if (viewContext == null)
        {
            throw new ArgumentNullException("viewContext");
        }

        MvcControlHelper.RenderWatermarkScript(writer, viewContext, 
                         ID, Name, WatermarkedCssClass, WatermarkText);
    }

    public int Columns
    {
        set { Attributes.Merge("size", value.ToString()); }
    }
    public string MaximumLength
    {
        set { Attributes.Merge("maxlength", value); }
    }

    public string WatermarkedCssClass { get; set; }

    public string WatermarkText { get; set; }

    public object Value
    {
        set { Attributes.Merge("value", value); }
    }

    public MvcTextBox(string name)
        : base(InputType.Text, name)
    {
        WatermarkedCssClass = "input-watermarked";
    }
}

And, here is the code to render an MvcTextBox:

ASP.NET
<%= Html.MvcControl(new MvcTextBox("name")
  { Columns = 50, Class = "styledInput", Value = "Please enter your name..." })%>

Output:

HTML
<input class="styledInput" id="name" name="name" 
       size="50" type="text" value="Please enter your name..." />

There might be a few more characters in comparison to Jeff’s, but the plan is to make coding easier. I guess the only way to know which is best is to try them both.

Summary

To put this all into context, the source code shows how to create an application to send SMS messages. The default View contains the form to capture all the required information which is passed to the SendMessage() method of HomeController where the basic validation is carried out. There is scope for it to be linked into one of our SDKs/APIs, meaning the messages would get delivered - sign up for a free trial.

I haven’t finished this library, but it’s a good starting point. Hopefully, after reading this article, you’ll appreciate the benefits of spending a little extra time today to produce something that saves you some in the future.

We aim to have Doyle ready for public testing in the first quarter of 2009. As planned, all Views utilise the MvcControls library, and we’re very happy with the results. Here is a preview of our Compose page:

Image 1

* In order to run the demo, you'll need to install the Microsoft .NET Framework 3.5 and Microsoft ASP.NET MVC Beta.

Acknowledgements

Some code snippets were copied from the Microsoft ASP.NET MVC Beta source. In particular, the UrlHelperExtensions simply exposes methods that had already been defined with the internal keyword.

I'd like to thank Jeff Handley again for his original ideas.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Web Developer
United Kingdom United Kingdom
Software engineer.

Blog: http://andrewgunn.blogspot.com/
Twitter: andrewgunn

Comments and Discussions

 
GeneralMy vote of 5 Pin
rrossenbg11-Apr-13 9:58
rrossenbg11-Apr-13 9:58 
NewsCodeContrib Pin
Andrew Gunn23-Jul-09 11:54
Andrew Gunn23-Jul-09 11:54 
GeneralAwesome stuff! Pin
Joost Verdaasdonk3-Jul-09 10:12
Joost Verdaasdonk3-Jul-09 10:12 
GeneralRe: Awesome stuff! Pin
Andrew Gunn3-Jul-09 12:12
Andrew Gunn3-Jul-09 12:12 
GeneralRe: Awesome stuff! Pin
Joost Verdaasdonk4-Jul-09 2:28
Joost Verdaasdonk4-Jul-09 2:28 
GeneralWhat's about DropDownList [modified] Pin
sanjeev537@hotmail.com6-Apr-09 21:16
sanjeev537@hotmail.com6-Apr-09 21:16 
AnswerRe: What's about DropDownList Pin
Andrew Gunn17-Apr-09 3:18
Andrew Gunn17-Apr-09 3:18 
GeneralModelState.AttemptedValue Pin
Nic_Roche24-Feb-09 10:12
professionalNic_Roche24-Feb-09 10:12 
AnswerRe: ModelState.AttemptedValue Pin
Andrew Gunn25-Feb-09 4:20
Andrew Gunn25-Feb-09 4:20 
QuestionNamed arguments? Pin
nelis28-Jan-09 0:40
nelis28-Jan-09 0:40 
GeneralExcellent... Pin
Rajesh Pillai10-Jan-09 5:11
Rajesh Pillai10-Jan-09 5:11 
GeneralAwesome Start Pin
Jeff Handley9-Jan-09 10:10
Jeff Handley9-Jan-09 10:10 
QuestionCustom attributes? Pin
Todd Smith8-Jan-09 20:58
Todd Smith8-Jan-09 20:58 
AnswerRe: Custom attributes? [modified] Pin
Andrew Gunn8-Jan-09 22:20
Andrew Gunn8-Jan-09 22:20 
GeneralGreat Attempt Pin
Rahul Ravindran8-Jan-09 15:53
Rahul Ravindran8-Jan-09 15:53 
GeneralRe: Great Attempt Pin
Andrew Gunn10-Jan-09 11:17
Andrew Gunn10-Jan-09 11:17 
NewsRe: Great Attempt Pin
Andrew Gunn5-Feb-09 22:21
Andrew Gunn5-Feb-09 22:21 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.