ASP.NET MVC: Implement Theme Folders using a Custom ViewEngine

One of the things that ASP.NET MVC 1.0 is missing is the ability to easily implement Themes. The older, more mature standard ASP.NET framework includes theme support via the App_Themes folder; however limited it can be, it’s still more than ASP.NET MVC currently has. Well, at least until I wrote this little custom ViewEngine and ControllerBase class to help out and allow us to very easily implement Themes within our ASP.NET MVC applications.

A little history: A few months back I wrote up a post on “How To Setup Custom Theme Support In ASP.NET MVC Preview 4 using a Custom ViewEngine”, then a couple weeks later I posted an updated version that added Custom Themes to ASP.NET MVC Preview 5. So I’ve decided to update the code from Preview 5 and make it all work with ASP.NET MVC v1.0 Final Release.

Also, I took a tip from Michael Ryan and modified my previous theme implementation from Preview 5 to include both Views and Content folders within the Theme folder.

Download the Code

I know some of you may want to download the code and look at it before reading on, so here’s the download link:

Source Code: AspNetMvc1CustomThemeImplementation.zip (262.99 kb)

Create “~/Themes” Folder and a “Default” View Theme

ASPNETMVC_1_CustomThemeFolderLayoutFirst, we’ll make some changes to the Views contained within the default ASP.NET MVC Template.

Here’s a brief summary of what changes are needed:

  1. Create a sub-folder named “Themes” within the website root folder
  2. Create a sub-folder names “Default” within the “Themes” folder
  3. Cut and Paste the “Views” folder into the new “~/Themes/Default” folder
  4. Cut and Past the “Content” folder into the new “~/Themes/Default” folder
  5. Modify the Pages (.aspx) to point correctly to the Master Page (.master) for the Theme their in, now that we moved the files.
  6. Modify the Master Pages (.master) to point to the CSS file within the “Content” folder correctly, now that we moved the files.
  7. To create additional Themes, just copy the “Default” folder and name it what you want for the desired Theme, and repeat Steps 5 and 6 above.

To the right is a screenshot displaying the layout of the Themes folder as described above.

Apply the Custom ViewEngine

Now that we have our Themes folder created, we can go ahead and implement our Custom ViewEngine. The Custom ViewEngine in this example was created by inheriting the WebFormViewEngine and just overriding/changing the necessary functionality.

First we need to tell our application to use the new “WebFormThemeViewEngine” ViewEngine class. To do this we’ll need to clear any existing ViewEngines and add a new instance of the ‘WebFormThemeViewEngine”. This needs to be done within the Application_Start method in the Global.asax. Below is what the Application_Start method will look like after we make the necessary changes:

protected void Application_Start()
{
    RegisterRoutes(RouteTable.Routes);

    // Replace the Default WebFormViewEngine with our custom WebFormThemeViewEngine
    System.Web.Mvc.ViewEngines.Engines.Clear();
    System.Web.Mvc.ViewEngines.Engines.Add(new WebFormThemeViewEngine());
}

Also, for reference here’s the complete code for the “WebFormThemeViewEngine” class that’s used in this example. I wont be discussing the steps necessary when creating your own custom ViewEngines; that would be a little more involved than I would like to get within the scope of this article. A link to download the entire code for the project I created when writing this article is located at the top of the article

public class WebFormThemeViewEngine : WebFormViewEngine
{
    public WebFormThemeViewEngine()
    {
        base.MasterLocationFormats = new string[] {
            "~/Themes/{2}/Views/{1}/{0}.master", 
            "~/Themes/{2}/Views/Shared/{0}.master"
        };
        base.ViewLocationFormats = new string[] { 
            "~/Themes/{2}/Views/{1}/{0}.aspx", 
            "~/Themes/{2}/Views/{1}/{0}.ascx", 
            "~/Themes/{2}/Views/Shared/{0}.aspx", 
            "~/Themes/{2}/Views/Shared/{0}.ascx" 
        };
        base.PartialViewLocationFormats = new string[] {
            "~/Themes/{2}/Views/{1}/{0}.aspx",
            "~/Themes/{2}/Views/{1}/{0}.ascx",
            "~/Themes/{2}/Views/Shared/{0}.aspx",
            "~/Themes/{2}/Views/Shared/{0}.ascx"
        };
    }

    protected override bool FileExists(ControllerContext controllerContext, string virtualPath)
    {
        try
        {
            return System.IO.File.Exists(controllerContext.HttpContext.Server.MapPath(virtualPath));
        }
        catch (HttpException exception)
        {
            if (exception.GetHttpCode() != 0x194)
            {
                throw;
            }
            return false;
        }
        catch
        {
            return false;
        }
    }

    public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
    {
        string[] strArray;
        string[] strArray2;

        if (controllerContext == null)
        {
            throw new ArgumentNullException("controllerContext");
        }
        if (string.IsNullOrEmpty(viewName))
        {
            throw new ArgumentException("viewName must be specified.", "viewName");
        }

        string themeName = this.GetThemeToUse(controllerContext);

        string requiredString = controllerContext.RouteData.GetRequiredString("controller");

        string viewPath = this.GetPath(controllerContext, this.ViewLocationFormats, "ViewLocationFormats",
                viewName, themeName, requiredString, "View", useCache, out strArray);

        string masterPath = this.GetPath(controllerContext, this.MasterLocationFormats, "MasterLocationFormats",
                masterName, themeName, requiredString, "Master", useCache, out strArray2);

        if (!string.IsNullOrEmpty(viewPath) && (!string.IsNullOrEmpty(masterPath) || string.IsNullOrEmpty(masterName)))
        {
            return new ViewEngineResult(this.CreateView(controllerContext, viewPath, masterPath), this);
        }
        return new ViewEngineResult(strArray.Union(strArray2));
    }

    public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache)
    {
        string[] strArray;
        if (controllerContext == null)
        {
            throw new ArgumentNullException("controllerContext");
        }
        if (string.IsNullOrEmpty(partialViewName))
        {
            throw new ArgumentException("partialViewName must be specified.", "partialViewName");
        }

        string themeName = this.GetThemeToUse(controllerContext);

        string requiredString = controllerContext.RouteData.GetRequiredString("controller");
        string partialViewPath = this.GetPath(controllerContext, this.PartialViewLocationFormats,
                "PartialViewLocationFormats", partialViewName, themeName, requiredString, "Partial", useCache, out strArray);
        if (string.IsNullOrEmpty(partialViewPath))
        {
            return new ViewEngineResult(strArray);
        }
        return new ViewEngineResult(this.CreatePartialView(controllerContext, partialViewPath), this);

    }

    private string GetThemeToUse(ControllerContext controllerContext)
    {
        string themeName = controllerContext.HttpContext.Items["themeName"] as string;
        if (themeName == null) themeName = "Default";
        return themeName;
    }

    private static readonly string[] _emptyLocations;

    private string GetPath(ControllerContext controllerContext, string[] locations, string locationsPropertyName,
        string name, string themeName, string controllerName, string cacheKeyPrefix, bool useCache, out string[] searchedLocations)
    {
        searchedLocations = _emptyLocations;
        if (string.IsNullOrEmpty(name))
        {
            return string.Empty;
        }
        if ((locations == null) || (locations.Length == 0))
        {
            throw new InvalidOperationException("locations must not be null or emtpy.");
        }

        bool flag = IsSpecificPath(name);
        string key = this.CreateCacheKey(cacheKeyPrefix, name, flag ? string.Empty : controllerName, themeName);
        if (useCache)
        {
            string viewLocation = this.ViewLocationCache.GetViewLocation(controllerContext.HttpContext, key);
            if (viewLocation != null)
            {
                return viewLocation;
            }
        }
        if (!flag)
        {
            return this.GetPathFromGeneralName(controllerContext, locations, name, controllerName, themeName, key, ref searchedLocations);
        }
        return this.GetPathFromSpecificName(controllerContext, name, key, ref searchedLocations);
    }

    private static bool IsSpecificPath(string name)
    {
        char ch = name[0];
        if (ch != '~')
        {
            return (ch == '/');
        }
        return true;
    }

    private string CreateCacheKey(string prefix, string name, string controllerName, string themeName)
    {
        return string.Format(CultureInfo.InvariantCulture,
            ":ViewCacheEntry:{0}:{1}:{2}:{3}:{4}",
            new object[] { base.GetType().AssemblyQualifiedName, prefix, name, controllerName, themeName });
    }

    private string GetPathFromGeneralName(ControllerContext controllerContext, string[] locations, string name,
        string controllerName, string themeName, string cacheKey, ref string[] searchedLocations)
    {
        string virtualPath = string.Empty;
        searchedLocations = new string[locations.Length];
        for (int i = 0; i < locations.Length; i++)
        {
            string str2 = string.Format(CultureInfo.InvariantCulture, locations[i], new object[] { name, controllerName, themeName });

            if (this.FileExists(controllerContext, str2))
            {
                searchedLocations = _emptyLocations;
                virtualPath = str2;
                this.ViewLocationCache.InsertViewLocation(controllerContext.HttpContext, cacheKey, virtualPath);
                return virtualPath;
            }
            searchedLocations[i] = str2;
        }
        return virtualPath;
    }

    private string GetPathFromSpecificName(ControllerContext controllerContext, string name, string cacheKey, ref string[] searchedLocations)
    {
        string virtualPath = name;
        if (!this.FileExists(controllerContext, name))
        {
            virtualPath = string.Empty;
            searchedLocations = new string[] { name };
        }
        this.ViewLocationCache.InsertViewLocation(controllerContext.HttpContext, cacheKey, virtualPath);
        return virtualPath;
    }
}</pre>

 
## Create a ThemeControllerBase Class to Initially Set the Theme to Use

One additional step that’s necessary when using the above WebFormThemeViewEngine is to create a custom Controller Base Class, and inherit from it with all your Controllers. This ThemeControllerBase class needs to set the Theme to use within the “HttpContext.Items[“themeName”]”. If you miss or skip this step you will get an exception and will not be able to run the website.

The ThemeControllerBase class used in this example includes code that allows you to specify the Theme to use by passing it in using the QueryString like this: *“http://localhost/Default.aspx?theme=Red”*

Here’s the code for the ThemeControllerBase class:
public abstract class ThemeControllerBase : Controller
{
    protected override void Execute(System.Web.Routing.RequestContext requestContext)
    {
        // Add code here to set the Theme based on your database or some other storage
        requestContext.HttpContext.Items["themeName"] = "Red";


        
        // Allow the Theme to be overriden via the querystring
        // If a Theme Name is Passed in the querystring then use it and override the previously set Theme Name
        // http://localhost/Default.aspx?theme=Red
        var previewTheme = requestContext.HttpContext.Request.QueryString["theme"];
        if (!string.IsNullOrEmpty(previewTheme))
        {
            requestContext.HttpContext.Items["themeName"] = previewTheme;
        }

        base.Execute(requestContext);
    }
}
Then you need to have all your Controller’s inherit from the ThemeControllerBase like the following:
public class HomeController : ThemeControllerBase { }
## Conclusion I’m sure some kind of Themes support is on the list for Microsoft to eventually implement into ASP.NET MVC some time down the road, but I have yet to see anything mentioned. Also, since the Official v1.0 release has already been released, I don’t really expect anything to change until presumably after .NET 4.0 ships; this is assuming the ASP.NET MVC 1.0 bits are what make it into .NET 4.0 and not a newer version of ASP.NET MVC. So, for now if you want to implement Themes into your ASP.NET MVC Website, you’ll just have to use the code I post here, or some other custom implementation.