Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Custom View Paths and View Hierarchies in MVC Core

0.00/5 (No votes)
17 Nov 2016 1  
MVC Core opens new ways to managing View locations and enables moving from the flat project structure of the Views folder to a layered, nested, or distributed Views project structure.

Introduction

In previous versions of MVC placing your views in custom directories was painful and required writing custom ViewEngines. Creating multilevel, hierarchial, or complex project structures was difficult to implement, and performance could be a problem. With MVC Core putting your views anywhere you want them is pretty simple. This approach makes it easier, more configurable, and even more performant.

Background

Problems with the Default

By default MVC wants views to exist in a subfolder of the Views folder. In the MVC default configuration, when you call "returnView();" MVC tries to find the view by searching a subfolder of the Views folder with the name of the controller the action is in (minus any xController suffix), looking for a file with the name of the method with a ".cshtml" extension. This makes simple things simpler, but can make complex things much more complex. It leads to a very flat application structure which could have dozens of folders in the View folder. This inability to group and nest has some side effects that can create additional work.

For example, if I want to apply a different layout to a set of views across multiple controllers I need to declare the layout in each of these views. If the layouts are under the same parent folder, MVC can find them by name alone. But if they are somewhere else, we need to resort to full path names, and that means managing all of these references manually.

In our problem spaces things are rarely flat structures, and the larger they get the less flat they tend to become. 

Old Solutions

Traditionally we have had two tools to help us with this. MVC supports Areas, which provide a second level of grouping. We can put our views under areas and we have a two level structure. This helps some of the issues a little, but I've always found areas a little weak and two levels to rarely be enough.

The other tool we have had available was custom ViewEngines. These helped define multiple search paths, but had limited dynamic capability.

New Tool

With MVC Core we have a first class tool for specifying alternate view search paths - the ViewLocationExpander. Not only does this tool extract the ViewPath work from the ViewEngine, it can be efficiently inserted into the page lifecycle. This allows us to create targeted searches on a per request basis. With this capability we can put our views anywhere we want, in many directories, and not have to search them all on every request. This opens up many possibilities for structuring your projects anyway you want - even completly eliminating a Views folder and conventions altogether, without having to hardcode every view path. For this article I'm going to stick with the standard approach and View folder, but you can easily us this approach to make things a custom as you like.

The Approach

The goal of this article is to simply relocate our Views folder to any arbitrary location and still maintain the convention approach of having our views automatically linked to our controllers. To do this we will use a CustomAttribute on our controllers.

There are four steps to using this approach.

  1. Create a custom attribute.
  2. Create a custom ViewLocationExpander
  3. Update your Startup.cs to use the expander
  4. Decorate your controller with the attribute.

Custom Attribute

Our first step is to create a CustomAttribute. The attribute simply allows us to put a single sting metavalue on our controller called ViewPath. The simple example allows us to target any class, but this can be tightened for more robust scenarios. Create this class anywhere you like.

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ViewPathAttribute : Attribute {
 private string _viewPath;
 
 public ViewPathAttribute(string viewPath) {
  this._viewPath = viewPath;
 }
 
 public string ViewPath { get; set; }
}

 

ViewLocationExpander

Next, we create a ViewLocationExpander that implements the IViewLocationExpander interface. Besides processing the attribute, the example expander here does a few additional things to show what you can do with it.  In this example I also renamed the Shared folder to _Shared, and added a _Partials directory to the search path. You can set up any type of structure or convention you want here.

I won't go into the details of expanders, but basically the expander is called during the page lifecycle intelligently. It has access to the the action context and controller that is processing the view. To get the ViewPath attribute we read it from ControllerTypeInfo into the variable "viewpath". Then we create a linked list of paths (additionalLocations) and start adding the search paths we want to use. In this case we add a fixed list, but you can include additional logic to minimize the list size.

If the attribute is found we add our custom paths. The first three paths add the standard controller/action and action conventions, and Shared (renamed as _Shared) paths. The next adds a new paths to create a custom project structure where we keep out partials in a _Partials directory. In the last section we add the default conventions in too, as well as a _Partials folder. In the final line I also show how you can rename the Shared folder to _Shared if you are inclined.

You can create this class anywhere you like.

public class CustomViewLocationExpander : IViewLocationExpander {
 public void PopulateValues(ViewLocationExpanderContext context) {
 
 }
 public virtual IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations) {
 
  // Get Attribute Value
  var descriptor = (context.ActionContext.ActionDescriptor as ControllerActionDescriptor);
  var viewPath = descriptor.ControllerTypeInfo.CustomAttributes?.FirstOrDefault(rc => rc.AttributeType == typeof(ViewPathAttribute))?.ConstructorArguments[0].Value.ToString();
  
  var additionalLocations = new LinkedList<string>();
 
  // If the attribute exists it will add this
  if (viewPath != null) {
   additionalLocations.AddLast($"/{viewPath}/Views/{{1}}/{{0}}.cshtml");
   additionalLocations.AddLast($"/{viewPath}/Views/{{0}}.cshtml");
   additionalLocations.AddLast($"/{viewPath}/Views/_Shared/{{0}}.cshtml");
   additionalLocations.AddLast($"/{viewPath}/Views/_Partials/{{0}}.cshtml");
  }
 
  // if not it will use this
  additionalLocations.AddLast("/Views/{1}/{0}.cshtml");
  additionalLocations.AddLast("/Views/{0}.cshtml");
  additionalLocations.AddLast("/Views/_Partials/{0}.cshtml");
  return viewLocations.Concat(additionalLocations).Select(x => x.Replace("/Shared", "/_Shared"));
 
 }
}

Edit Startup.cs

To get MVC to use the expander we need to configure it during startup. To do this we edit the Startup.cs file ConfigureService method

public void ConfigureServices(IServiceCollection services)
{

by adding this code:

services.Configure<RazorViewEngineOptions>(options =>
{
 var expander = new CustomViewLocationExpander();
 options.ViewLocationExpanders.Add(expander);
});

Once this is done MVC is ready to use the custom paths.

Edit Controllers

In our case we are working at controller level, so we need to assign the view path to our controller. All we need to do is decorate a controller with the ViewPath attribute and path to the views. The path base is the application root.

By default Views is also at the root, and the page for our HomeController are at <App>/Views/Home. We are going to rebase these views under "SiteHome", so we add "[ViewPath("SiteHome")]" as the attribute. Now our views for the HomeController (and only the HomeController) are searched for at <app>/SiteHome/Views. Note that since we left our default paths they will be found in either place. For tighter control you can not add back the defaults in the extender.

[ViewPath("SiteHome")]
public class HomeController : Controller
{
 public IActionResult Index()
 {
  return View();
 }
}

The path on the attribute can be any path fragment, providing any level of hierarchy or grouping you like, e.g., "[ViewPath("Domains/Person/Employee")]".

Test It

To test this example create a new project and follow the steps above. Create a directory called "SiteHome" to the root of your project and move your entire Views folder under it. Since we renamed Shared, rename the folder "Shared" to "_Shared". Run your application and it will find your views in the new location.

Conclusion

The ability to place your views anywhere you like and create any level of hierarchy or nesting opens many possibilities for project structures. For example, during development I will often keep a temp set of scaffolding generated controller/view sets around for testing and entering test data of my domain entities. I can create a folder <app>/Dev/Domains/Entities/Person/Views, and add a working controller with [ViewPath("Dev/Domains/Entities/Person")] (also in the Dev branch). When I am done with them I can simply delete the Dev folder and they are cleaned away without impacting anything else.

While this demo implemented this concept at the Controller level, the attribute can also be applied at the action level. This opens a whole other world of architectures and can help create some interesting CQRS approaches.

This was my first CodeProject article. Hope you liked it!

History

 

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here