0
0

Building a Mobile Web Experience with Rails and Backbone.js

As you may have already heard, we recently finished building our new mobile site! I'm here to talk a bit about how we built it and why we did the things we did.

We knew that we wanted to take an exploratory step towards a Wistia mobile experience, given increasing interest in mobile video. Because of our needs for the new mobile site, we opted to go web-based and build a snazzy JavaScript front end for our main Rails app.

There are a lot of JavaScript frameworks out there, but we ended up going with Backbone.js for the Wistia mobile front end. Here are a few reasons why:

  • We love the structure it gives large JavaScript apps.
  • Backbone plays very nicely with Rails out of the box.
  • We’re already using it in other places across the Wistia app.

All Backbone needs to get going is a RESTful JSON API, and Rails can fill that role quite nicely. We use Rails active model serializers to specify attributes that need to be passed along to the front end, and we created a special internal JSON API for our client-side apps to access. Voila, data.

Making the mobile experience delightful

So, we've got all the data we need. Great! Now, time to design! We started by defining our ideal Wistia mobile experience. It needed to feel friendly, snappy, and undeniably "Wistia." Among other qualities, that means looking nice, incorporating smooth transitions, and keeping the focus on the content.

Really though, we just want to make you smile.

The trickiest part of this whole design process was making the site feel like a real app. For example, mobile Safari waits 300ms by default when you click a link for it to actually navigate, in case you hit the link while you're scrolling. Clicking should be intentional.

Many of the touch library options out there offered too much or were too invasive, so we ended up rolling our own.

class @Apatosaurus.Views.ApatosaurusView extends Backbone.View


  clickEventType: ->
    if 'ontouchstart' of window then 'touchstart' else 'click'


  afterRender: ->
    @_addTouchClass ?= => @addTouchClass(arguments...)
    @_removeTouchClass ?= => @removeTouchClass(arguments...)
    @$el.off @touchStartEvent(), '[data-touch-responsive]', @_addTouchClass
    @$el.off @touchEndEvent(), '[data-touch-responsive]', @_removeTouchClass
    @$el.on @touchStartEvent(), '[data-touch-responsive]', @_addTouchClass
    @$el.on @touchEndEvent(), '[data-touch-responsive]', @_removeTouchClass
    @$el.on @touchMoveEvent(), '[data-touch-responsive]', @_removeTouchClass

    @_preventDefault ?= (event) => event.preventDefault()
    @$el.off 'click', 'a', @_preventDefault
    @$el.on 'click', 'a', @_preventDefault

    @_markDrag ?= => @_dragging = true
    @_endDrag ?= => @_dragging = false
    @_interceptClick ?= => @interceptClick(arguments...)

    @$el.off @touchMoveEvent(), 'a', @_markDrag
    @$el.on @touchMoveEvent(), 'a', @_markDrag
    @$el.off @touchEndEvent(), 'a', @_interceptClick
    @$el.on @touchEndEvent(), 'a', @_interceptClick
    @$el.off @touchEndEvent(), 'a', @_endDrag
    @$el.on @touchEndEvent(), 'a', @_endDrag

    @$el.trigger 'ready'


  interceptClick: (event) ->
    unless @_dragging
      $elem = $(event.target or event.srcElement).closest('a')
      if $elem.attr('data-intercept-click') && $elem.attr('data-intercept-click') == 'true'
        Apatosaurus.router.navigate($elem.attr('href'), trigger: true)
      else
        window.location.href = $elem.attr('href')


  touchStartEvent: ->
    if `'ontouchstart' in document.documentElement`
      'touchstart'
    else
      'mousedown'


  touchEndEvent: ->
    if `'ontouchstart' in document.documentElement`
      'touchend'
    else
      'mouseup'


  touchMoveEvent: ->
    if `'ontouchstart' in document.documentElement`
      'touchmove'
    else
      'mousemove'


  addTouchClass: (event) ->
    $elem = $(event.srcElement or event.target)
      .closest('[data-touch-responsive]')
    $elem.addClass('touch')


  removeTouchClass: (event) ->
    $elem = $(event.srcElement or event.target)
      .closest('[data-touch-responsive]')
    $elem.removeClass('touch') 

We knew exactly what we needed, and didn't want to overcomplicate things.

Navigation and accessibility

Okay, it's not real magic, but there is some serious Wall-of-Oz stuff going on here. We wanted to create a user experience that was rich but hyperspecific, delivering exactly what you needed at a given time, nothing more or less.

How'd we do it? We built a router that handles most of the chaos by asking questions like "where am I?" and "where am I going?" before rendering new pages. We wanted the site to behave differently on initial load than on subsequent navigations (presence/absence of page transitions). The router also uses pushState to update the URL and give you those shareable links.

Another thing: you know how most mobile apps have a header that stays at the top of the screen as you scroll?

This doesn't look like anything special, but there's a lot going on behind the scenes. If you've made a mobile web app before, you know that maintaining these fixed-position headers can be horrendously annoying. We dealt with a lot of these issues by placing a dummy div at the top of every page and adjusting its height when we expand or collapse the header. Timing it all was tricky (imagine a rogue dummy div expanding and collapsing just out-of-sync with the user's behavior), but we figured it out. :)

We ended up building a parent view for our headers to manage shared behavior.

class @Apatosaurus.Views.Header extends @Apatosaurus.Views.ApatosaurusView
  initialize: ->
    _.bindAll(this)

  afterRender: ->
    super
    @fit()
    $(window).resize => @fit()
    $(window).bind 'orientationchange', => @fit()

  fit: ->
    $centerCol = @$el.find('.center_col')
    $rightCol = @$el.find('.right_col')
    $leftCol = @$el.find('.left_col')
    newWidth = @$el.width() - 3 - $leftCol.outerWidth(true) - $rightCol.outerWidth(true)
    $centerCol.width(newWidth)
    $('#header-dummy').height($('#header').outerHeight())

  expandElem: (elem, time = 175) ->
    elemHeight = 0
    headerHeight = 0
    $(elem).height('').peek ->
      elemHeight = $(elem).outerHeight()
      headerHeight = $('#header').outerHeight(true)

    $(elem).css(height: 0, opacity: 0).show()
    $(elem).addClass('visible')
    $(elem).animate { height: elemHeight }, time, 'linear', ->
      $(elem).animate(opacity: 1, time)

    $('#header-dummy').css(opacity: 0).show()
    $('#header-dummy').animate { height: headerHeight }, time, 'linear', ->
      $('#header-dummy').animate(opacity: 1, time)

  collapseElem: (elem, time = 175) ->
    $(elem).animate { opacity: 0 }, time, 'linear', ->
      $(elem).animate { height: 0 }, time, 'linear', ->
        $(elem).removeClass('visible')
        $(elem).hide()
      $('#header-dummy').animate { height: 72 }, time, 'linear'

The result of all of this is that the site just works—you probably wouldn't think twice about everything happening behind the scenes. New page, new page. Projects, medias, stats, share. Pressing buttons evokes appropriate reactions. It works, and I am so happy about that.

Transparency

Wistia's engineering team is growing. If someone comes to work on the mobile site, they should find the code pretty familiar and have an easy time getting acquainted with it. Using Backbone.js as a front end for our main Rails app meant that the front end mirrored the structure of the back end.

../  
collections/  
models/  
routers/  
utils/  
views/  
base.coffee  
uploader.coffee  

It's pretty easy to poke around in here and see how things work. The router listens for changes in the url, renders new pages, and handles page transitions. Models make the JSON we get from Rails accessible via instance methods. Collections are just, well, groups of models. Views are the unit of content delivery. We have views for different headers and pages. Many, many views.

We're pretty happy with how our mobile site turned out. But really, we made this for you. We hope you love it too. What do you think?

Keep Learning
Here are some related guides and posts that you might enjoy next.