Confounding URL typists since 2007.
The “mobile web” may look more like the web we know and love as devices get more powerful, but there will always be a place for a bit of customization in the name of improved UX on mobile browsers. CSS media queries go a long way toward accomplishing this, but you may find yourself wanting to do more. Ask around or do some searches, and you’ll find the usual answer to supporting mobile devices in Rails involves adding a MIME alias for text/html named “iphone” or “mobile” with register_alias, then forcing a mobile request’s format to this MIME for use with respond_to. This can work, but it’s a huge pain in the neck, and I believe there’s a better way. Read on.
A. I am lazy.
Let me explain: If you specify a custom format for a request, your app needs a corresponding template for every single action a mobile browser might access, or you’ll get a fun error message like this:
There’s no way I’m gonna create two files for each action, even when they’re identical. My ideal solution should use a mobile template if it’s available, but fall back to a sensible default if not. Convention over configuration, right? Right.
So, let’s tackle this in two main steps. First, let’s set up our mobile app at m.mydomain.com. Requests made to the “m” subdomain will use our mobile templates, if they exist. Once we have that working, we’ll handle user agent detection and allow toggling between the mobile and full version of our app (because sites that don’t allow you to get at the full version even when your device is capable of rendering it are downright rude).
When Rails sets out to render one of our templates it looks for them in our controller’s array of view paths. Normally, this is going to contain app/views. View paths work much like the $PATH environment variable in your shell. If you type a command into your shell, your system goes through the directories listed in your $PATH, in order, and runs the first executable of the given name that it finds. Similarly, if Rails wants to render the action for widgets/index, it goes through your view paths in order, looking for a suitable candidate. If it finds app/views/widgets/index.html.erb, it will stop looking and render this file.
This means that if we prepend app/mobile_views to the array of view paths, we can place our custom mobile view for our widget index into app/mobile_views/widgets/index.html.erb. When Rails finds this file, it won’t bother looking in app/views for our default template. However, if we don’t bother creating an app/mobile_views/widgets/show.html.erb file, we can fall back to using the one in app/views. Laziness satisfied!
Let’s get started. In our ApplicationController, we just need to add a few lines:
app/controllers/application_controller.rb:
before_filter :prepend_view_path_if_mobile
private
def prepend_view_path_if_mobile
if mobile_request?
prepend_view_path Rails.root + 'app' + 'mobile_views'
end
end
def mobile_request?
request.subdomains.first == 'm'
end
helper_method :mobile_request?
At this point, we can connect to m.mydomain.com (assuming our server is properly configured — I’d suggest ghosting m.[yourhostname].local if you’re testing locally on OS X) and our app will render from app/mobile_views before falling back to the standard app/views.
If you’re OK with telling people to access another URL for the mobile version of your site, I guess you could stop here. It’s that easy. But it’s not much harder to handle toggling back and forth, so let’s do that, too.
We’ll need to add some methods and a couple more before_filters to our ApplicationController, for starters — here’s what the full file will look like:
app/controllers/application_controller.rb:
class ApplicationController < ActionController::Base
protect_from_forgery
before_filter :set_mobile_preferences
before_filter :redirect_to_mobile_if_applicable
before_filter :prepend_view_path_if_mobile
private
def set_mobile_preferences
if params[:mobile_site]
cookies.delete(:prefer_full_site)
elsif params[:full_site]
cookies.permanent[:prefer_full_site] = 1
redirect_to_full_site if mobile_request?
end
end
def prepend_view_path_if_mobile
if mobile_request?
prepend_view_path Rails.root + 'app' + 'mobile_views'
end
end
def redirect_to_full_site
redirect_to request.protocol + request.host_with_port.gsub(/^m\./, '') +
request.request_uri and return
end
def redirect_to_mobile_if_applicable
unless mobile_request? || cookies[:prefer_full_site] || !mobile_browser?
redirect_to request.protocol + "m." + request.host_with_port.gsub(/^www\./, '') +
request.request_uri and return
end
end
def mobile_request?
request.subdomains.first == 'm'
end
helper_method :mobile_request?
def mobile_browser?
request.env["HTTP_USER_AGENT"] && request.env["HTTP_USER_AGENT"][/(iPhone|iPod|iPad|Android)/]
end
helper_method :mobile_browser?
end
There’s nothing too complex going on here. We’re going to check if a user has specifically requested the mobile or full site in set_mobile_preferences, and if the user’s asked for the full site, we’ll store a “permanent” cookie that says so — this way the next time they visit our application from this device, they’ll immediately be in their preferred viewing format, because the next filter, redirect_to_mobile_if_applicable, only redirects if the request isn’t already being made to m.mydomain.com, the user hasn’t previously stored a cookie that says they’d like the full site, and the user agent appears to be a browser we’d like to redirect to the mobile site. You may want to season your mobile_browser? method to taste. The one I used here works well for my needs.
The only thing that remains is to place a link allowing the mobile user to toggle between sites. I put mine in the footer of my application layouts. For the mobile page, it’s a simple link_to.
app/mobile_views/layouts/application.html.erb:
<%= link_to "View full site", url_for(:full_site => 1) %>
On the full page, we only want to display the link “back” to the mobile site if the user is on a mobile browser:
app/views/layouts/application.html.erb:
<%= link_to "View mobile site", url_for(:mobile_site => 1) if mobile_browser? %>
That’s it!