Localising MVC Views using Display Modes

When considering localisation/globalisation/internationalisation of an MVC website there are a lot of routes you can take. The easiest of these (and therefore the most tempting) is to use a Resources.resx file to handle string replacement, but there are scenarios where you need to do more than just replace some text.

You might need to rearrange the page to handle right-to-left languages, you might need to change a background image that is particularly offensive in some countries; either way, you’re looking at writing a specific view for a specific culture.

Custom Per-Culture Views

While googling for a solution I found quite a few examples (such as this blog post from Brian Reiter) that work something like this:

  1. Create a folder structure like the one below for your culture-specific views
  2. Subclass WebFormViewEngine or RazorViewEngine to transform the view path to the new folder structure

In the example above, any visitor with a culture of en-GB will see /Views/i18n/en-GB/Home/Index.cshtml; anyone else will see the default view.

In many ways this is a very elegant solution: it doesn’t require you to create localised versions of every view; it elegantly falls back to sensible defaults; and you don’t have to write a lot of custom code. Unfortunately it does have a couple of problems:

  • It requires that you subclass a specific -ViewEngine implementation
  • It doesn’t support localised versions of master pages
  • It doesn’t work with the MVC mobile features introduced in MVC4

The first is not necessarily a show-stopper, but the mobile support issue is a pretty big deal for my scenario and the lack of master page localisation could cause problems.

“Borrowing” from Mobile Features

Seeing as the mobile features were the main driver behind my need to change, it makes sense to use the same mechanism for path-transformation that they use: display modes.

Display Modes (Simple Version)

Display modes allow you to set up special suffixes (suffices?) such as “Mobile” that, under certain circumstances, ASP.NET will look for before looking for the standard .cshtml. For example, the following would set up an iPhone suffix that would be considered whenever the user agent of a request includes the string “iPhone”:

DisplayModeProvider.Instance.Modes.Insert(0, new DefaultDisplayMode("iPhone")
{
    ContextCondition = (context => context.Request.UserAgent
        .IndexOf("iPhone", StringComparison.OrdinalIgnoreCase) >= 0)
});

This means that when a user browses to your site using an iPhone, ASP.NET will try to find a /Views/Controller/Index.iPhone.cshtml before falling back to /Views/Controller/Index.cshtml.

Display Modes (Slightly-More-Detailed Version)

So how does the example above actually work?

The DefaultDisplayMode class in the example is part of MVC and is responsible for the transformation of a virtual path and for checking if it exists. By adding it to the DisplayModeProvider.Instance.Modes collection at index zero, we are telling ASP.NET that it should check this display mode before any others.

To determine which mode to use, each IDisplayMode instance that is added to the DisplayModeProvider.Instance.Modes collection will have the GetDisplayInfo method called in the order that they are added.

public DisplayInfo GetDisplayInfo(HttpContextBase httpContext, string virtualPath, Func<string, bool> virtualPathExists)
{
	//...
}

If the instance is able to return a valid view path then it does so by returning an instance of DisplayInfo; if not, it returns null and the next element in the Modes collection is queried.

So how can we use this to handle localisation?

Handling Localisation with Display Modes

The first step is to subclass DefaultDisplayMode so we have somewhere to implement our localisation path transformation. We could implement IDisplayMode directly, but we get some things for free by using the existing implementation:

public class LocalisedDisplayMode : DefaultDisplayMode
{
	private string _suffix;

	public LocalisedDisplayMode(string suffix)
		: base(suffix)
	{
		_suffix = suffix;
	}
}

All we are doing here is accepting a suffix in the constructor that we pass to the base class as well as keeping a reference for ourselves – we’ll need it later.

Next up we want to override the implementation of GetDisplayInfo so that we can transform the path to the localised equivalent.

public override DisplayInfo GetDisplayInfo(
	HttpContextBase httpContext,
	string virtualPath,
	Func<string, bool> virtualPathExists)
{
    //...	
}

The 3 parameters are:

  • The context of the current request
  • The current virtual path
  • An anonymous helper function to determine whether or not a given virtual path exists

Our implementation needs to:

  1. Get the appropriate culture for the user
  2. Check to see if a custom view exists for the specific culture (e.g. en-GB)
  3. If not, check to see if a custom view exists for the 2-character culture (e.g. en)
  4. If not, return null to indicate that this display mode cannot handle the request

Acquiring User Culture

We can get an appropriate culture for the user through a number of mechanisms – Accept-Language headers/Request.UserLanguages, storing a cookie, using CultureInfo.CurrentUICulture etc. – and how this is sourced doesn’t really matter for this example. For now, I’m going to leave it as an abstract method:

protected abstract CultureInfo GetCulture();

Selecting a View Path

Let’s assume that a request for ~/Views/Home/Index.cshtml is made from a user with a culture of es-ES, and our display mode has been set up with a suffix of “suffix”. We want to check the following paths (in order) to see if they exist:

  • ~/Views/i18n/es-ES/Home/Index.suffix.cshtml
  • ~/Views/i18n/es/Home/Index.suffix.cshtml

We’re not worried about looking in /Shared or in checking the non-localised paths – those will be covered by default display modes.

public override DisplayInfo GetDisplayInfo(
	HttpContextBase httpContext,
	string virtualPath,
	Func<string, bool> virtualPathExists)
{
	var culture = this.GetCulture();

	return
		TryGetDisplayInfo(virtualPath, culture.Name, virtualPathExists) ??
		TryGetDisplayInfo(virtualPath, culture.TwoLetterISOLanguageName, virtualPathExists);
}

private DisplayInfo TryGetDisplayInfo(string virtualPath, string cultureString, Func<string,bool> virtualPathExists)
{
	//use the base class to handle suffix transformation
	var transformedPath = this.TransformPath(virtualPath, _suffix);
		
	//use a regular expression to replace the view folder
	var cultureSpecificPath = Regex.Replace(
				transformedPath,
				"^~/Views/",
				string.Format("~/Views/i18n/{0}/", cultureString));

	//return the virtual path if it exists...
	if (virtualPathExists(cultureSpecificPath))
		return new DisplayInfo(cultureSpecificPath, this);

	//...or null if it doesn't
	return null;
}

Here we have implemented a helper method that will transform and check a path, then return a DisplayInfo instance if it exists. The null-coalescing operator in GetDisplayInfo will fallback to each less-specific view path in turn.

Plugging it in

The final step is to insert the new display mode into the configuration. We do this in the Global.asax file as below:

protected void Application_Start()
{
	DisplayModeProvider.Instance.Modes.Insert(0, new LocalisedDisplayMode(DisplayModeProvider.DefaultDisplayModeId));
	DisplayModeProvider.Instance.Modes.Insert(0, new LocalisedDisplayMode(DisplayModeProvider.MobileDisplayModeId)
		{ ContextCondition = context => context.GetOverriddenBrowser().IsMobileDevice });

	//...
}

We are inserting 2 instances so we can mimic the behaviour of the 2 default display modes (Default and Mobile), and we are inserting them at the top of the list so that they take precedence over the defaults.

Now we can create localised versions of master pages, views, partial views, mobile views…pretty much everything!

As an added bonus, this implementation is reusable between both Razor and Web Forms, and is much easier to unit test than the subclassed-view-engine approach.

Advertisements

2 thoughts on “Localising MVC Views using Display Modes

  1. It appears that this breaks when you run it outside of the debugger because the views start to become cached using a key generated from the DisplayModeId (One per DisplayModeId) without any regard to the current culture. Some additional steps are necessary for any non-debugging environment.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s