Building a Mobile Web Experience with Rails and Backbone.js
October 7, 2014
Topic tags
Mary Schmidt
Alumni
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.
this.Apatosaurus.Views.ApatosaurusView = class ApatosaurusView extends Backbone.View {
clickEventType() {
if ('ontouchstart' in window) { return 'touchstart'; } else { return 'click'; }
}
afterRender() {
if (this._addTouchClass == null) { this._addTouchClass = function() { return this.addTouchClass(...arguments); }.bind(this); }
if (this._removeTouchClass == null) { this._removeTouchClass = function() { return this.removeTouchClass(...arguments); }.bind(this); }
this.$el.off(this.touchStartEvent(), '[data-touch-responsive]', this._addTouchClass);
this.$el.off(this.touchEndEvent(), '[data-touch-responsive]', this._removeTouchClass);
this.$el.on(this.touchStartEvent(), '[data-touch-responsive]', this._addTouchClass);
this.$el.on(this.touchEndEvent(), '[data-touch-responsive]', this._removeTouchClass);
this.$el.on(this.touchMoveEvent(), '[data-touch-responsive]', this._removeTouchClass);
if (this._preventDefault == null) { this._preventDefault = event => event.preventDefault(); }
this.$el.off('click', 'a', this._preventDefault);
this.$el.on('click', 'a', this._preventDefault);
if (this._markDrag == null) { this._markDrag = () => { return this._dragging = true; }; }
if (this._endDrag == null) { this._endDrag = () => { return this._dragging = false; }; }
if (this._interceptClick == null) { this._interceptClick = function() { return this.interceptClick(...arguments); }.bind(this); }
this.$el.off(this.touchMoveEvent(), 'a', this._markDrag);
this.$el.on(this.touchMoveEvent(), 'a', this._markDrag);
this.$el.off(this.touchEndEvent(), 'a', this._interceptClick);
this.$el.on(this.touchEndEvent(), 'a', this._interceptClick);
this.$el.off(this.touchEndEvent(), 'a', this._endDrag);
this.$el.on(this.touchEndEvent(), 'a', this._endDrag);
return this.$el.trigger('ready');
}
interceptClick(event) {
if (!this._dragging) {
const $elem = $(event.target || event.srcElement).closest('a');
if ($elem.attr('data-intercept-click') && ($elem.attr('data-intercept-click') === 'true')) {
return Apatosaurus.router.navigate($elem.attr('href'), {trigger: true});
} else {
return window.location.href = $elem.attr('href');
}
}
}
touchStartEvent() {
if (Array.from(document.documentElement).includes('ontouchstart')) {
return 'touchstart';
} else {
return 'mousedown';
}
}
touchEndEvent() {
if (Array.from(document.documentElement).includes('ontouchstart')) {
return 'touchend';
} else {
return 'mouseup';
}
}
touchMoveEvent() {
if (Array.from(document.documentElement).includes('ontouchstart')) {
return 'touchmove';
} else {
return 'mousemove';
}
}
addTouchClass(event) {
const $elem = $(event.srcElement || event.target)
.closest('[data-touch-responsive]');
return $elem.addClass('touch');
}
removeTouchClass(event) {
const $elem = $(event.srcElement || event.target)
.closest('[data-touch-responsive]');
return $elem.removeClass('touch');
}
};
We knew exactly what we needed, and didn’t want to overcomplicate things.
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?