OdeToCode IC Logo

Mixing ASP.NET MVC Display Mode Providers and Routing Rules

Thursday, December 6, 2012

You can build custom views for mobile devices in ASP.NET MVC 4 using the DisplayModeProvider features of the framework (see "Browser Specific Views" of the "ASP.NET MVC 4 Mobile Features" article for more details). The default view switching behavior happens by ultimately looking at the incoming user agent header to determine if the request came from a a mobile device, but display modes can do more than just peek at user agent strings.

Let's say we don't want a user agent or cookie to tell us which view to use. Instead, we want information in the path of the URL to tell us the view to use.

For example, the following URL should always use a "normal" view like Layout.cshtml:

/Home/About

And the next URL should always render a mobile view, like Layout.Mobile.cshtml, when a mobile view is available.

/Mobile/Home/About

Both URLs should go to the same controller action and we only want to influence the view selection. One possible solution to the problem is to have display modes and routing work together.

Routes & Display Modes

The first thing we'll do is register a new route before the default route. This "DefaultMobile" route will only match URLs starting with "mobile/". When the route does match it will add a mobile=true entry to the route data dictionary.

routes.MapRoute(
   name: "DefaultMobile",
   url: "mobile/{controller}/{action}/{id}",
   defaults: new
                 {
                     mobile = true,
                     controller = "Home",
                     action = "Index",
                     id = UrlParameter.Optional
                 }
);

Also during application startup, we'll modify the default "mobile" display mode to look at route data values instead of looking up device capabilities with the user agent string.

var mobileMode = DisplayModeProvider.Instance.Modes
                 .OfType<DefaultDisplayMode>()
                 .First(m => m.DisplayModeId == "Mobile");
                 
mobileMode.ContextCondition = ctx =>
    {
        var handler = ctx.Handler as MvcHandler;
        if(handler != null)
        {
            var routeData = handler.RequestContext.RouteData;
            return routeData.Values["mobile"] != null;
        }
        return false;
    };

This is all you need to have /Home/About use regular views, and have /Mobile/Home/About use a mobile view.

Good night, and good luck.

You Broke My Links!

Oh, right, you might notice that the following ActionLink will always generate a URL like /Mobile/Home/About, even when you are on a non-mobile view.

@Html.ActionLink("About", "About", "Home")

The ActionLink helper talks to the routing engine to figure out the URL needed to reach the About action of the Home controller. Since the "DefaultMobile" route we added to the route table is greedy and can reach any controller, it will happily respond to any ActionLink that just specifies controller, action, and optionally an ID.

We need to constrain the "DefaultMobile" route so it only gets involved in generating URLs when we are generating links during a "mobile" request.

Adding A Route Constraint

What follows is a route constraint that will only match a route when a given key (specified by parameterName) is in the route data dictionary.

public class RouteValuePresent : IRouteConstraint
{
    public bool Match(HttpContextBase httpContext,
                      Route route, string parameterName,
                      RouteValueDictionary values,
                      RouteDirection routeDirection)
    {
        if (values.ContainsKey(parameterName))
        {
            return true;
        }
        return false;
    }
}

We could also check the routeDirection parameter to only perform checks during link generation (RouteDirection.UrlGeneration), but the constraint also works for incoming request matching, too (since route data is already in place).

Now we just need to add the constraint to the "DefaultMobile" route we added earlier.

routes.MapRoute(
   name: "DefaultMobile",
   url: "mobile/{controller}/{action}/{id}",
   defaults: new
                 {
                     mobile = true,
                     controller = "Home",
                     action = "Index",
                     id = UrlParameter.Optional
                 },
    constraints: new { mobile = new RouteValuePresent() }
);

I'm sure there are other possible solutions, but I rather like the explicit route entry to know something magical happens when "mobile" is in the path.