As promised in my prior article, here is my current solution to quickly and easily tack on an iPhone specific user interface to Rails applications. Our goal is to be able to deliver customized html, js and css for the iPhone. To do so, I’ve decided to hijack our best friend respond_to. respond_to delivers different content to browsers based on their HTTP request and even though its only designed to discriminate based on mime-type, its pretty easy to modify it to also discriminate based on user-agent. The other distinct advantage of using respond_to is that not only can we create different html, javascript and css views, but we can also pass blocks.
If you want to follow along in the core, open up actionpack/lib/action_controller/mime_responds.rb and scroll down to the Responder Class.
The initialize method checks the CGI request for the list of acceptable formats and puts them in an array in order of preference (@mime_type_priority). Later it iterates through this array and returns the first one that matches what’s listed in respond_to. Let’s modify it to automagically add iPhone specific MIME types to the front of that preference array.
alias original_initialize initialize
def initialize(controller)
original_initialize(controller)
# If the user agent is Safari on iPhone, duplicate the acceptable
# formats with iphone_ prepended versions
if @request.user_agent.include?('iPhone')
# We only need to duplicate html, js & css mime-types
mime_types_to_duplicate = @mime_type_priority.select do |m|
[Mime::EXTENSION_LOOKUP['html'], Mime::EXTENSION_LOOKUP['js'], Mime::EXTENSION_LOOKUP['css']].include?(m)
end
# Retrieve iPhone mime-types
mime_types_to_duplicate.map!{ |m| Mime::EXTENSION_LOOKUP[("iphone_" + m.to_sym.to_s)] }
# and place them first in the priority list. If they exist, they'll
# be delivered first. If they don't, then iPhone requests will fallback
# on the standard html/js/css responses.
@mime_type_priority = mime_types_to_duplicate + @mime_type_priority
end
end
When
respond_to is called, it creates a new
Responder object and calls it’s
respond method. Eventually
method_missing is called on the
mime_type names which sends those names on to the
custom method.
custom generates a response for that mime-type. We have to grab these iPhone mime-types before they are processed by
custom, otherwise we’ll end up sending a response to the browser with an invalid content-type header for our fake iPhone mime-types. So let’s change
method_missing to this:
def method_missing(symbol, &block)
mime_constant = symbol.to_s.upcase
# Intercept iphone_ prepended acceptable mime-types and send them to our
# custom_iphone response constructor
if mime_constant.include?('IPHONE_') && Mime::SET.include?(Mime.const_get(mime_constant))
custom_iphone(Mime.const_get(mime_constant), &block)
elsif Mime::SET.include?(Mime.const_get(mime_constant))
custom(Mime.const_get(mime_constant), &block)
else
super
end
end
Now we need to create our custom_iphone method which creates the response just like custom except it changes the content-type header to the valid content-type:
def custom_iphone(mime_type, &block)
mime_type = mime_type.is_a?(Mime::Type) ? mime_type : Mime::Type.lookup(mime_type.to_s)
# Remove the "iphone_" part of the name and retrieve the valid mime-type object
actual_mime_type = Mime::Type.lookup_by_extension(mime_type.to_sym.to_s[7..-1])
@order << mime_type
if block_given?
@responses[mime_type] = Proc.new do
@response.template.template_format = mime_type.to_sym
# Deliver the valid mime-type instead of our fake iphone one
@response.content_type = actual_mime_type.to_s
block.call
end
else
@responses[mime_type] = Proc.new do
@response.template.template_format = mime_type.to_sym
# Deliver the valid mime-type instead of our fake iphone one
@response.content_type = actual_mime_type.to_s
@controller.send :render, :action => @controller.action_name
end
end
end
One last thing. We need to register our fake iPhone
MIME types:
Mime::Type.register "iphone/html", :iphone_html
Mime::Type.register "iphone/javascript", :iphone_js
Mime::Type.register "iphone/css", :iphone_css
A little hacky? Yea. I’m not so happy with creating fake
MIME types and
respond_to wasn’t meant to identify user-agents, but it’ll work until I can figure out a cleaner implementation. There’s no denying the benefits of harnessing the power of
respond_to. It makes developing a separate UI for iPhones super simple. Now you can do things like:
respond_to do |format|
format.html
format.iphone_html
format.js
format.iphone_js
end
Want to redirect iPhone users away from a particular method?:
respond_to do |format|
format.html
format.iphone_html{ redirect_to( :action => "index") }
end
No need to modify any of your other application code or existing views. Just add the .iphone_* templates (i.e. index.iphone_html.erb, index.iphone_js.erb) and modify your respond_to blocks. GOTCHA ALERT: regular and iPhone-specific html templates will share the same layout if one is designated at the controller level. To hide parts of your layout files from iPhone requests, just do:
primary_layout.html.erb:
<% unless request.user_agent.include('iPhone') %>
Hidden from iPhones
<% end %>