您的位置:首页 > 编程语言 > ASP

Flexible theming for ASP.NET MVC

2011-10-26 20:05 375 查看
http://blogs.planetcloud.co.uk/mygreatdiscovery/post/Flexible-theming-for-ASPNET-MVC.aspx

I’m sure many people have experienced frustration when creating custom themes for various apps. Now if it was just a case of changing CSS then that would be fine. But often there is a need to change master pages and pages for either layout purposes or perhaps to add additional functionality or UI components.

Well since I’m loving MVC so much at the moment I thought I would look into how we could do theming. The great thing about ASP.NET MVC is that since you are just calling a controller action, you get so much control over the entire request. For example, you can return a view and override the master page that should be used:

public ActionResult Index()

{

ViewData["Message"] = "Welcome to ASP.NET MVC!";

return View("Index", "CustomMaster");

}

For my ideal theming solution I would want to:
1) Easily add my own CSS

2) Change site layout (master page)

3) Override specific views (perhaps I want to change some of the elements on a product detail page)

4) Do any of the above, or none at all – and everything just works.
ASP.NET MVC is built on a lot of convention. For example, the default ViewEngine looks in specific areas for the View names passed by controller actions. The great thing is, you can add your own custom ViewEngine and override how Views are located.
This is exactly what Chris Pietschmann did in his article ASP.NET MVC: Implement Theme Folders using a Custom ViewEngine.
In order to meet my theming requirements I made a few changes since I wanted it to be optional to override the default views.
So if http://www.mysite.com/catalog/product/groovy-shoes was requested, the ViewEngine would first look for a view called “Product” in the currently selected themes folder and if it did not exist then it would use the one in the /Views/Catalog/ directory.
I like this approach because it means that if you release a new version of your application and some of the views change then this won’t effect the custom views made by other people. In fact, if your styles no longer worked against a new version of the view, you could grab the view from the existing release (providing your model hadn’t changed) and just drop this inside your theme folder.
So to better explain, how about some visuals. The image below displays the default structure of my MVC project:



The “Default” views are in their default location. These are the ones we will develop with since things like the “New View” action in Visual Studio will pop the views in your /Views directory.
The site master page sits in the default location of /Views/Shared and CSS sits in /Content/css. So if I run my project and I get the default MVC site look and feel:



So now I decide that everything looks good, but I want to change the logo on my site and add in another menu item. To do this I just take a copy of the existing master page and drop it in my themes folder (maintaining the same directory structure):



Then I make my changes and run the site.



So now we have a custom master page but still using the default CSS. So let’s add our own custom stylesheet. Grab a copy of the default CSS stylesheet and paste into your themes folder (again, maintaining the same directory structure):



Currently the master page is referencing the default CSS so we need to change the CSS reference to point to our theme CSS. To do this I have written a few helper methods. The default method is shown below:

<link href="<%=AppHelper.CssUrl("site.css")%>" rel="stylesheet" type="text/css" />
Simply change to:

<link href="<%=AppHelper.ThemeCssUrl("site.css", "Default")%>" rel="stylesheet" type="text/css" />

Here we are passing the name of the theme in order to grab the correct stylesheet.
Note: Since I am preserving the same directory structure then a relative reference should have worked but it didn’t (and I don’t know why). If there is a better way of doing this then leave a comment. This approach seems clean enough and does get rid of any hardcoded paths.
Now when I run my site I pick up my custom stylesheet:



The final requirement was that I should be able to override any of the existing views. Sure I can change the styles using CSS but often the HTML markup isn’t quite what you need or perhaps you want to add some additional elements.
Let’s say I want to add another heading and add a widget that displays some product categories. Again, simply copy the index view into your theme directory:



Make your changes and launch the site:



The source code:

public CommerceViewEngine()

{

MasterLocationFormats = new string[] {

"~/Themes/{2}/Views/{1}/{0}.master",

"~/Themes/{2}/Views/Shared/{0}.master",

"~/Views/Shared/{0}.master"

};

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",

"~/Views/{1}/{0}.aspx",

"~/Views/{1}/{0}.ascx",

"~/Views/Shared/{0}.aspx",

"~/Views/Shared/{0}.ascx"

};

PartialViewLocationFormats = ViewLocationFormats;

}
#region Overrides
public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, stringmasterName, bool useCache)

{

if (controllerContext == null) {

throw new ArgumentNullException("controllerContext");

}

if (string.IsNullOrEmpty(viewName)) {

throw new ArgumentException("viewName must be specified.", "viewName");

}
if (controllerContext.Controller.GetType().BaseType == typeof(CommerceController)) {

var commerceController = controllerContext.Controller as CommerceController;

string themeName = controllerContext.HttpContext.Items["theme"] as string;
string controllerName = controllerContext.RouteData.GetRequiredString("controller");
string[] strArray, strArray2;
masterName = (string.IsNullOrEmpty(masterName)) ? "site" : masterName;
string viewPath = this.GetPath(controllerContext, this.ViewLocationFormats, "ViewLocationFormats",

viewName, themeName, controllerName, "View", useCache, out strArray);
string masterPath = this.GetPath(controllerContext, this.MasterLocationFormats, "MasterLocationFormats",

masterName, themeName, controllerName, "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<string>(strArray2));

}

else

{

return base.FindView(controllerContext, viewName, masterName, useCache);

}

}
public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, booluseCache)

{

if (controllerContext == null) {

throw new ArgumentNullException("controllerContext");

}
if (string.IsNullOrEmpty(partialViewName)) {

throw new ArgumentException("viewName must be specified.", "viewName");

}
if (controllerContext.Controller.GetType().BaseType == typeof(CommerceController))

{

var commerceController = controllerContext.Controller as CommerceController;

string themeName = controllerContext.HttpContext.Items["theme"] as string;

string controllerName = controllerContext.RouteData.GetRequiredString("controller");
string[] strArray;

string partialViewPath = this.GetPath(controllerContext, this.PartialViewLocationFormats,

"PartialViewLocationFormats", partialViewName, themeName, controllerName, "Partial", useCache, outstrArray);
if (string.IsNullOrEmpty(partialViewPath)) {

return new ViewEngineResult(strArray);

}

return new ViewEngineResult(this.CreatePartialView(controllerContext, partialViewPath), this);

} else {

return base.FindPartialView(controllerContext, partialViewName, useCache);

}

}
protected override bool FileExists(ControllerContext controllerContext, string virtualPath)

{

try {

return File.Exists(controllerContext.HttpContext.Server.MapPath(virtualPath));

} catch (HttpException exception) {

if (exception.GetHttpCode() != 0x194)

throw;

return false;

} catch {

return false;

}

}
#endregion
#region Utilities
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, refstring[] 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;

}
#endregion

We are also setting the theme on our custom controller (currently hardcoded):

public class CommerceController : Controller

{

string _themeName;

public string ThemeName

{

get {

if (string.IsNullOrEmpty(_themeName))

_themeName = "Default";

return _themeName;

}

set {

_themeName = value;

}

}
protected override void Execute(System.Web.Routing.RequestContext requestContext)

{

requestContext.HttpContext.Items["theme"] = this.ThemeName;

base.Execute(requestContext);

}

}

One thing that does not seem to be working currently is the caching of the view locations. Whilst ViewLocationCache.InsertViewLocation certainly fires, it does not appear to store anything. I have emailed Chris and posted to StackOverflow to try and find out why (perhaps a difference in MVC 2.0 RC?).
Enjoy!
[Update]
The ViewLocationCache is disabled by default when running in debug mode. If you want to enable it for testing purposes, see my post here.
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: 
相关文章推荐