Serving different views for mobile devices in ASP.NET MVC

Serving different views for mobile devices in ASP.NET MVC

Monday 3 May 2010

As browsing becomes more commonplace on phones, sub-notebooks and (within the next year or so) Tablet PCs, there’s an increased appetite to tailor your user experience for people using these “non-desktop” devices.  You can leverage your existing application infrastructure without having to create costly or outsource applications for specific (cough iPhone) platforms that have lots of market share, to the exclusion of others (cough iPhone).

There are some excellent examples of this in the wild, Facebook and the BBC do a great job of this already.

So what about us ASP.NET MVC types?  Well thankfully, there’s a lot of stuff built in to the framework to allow us to do this with relative ease.  First you’re going to need a few things..

  1. Go grab the latest Mobile Device Browser File from http://mdbf.codeplex.com.  If you’re familiar with the old “Browser Caps”, it’s the same sort of thing.  A regularly updated collection of device data.  It’s pretty interesting on it’s own, breaking down incoming devices by user agent / headers and offering you various stats (touch enabled, screen resolution etc).  You’ll probably want to keep this up to date periodically.

  2. A new ViewEngine!  Thankfully, ASP.NET MVC has a pretty flexible pipeline when it comes to slotting in new ViewEngines.  The most transparent way to switch out which view you’re serving for any given request is to override some of the logic in the default ViewEngine to look elsewhere when a View is requested by the MVC framework.

  3. A way to test this stuff.  I normally break out the “User Agent Switcher” FireFox plugin which you can grab here: https://addons.mozilla.org/en-US/firefox/addon/59

 

The View Engine

In order to keep this as close to vanilla MVC as possible, I’m going to extend the regular WebFormsViewEngine to add mobile device detection and view overriding.  What I’m going to do is check for a mobile browser when a request reaches the view engine, and if one is found, add some extra paths to look for the view files in at the top of the list of locations searched.  By doing this, we can prioritise the mobile edition of a website if the user is visiting from a phone, while degrading gracefully, allowing the regular version of the website to be served if a mobile version of a given page isn’t available.  This actually allows us to support mobile views piece by piece rather than forcing us to support the entire site out of the box.

Unfortunately, a few of the methods surrounding view resolution are marked as private in the WebFormsViewEngine and as such are inaccessible.  I’ve had to reflect in and copy a couple of methods to get around this.  Ideally the access modifier on these could be changed in later versions of the framework.

The key method we have to work with is

public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache) { … }

What we’re going to do is add a few extra locations based on the browser type.  With the mobile device browser file installed, this is really simple:

public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache) { if (controllerContext == null) { throw new ArgumentNullException("controllerContext"); } if (String.IsNullOrEmpty(viewName)) { throw new ArgumentException("viewName"); }

List<string> viewLocationsSearched; List<string> masterLocationsSearched;

string[] viewLocationsToSearch = ViewLocationFormats; string[] masterLocationsToSearch = MasterLocationFormats;

viewLocationsToSearch = AddMobileViewLocations(controllerContext, viewLocationsToSearch, MobileViewLocationFormats); masterLocationsToSearch = AddMobileViewLocations(controllerContext, masterLocationsToSearch, MobileMasterLocationFormats);

string controllerName = controllerContext.RouteData.GetRequiredString(“controller”); string viewPath = GetPath(controllerContext, viewLocationsToSearch, viewName, controllerName, CacheKeyPrefixView, useCache, out viewLocationsSearched); string masterPath = GetPath(controllerContext, masterLocationsToSearch, masterName, controllerName, CacheKeyPrefixMaster, useCache, out masterLocationsSearched);

if (String.IsNullOrEmpty(viewPath) || (String.IsNullOrEmpty(masterPath) && !String.IsNullOrEmpty(masterName))) { return new ViewEngineResult(viewLocationsSearched.Union(masterLocationsSearched)); }

return new ViewEngineResult(CreateView(controllerContext, viewPath, masterPath), this); }

You’ll notice there’s a few methods that get called in there.  The most important of which is “AddMobileViewLocations”.  This really is where all the legwork is done, and looks like this

public class SwitchingViewEngine : WebFormViewEngine { private const string CacheKeyFormat = ":ViewCacheEntry:{0}:{1}:{2}:{3}:"; private const string CacheKeyPrefixMaster = "Master"; private const string CacheKeyPrefixView = "View"; private static readonly List<string> EmptyLocations = new List<string>();

protected string[] MobileViewLocationFormats { get; private set; } protected string[] MobileMasterLocationFormats { get; private set; }

public SwitchingViewEngine() { ViewLocationFormats = new[] { “/Views/{1}/{0}.aspx”, “/Views/{1}/{0}.ascx”, “/Views/Shared/{0}.aspx”, “/Views/Shared/{0}.ascx” };

MobileViewLocationFormats = new[] &#160 ;& #160;                        { “/Views/{1}/{0}.mobile.aspx”, “/Views/{1}/{0}.mobile.ascx”, “/Views/Shared/{0}.mobile.aspx”, “/Views/Shared/{0}.mobile.ascx” };

MasterLocationFormats = new[] {“/Views/{1}/{0}.master”, “/Views/Shared/{0}.master”}; MobileMasterLocationFormats = new[] {“/Views/{1}/{0}.mobile.master”, “/Views/Shared/{0}.mobile.master”}; }

public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache) {   …    }

private static string[] AddMobileViewLocations(ControllerContext controllerContext, string[] viewLocationsToSearch, IEnumerable<string> mobileViewLocations) { if (controllerContext == null || controllerContext.HttpContext == null || controllerContext.HttpContext.Request == null || controllerContext.HttpContext.Request.Browser == null || viewLocationsToSearch == null || viewLocationsToSearch.Length == 0 || mobileViewLocations == null || mobileViewLocations.ToList().Count == 0 || !controllerContext.HttpContext.Request.Browser.IsMobileDevice) { return viewLocationsToSearch; }

var mobileViews = viewLocationsToSearch.ToList(); foreach (var view in mobileViewLocations.Reverse()) { mobileViews.Insert(0, view); }

viewLocationsToSearch = mobileViews.ToArray();

return viewLocationsToSearch; }

This method takes the current ViewLocations that are defined at the top of the class, does a bunch of guard checks (to prevent mobile switching crashing your request.. call me paranoid), then verifies that controllerContext.HttpContext.Request.Browser.IsMobileDevice is true.  This check makes use of the browser device file.  If all the checks pass, it inserts the new “mobile view paths” at the very top of the list of paths to be searched when resolving the location of a view file.  The calling method then subsequently calls the method “GetPath” (which is one of the private methods I’ve had to reflect out of the framework source code).  GetPath searches the supplied list of potential view locations, and returns as soon as it finds a match.  In our case, if the browser is mobile, and a view file with the extension mobile.aspx is found, this will be the first view resolved and returned.

The full code listing for the view engine (don’t worry, there’s a link at the bottom to grab all of this in one archive):

using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Web.Mvc;

namespace MultipleViewMvcExample.DemoCode { public class SwitchingViewEngine : WebFormViewEngine { private const string CacheKeyFormat = “:ViewCacheEntry:{0}:{1}:{2}:{3}:”; private const string CacheKeyPrefixMaster = “Master”; private const string CacheKeyPrefixView = “View”; private static readonly List<string> EmptyLocations = new List<string>();

protected string[] MobileViewLocationFormats { get; private set; } protected string[] MobileMasterLocationFormats { get; private set; }

public SwitchingViewEngine() { ViewLocationFormats = new[] { “/Views/{1}/{0}.aspx”, “/Views/{1}/{0}.ascx”, “/Views/Shared/{0}.aspx”, “/Views/Shared/{0}.ascx” &#1

60;                       };

MobileViewLocationFormats = new[] { “/Views/{1}/{0}.mobile.aspx”, “/Views/{1}/{0}.mobile.ascx”, “/Views/Shared/{0}.mobile.aspx”, “/Views/Shared/{0}.mobile.ascx” };

MasterLocationFormats = new[] {“/Views/{1}/{0}.master”, “/Views/Shared/{0}.master”}; MobileMasterLocationFormats = new[] {“/Views/{1}/{0}.mobile.master”, “/Views/Shared/{0}.mobile.master”}; }

public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache) { if (controllerContext == null) { throw new ArgumentNullException(“controllerContext”); } if (String.IsNullOrEmpty(viewName)) { throw new ArgumentException(“viewName”); }

List<string> viewLocationsSearched; List<string> masterLocationsSearched;

string[] viewLocationsToSearch = ViewLocationFormats; string[] masterLocationsToSearch = MasterLocationFormats;

viewLocationsToSearch = AddMobileViewLocations(controllerContext, viewLocationsToSearch, MobileViewLocationFormats); masterLocationsToSearch = AddMobileViewLocations(controllerContext, masterLocationsToSearch, MobileMasterLocationFormats);

string controllerName = controllerContext.RouteData.GetRequiredString(“controller”); string viewPath = GetPath(controllerContext, viewLocationsToSearch, viewName, controllerName, CacheKeyPrefixView, useCache, out viewLocationsSearched); string masterPath = GetPath(controllerContext, masterLocationsToSearch, masterName, controllerName, CacheKeyPrefixMaster, useCache, out masterLocationsSearched);

if (String.IsNullOrEmpty(viewPath) || (String.IsNullOrEmpty(masterPath) && !String.IsNullOrEmpty(masterName))) { return new ViewEngineResult(viewLocationsSearched.Union(masterLocationsSearched)); }

return new ViewEngineResult(CreateView(controllerContext, viewPath, masterPath), this); }

private static string[] AddMobileViewLocations(ControllerContext controllerContext, string[] viewLocationsToSearch, IEnumerable<string> mobileViewLocations) { if (controllerContext == null || controllerContext.HttpContext == null || controllerContext.HttpContext.Request == null || controllerContext.HttpContext.Request.Browser == null || viewLocationsToSearch == null || viewLocationsToSearch.Length == 0 || mobileViewLocations == null || mobileViewLocations.ToList().Count == 0 || !controllerContext.HttpContext.Request.Browser.IsMobileDevice) &#

160;   { return viewLocationsToSearch; }

var mobileViews = viewLocationsToSearch.ToList(); foreach (var view in mobileViewLocations.Reverse()) { mobileViews.Insert(0, view); }

viewLocationsToSearch = mobileViews.ToArray();

return viewLocationsToSearch; }

private string GetPath(ControllerContext controllerContext, string[] locations, string name, string controllerName, string cacheKeyPrefix, bool useCache, out List<string> searchedLocations) { searchedLocations = EmptyLocations; if (string.IsNullOrEmpty(name)) { return string.Empty; } if ((locations == null) || (locations.Length == 0)) { throw new InvalidOperationException(“Property cannot be null or empty.”); } bool flag = IsSpecificPath(name); string key = CreateCacheKey(cacheKeyPrefix, name, flag ? string.Empty : controllerName); if (useCache) { string viewLocation = ViewLocationCache.GetViewLocation(controllerContext.HttpContext, key); if (viewLocation != null) { return viewLocation; } } if (!flag) { return GetPathFromGeneralName(controllerContext, locations, name, controllerName, key,ref searchedLocations); } return GetPathFromSpecificName(controllerContext, name, key, ref searchedLocations); } private static bool IsSpecificPath(string name) { char ch = name[0]; if (ch != ’~’) { return (ch == ’/’); } return true; }

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

private string GetPathFromGeneralName(ControllerContext controllerContext, string[] locations, string name, string controllerName, string cacheKey, ref List<string> searchedLocations) { string virtualPath = string.Empty; searchedLocations = new List<string>(); for (int i = 0; i < locations.Length; i++) { string str2 = string.Format(CultureInfo.InvariantCulture, locations[i], new object[] {name, controllerName}); if (FileExists(controllerContext, str2)) { searchedLocations = EmptyLocations; virtualPath = str2; ViewLocationCache.InsertViewLocation(controllerContext.HttpContext, cacheKey, virtualPath); return virtualPath; } searchedLocations[i] = str2; } return virtualPath; }

private string CreateCacheKey(string prefix, string name, string controllerName) { return String.Format(CultureInfo.InvariantCulture, CacheKeyFormat, GetType().AssemblyQualifiedName, prefix, name, controllerName); } } }

 

The Wiring

Now you have your view engine, you need to register it as the default view engine in your MVC application.  Easy!  Open up your Global.aspx.cs file and add the following to ApplicationStart
ViewEngines.Engines.Clear(); ViewEngines.Engines.Add(new SwitchingViewEngine());
Done!

Now you need to actually add the browser detection file to your application.  Presuming you downloaded the latest archive from the codeplex url at the top of this article, all you need to do is copy the supplied mobile.browser file to App_Browsers/Device/* in your MVC application.

That’s all the wiring you need to get everything up and running.

If you’ve done it right, a default “New MVC Template” project with these additions might look something like this:

image

For the sake of this demo, I put the view engine in a “DemoCode” sub-namespace. You don’t want to do that, put it somewhere sensible!

The more astute reader might now notice that my Views/Home directory in the above screenshot has an extra file, not supplied by the template, called “Index.mobile.aspx”.  Likewise, my /Views/Shared directory has Site.mobile.Master.  These are files I want the view engine to resolve if a user hits the http://localhost/Home default route from a mobile device.

Index.mobile.aspx looks like this:

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.mobile.Master" Inherits="System.Web.Mvc.ViewPage" %>

<asp:Content ID=“indexTitle” ContentPlaceHolderID=“TitleContent” runat=“server”> Mobile Home Page </asp:Content>

<asp:Content ID=“indexContent” ContentPlaceHolderID=“MainContent” runat=“server”> <h2><%= Html.Encode(ViewData[“Message”]) %></h2> This is my mobile index page. </asp:Content>

and Site.mobile.Master looks like this:

<%@ Master Language="C#" Inherits="System.Web.Mvc.ViewMasterPage" %>

<!DOCTYPE html PUBLIC ”-//W3C//DTD XHTML 1.0 Strict//EN” “http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd”> <html xmlns=“http://www.w3.org/1999/xhtml”> <head runat=“server”> <title><asp:ContentPlaceHolder ID=“TitleContent” runat=“server” /></title> </head>

<body> <div class=“page”>

<div id=“header”> <div id=“title”> <h1>My MVC Application - mobile master page</h1> </div> <div id=“logindisplay”> <% Html.RenderPartial(“LogOnUserControl”); %> </div> <div id=“menucontainer”> <ul id=“menu”> <li><%= Html.ActionLink(“Home”, “Index”, “Home”)%></li> <li><%= Html.ActionLink(“About”, “About”, “Home”)%></li> </ul> </div> </div>

<div id=“main”> <asp:ContentPlaceHolder ID=“MainContent” runat=“server” />

<div id=“footer”> </div> </div>

</div> </body> </html>

Nothing especially revolutionary.

 

Testing

First, install that FireFox plugin I mentioned at the top of the article, create a new MVC project (just use the template), and wire up the view engine and browser file.  Add some extra views with the .mobile.aspx prefix.  Or download the sample attached to the bottom of this post!

Start the site up in Cassini (Visual Studios default web server) and hit /Home.  You should see this:

image

Now, lets use the new FireFox plugin…

image

Select iPhone 3.0 from that menu and refresh the page…

image

Bang!  The more eagle eye reader might have notice that all I did in my “mobile” master page, was delete the CSS reference from the default MVC template, thus the above screenshot.

And that’s it.

 

Further Thoughts

This solution goes quite a way, but here are a few other ideas:
  • Don’t just do browser type detection, detect and switch on subdomain, so any visitors hitting http://m.mysite.com get a different view.
  • Allow the user to opt-out of the reduced view with a session cookie.
  • Switch views to a low-fi version to victimise IE6 users!
  • Target tablet PCs based on resolution to build a “touch UI”
It’s all pretty simple.  The really nice thing about this solution is that your designers can just add these mobile views as and when they see fit, as the same action methods that execute for the “full fat” website are run, and the same strongly typed view models (which you’re using, right? get out of here with your ViewData..) are delivered.  Designers can implement portable websites, piece by piece, just with a little view engine change.

As with all internet code, your mileage may vary, but this technique works for me.

You can download a working VS2008 solution (so long as you have ASP.NET MVC1 installed on your system) containing all the code used above from: http://github.com/davidwhitney/MultipleViewMvcExample