ribcage-picker
Version:
A iOS-inspired UIPickerView for ribcage-ui
652 lines (528 loc) • 19.7 kB
JavaScript
/**
* Derived from Matteo Spinelli's original implementation, see LICENSE for details
*/
/* global WebKitCSSMatrix */
var Ribcage = require('ribcage-view')
, each = require('lodash.foreach')
, bind = require('lodash.bind')
, isEqual = require('lodash.isequal')
, clone = require('lodash.clone')
, keys = require('lodash.keys')
, modernizr = require('./modernizr')
, Picker;
Picker = Ribcage.extend({
cellHeight: 44
, friction: 0.003
, currentSelection: {}
, defaultsApplied: false
, slotMachineCapable: modernizr.csstransitions &&
modernizr.csstransforms &&
modernizr.csstransforms3d &&
modernizr.touch
, slotMachineOpen: false
, disableToggle: false
, events: {
'change .js-select': 'onSelectChange'
, 'touchend .rp-select-blocker': 'toggleSlotMachine'
}
/**
* @param {object} opts - Picker options
* @param {object} opts.slots - The slots this picker should have
* @param {object} opts.values - A map of the values this slot should have
* @param {object} opts.style - The CSS style of this slot
* @param {object} opts.defaultKey - The key of the default value this slot should have
*/
, afterInit: function (opts) {
var self = this
, i = 0;
// Clone the user's input because we're going to augment it
this.slots = clone(opts.slots, true);
if(opts.onChange)
this.userOnChange = opts.onChange;
this.disableToggle = opts.disableToggle === true;
// Everything beyond here is slotmachine stuff
if(!this.slotMachineCapable)
return this;
this.offsetParent = opts.offsetParent;
each(this.slots, function (slot, key) {
/**
* This function is called when a slot stops scrolling outside its valid boundaries.
* We bind it with these values to make returnToValidRange faster and simpler.
*/
slot.returnToValidRange = bind(self.returnToValidRange, self, slot, key);
slot.onSlotStopsSpinning = bind(self.onSlotStopsSpinning, self, slot, key);
/**
* Save the index to the slot, start the offset at 0.
* Width will be overwritten in afterRender, once we actually
* can figure out how wide the slot is
*/
slot.index = i;
slot.currentOffset = 0;
slot.width = 0;
slot.style = slot.style == null ? 'right' : slot.style;
slot.defaultKey = slot.defaultKey == null ? keys(slot.values)[0] : slot.defaultKey;
self.currentSelection[key] = {
key: slot.defaultKey
, value: slot.values[slot.defaultKey]
};
++i;
});
/**
* We've got to bind these too, since they'll be called in the global context
*/
this.onTouchStart = bind(this.onTouchStart, this);
this.scrollStart = bind(this.scrollStart, this);
this.scrollMove = bind(this.scrollMove, this);
this.scrollEnd = bind(this.scrollEnd, this);
this.returnToValidRange = bind(this.returnToValidRange, this);
/**
* Global events are kinda nasty, but we need to reposition the widget
* when the orientation changes, or when the page scrolls because of some other event.
*/
window.addEventListener('orientationchange', this.calculateSlotWidths, true);
window.addEventListener('resize', this.calculateSlotWidths, true);
}
, toggleSlotMachine: function () {
this.calculateSlotWidths();
this.calculateMaxOffsets();
if(!this.slotMachineOpen || this.disableToggle) {
this.$('.rp-wrapper').addClass('rp-wrapper-open');
}
else {
this.$('.rp-wrapper').removeClass('rp-wrapper-open');
}
this.slotMachineOpen = !this.slotMachineOpen;
}
/**
* Clean up the events we added in afterInit
*/
, beforeClose: function () {
var swFrame = this.$('.rp-frame')[0];
window.removeEventListener('orientationchange', this.calculateSlotWidths, true);
window.removeEventListener('resize', this.calculateSlotWidths, true);
for (var slotKey in this.slots) {
if(this.slots[slotKey].el) {
this.slots[slotKey].el.removeEventListener('webkitTransitionEnd', this.slots[slotKey].returnToValidRange, false);
this.slots[slotKey].el.removeEventListener('webkitTransitionEnd', this.slots[slotKey].onSlotStopsSpinning, false);
}
delete this.slots[slotKey].returnToValidRange;
delete this.slots[slotKey].onSlotStopsSpinning;
delete this.slots[slotKey];
}
if(swFrame) {
swFrame.removeEventListener('touchstart', this.onTouchStart, false);
swFrame.removeEventListener('touchmove', this.scrollMove, false);
swFrame.removeEventListener('touchend', this.scrollEnd, false);
}
delete this.onChange;
delete this.currentSelection;
}
/**
* Lets the user change the values of a slot after a picker has been initialized and shown
*/
, setSlot: function (slotKey, opts) {
var self = this;
if(!this.slots[slotKey])
throw new Error('setSlot can only be used to update a slot that already exists');
this.getValues();
this.slots[slotKey].values = clone(opts.values, true);
this.slots[slotKey].style = opts.style == null ? this.slots[slotKey].style : opts.style;
this.render();
// Try our best to keep the same offset in the slot
each(this.slots, function (slot, slotKey) {
if(slot.values[self.currentSelection[slotKey].key] != null) {
self.setSlotKey(slotKey, self.currentSelection[slotKey].key);
}
else {
// The value doesn't exist.. try to scroll as physically close as possible
self.scrollToSlotOffset(slotKey, slot.currentOffset);
}
});
}
/**
* The entire widget's markup is created with this template
*/
, template: require('./picker.hbs')
/**
* Delegates the handling of touch gestures to
* either the scroll or dismissal handlers
*/
, onTouchStart: function (e) {
var target = event.target || event.srcElement;
if (target.className.indexOf('rp-frame') >= 0) {
// Stop the screen from moving!
e.preventDefault();
e.stopPropagation();
this.scrollStart(e);
}
// Let other events pass through
}
/**
* Iterate through each slot and get the width of each one
*/
, calculateSlotWidths: function () {
var div = this.$('.rp-slots').children('div')
, i = 0;
each(this.slots, function (slot) {
slot.width = div[i].offsetWidth;
i++;
});
}
/**
* Iterate through each slot and get the height of each one
*/
, calculateMaxOffsets: function () {
var wrapHeight = this.$('.rp-slots-wrapper').height();
each(this.slots, function (slot) {
slot.maxOffset = wrapHeight - slot.el.clientHeight - 86;
});
}
/**
* Pass the slot object to the template so it can construct the lists
*/
, context: function () {
return {
slots: this.slots
, slotMachineCapable: this.slotMachineCapable
, slotMachineOpen: this.disableToggle ? true : this.slotMachineOpen
};
}
/**
* The afterRender function does the things we can't do in afterInit
* namely, getting references to the newly created DOM elements,
* setting the default transition on the slots, and scrolling them to
* their default values.
*/
, afterRender: function () {
var self = this
, swWrapper = this.$('.rp-wrapper')
, isReady = swWrapper.height() > 0;
/**
* If no slot machine is available, this block will either
* apply the default value or select the last selected value
*/
if(!this.slotMachineCapable) {
each(this.slots, function (slot, k) {
// Align the slot at its default key if it is not already open
if (!this.defaultsApplied && slot.defaultKey != null) {
self.setSlotKey(k, slot.defaultKey);
}
else if(self.currentSelection[k]) {
self.setSlotKey(k, self.currentSelection[k].key);
}
});
this.defaultsApplied = true;
return this;
}
this.activeSlot = null;
for (var k in this.slots) {
/**
* Save the jquery wrapped element to the slot
* for convenience
*/
var $ul = this.$('ul.picker-slot-' + k)
, ul = $ul[0];
this.slots[k].$el = $ul;
this.slots[k].el = ul;
// Listen for transitionEnd events
ul.addEventListener('webkitTransitionEnd', this.slots[k].onSlotStopsSpinning, false);
}
/**
* At this point the widget *should* be on the DOM, so we can calculate the
* widths and heights of the slots.
*/
this.calculateSlotWidths();
this.calculateMaxOffsets();
if(isReady) {
each(this.slots, function (slot, k) {
// Wipe out any transition
slot.el.removeEventListener('webkitTransitionEnd', slot.returnToValidRange, false);
slot.el.style.webkitTransitionDuration = '0';
self.setSlotOffset(k, slot.currentOffset);
// Add the default transition
slot.el.style.webkitTransitionTimingFunction = 'cubic-bezier(0, 0, 0.2, 1)';
});
}
/**
* If no default has been applied yet, try to apply one
*/
if(!this.defaultsApplied) {
this.defaultsApplied = true;
each(this.slots, function (slot, k) {
// Align the slot at its default key if it is not already open
if (slot.defaultKey != null) {
self.setSlotKey(k, slot.defaultKey);
}
// Add the default transition
slot.el.style.webkitTransitionTimingFunction = 'cubic-bezier(0, 0, 0.2, 1)';
});
}
/**
* Otherwise, restore what ever was last selected
*/
else {
each(this.slots, function (slot, k) {
if(self.currentSelection[k]) {
self.setSlotKey(k, self.currentSelection[k].key);
}
// Add the default transition
slot.el.style.webkitTransitionTimingFunction = 'cubic-bezier(0, 0, 0.2, 1)';
});
}
/**
* Uses our scrolling logic when touches happen inside the picker
*/
this.$('.rp-frame')[0].addEventListener('touchstart', this.onTouchStart, false);
}
/**
*
* Rolling slots
*
*/
, scrollStart: function (e) {
var swFrame = this.$('.rp-frame')[0]
, xPos
, slot;
/**
* Find the clicked slot
* Clicked position minus left offset (should be 11px)
*/
xPos = e.targetTouches[0].clientX - this.$('.rp-slots').offset().left;
slot = 0;
for (var k in this.slots) {
slot += this.slots[k].width;
if (xPos < slot) {
this.activeSlot = k;
break;
}
}
var slotObj = this.slots[this.activeSlot];
// Wipe out any transition
slotObj.el.removeEventListener('webkitTransitionEnd', slotObj.returnToValidRange, false);
slotObj.el.style.webkitTransitionDuration = '0';
// Stop and hold slot position
var theTransform = window.getComputedStyle(this.slots[this.activeSlot].el).webkitTransform;
theTransform = new WebKitCSSMatrix(theTransform).m42;
if (theTransform != this.slots[this.activeSlot].currentOffset) {
this.setSlotOffset(this.activeSlot, theTransform);
}
this.startY = e.targetTouches[0].clientY;
this.scrollStartY = this.slots[this.activeSlot].currentOffset;
this.scrollStartTime = e.timeStamp;
swFrame.addEventListener('touchmove', this.scrollMove, false);
swFrame.addEventListener('touchend', this.scrollEnd, false);
return true;
}
, scrollMove: function (e) {
var topDelta = e.targetTouches[0].clientY - this.startY;
if (this.slots[this.activeSlot].currentOffset > 0 || this.slots[this.activeSlot].currentOffset < this.slots[this.activeSlot].maxOffset) {
topDelta /= 2;
}
this.setSlotOffset(this.activeSlot, this.slots[this.activeSlot].currentOffset + topDelta);
this.startY = e.targetTouches[0].clientY;
// Prevent slingshot effect
if (e.timeStamp - this.scrollStartTime > 80) {
this.scrollStartY = this.slots[this.activeSlot].currentOffset;
this.scrollStartTime = e.timeStamp;
}
}
, scrollEnd: function (e) {
var swSlotWrapper = this.$('.rp-wrapper')
, swFrame = this.$('.rp-frame')[0]
, scrollDuration = e.timeStamp - this.scrollStartTime
, newDuration
, newScrollDistance
, newPosition;
swFrame.removeEventListener('touchmove', this.scrollMove);
swFrame.removeEventListener('touchend', this.scrollEnd);
// If we are outside of the boundaries, let's go back to the sheepfold
if (this.slots[this.activeSlot].currentOffset > 0 || this.slots[this.activeSlot].currentOffset < this.slots[this.activeSlot].maxOffset) {
this.scrollToSlotOffset(this.activeSlot, this.slots[this.activeSlot].currentOffset > 0 ? 0 : this.slots[this.activeSlot].maxOffset);
return false;
}
// Lame formula to calculate a fake deceleration
var scrollDistance = this.slots[this.activeSlot].currentOffset - this.scrollStartY;
// The drag session was too short
if (scrollDistance < this.cellHeight / 1.5 && scrollDistance > -this.cellHeight / 1.5) {
if (this.slots[this.activeSlot].currentOffset % this.cellHeight) {
this.scrollToSlotOffset(this.activeSlot, Math.round(this.slots[this.activeSlot].currentOffset / this.cellHeight) * this.cellHeight, '100ms');
}
return false;
}
newDuration = (2 * scrollDistance / scrollDuration) / this.friction;
newScrollDistance = (this.friction / 2) * (newDuration * newDuration);
if (newDuration < 0) {
newDuration = -newDuration;
newScrollDistance = -newScrollDistance;
}
newPosition = this.slots[this.activeSlot].currentOffset + newScrollDistance;
if (newPosition > 0) {
// Prevent the slot to be dragged outside the visible area (top margin)
newPosition /= 2;
newDuration /= 3;
if (newPosition > swSlotWrapper.height() / 4) {
newPosition = swSlotWrapper.height() / 4;
}
} else if (newPosition < this.slots[this.activeSlot].maxOffset) {
// Prevent the slot to be dragged outside the visible area (bottom margin)
newPosition = (newPosition - this.slots[this.activeSlot].maxOffset) / 2 + this.slots[this.activeSlot].maxOffset;
newDuration /= 3;
if (newPosition < this.slots[this.activeSlot].maxOffset - swSlotWrapper.height() / 4) {
newPosition = this.slots[this.activeSlot].maxOffset - swSlotWrapper.height() / 4;
}
} else {
newPosition = Math.round(newPosition / this.cellHeight) * this.cellHeight;
}
this.scrollToSlotOffset(this.activeSlot, Math.round(newPosition), Math.round(newDuration) + 'ms');
return true;
}
/**
* Scrolls the slot to a specified offset, and attaches a handler
* that will bounce it back to the valid range if the destination
* is out of bounds.
*/
, scrollToSlotOffset: function (slotKey, dest, runtime) {
if(!this.slotMachineCapable) {
var valKeys = keys(this.slots[slotKey].values);
if(dest >= valKeys.length)
dest = valKeys.length - 1;
if(dest < 0)
dest = 0;
this.setSlotKey(slotKey, valKeys[dest]);
return this;
}
this.slots[slotKey].el.style.webkitTransitionDuration = runtime ? runtime : '100ms';
this.setSlotOffset(slotKey, dest ? dest : 0);
// If we are outside of the boundaries go back to the sheepfold
if (this.slots[slotKey].currentOffset > 0 || this.slots[slotKey].currentOffset < this.slots[slotKey].maxOffset) {
this.slots[slotKey].el.addEventListener('webkitTransitionEnd', this.slots[slotKey].returnToValidRange, false);
}
}
/**
* Given an offset, moves the slot to that offset immediately
*/
, setSlotOffset: function (slot, pos) {
this.slots[slot].currentOffset = pos;
this.slots[slot].el.style.webkitTransform = 'translate3d(0, ' + pos + 'px, 0)';
}
/**
* Given a key, scrolls the slot to that cell
*/
, setSlotKey: function (slotKey, valueKey) {
var yPos, count, i;
this.$('.picker-select-' + slotKey).val(valueKey);
this.currentSelection[slotKey] = {
key: valueKey
, value: this.slots[slotKey].values[valueKey]
};
if(!this.slotMachineCapable)
return this;
this.slots[slotKey].el.removeEventListener('webkitTransitionEnd', this.slots[slotKey].returnToValidRange, false);
this.slots[slotKey].el.style.webkitTransitionDuration = '0';
count = 0;
for (i in this.slots[slotKey].values) {
if (i == valueKey) {
yPos = count * this.cellHeight;
this.setSlotOffset(slotKey, yPos);
break;
}
count -= 1;
}
}
/**
* This event handler is called when a scroll animation has ended, but
* we knew that the scroll was going out of bounds. This function
* ensures that the slot scrolls back within the valid bounds
*/
, returnToValidRange: function (slot, key) {
if(slot) {
slot.el.removeEventListener('webkitTransitionEnd', slot.returnToValidRange, false);
}
this.scrollToSlotOffset(key, slot.currentOffset > 0 ? 0 : slot.maxOffset, '150ms');
return false;
}
/**
* Called when a slot stops spinning, used to trigger the `change` event
*/
, onSlotStopsSpinning: function () {
var self = this
, newSelection = this.getValues()
, different = false;
each(newSelection, function (slot, slotKey) {
if(! isEqual(newSelection[slotKey], self.currentSelection[slotKey])) {
self.setSlotKey(slotKey, newSelection[slotKey].key);
self.onChange(newSelection, slotKey, slot);
different = true;
return;
}
});
if(different) {
this.onChange(newSelection);
}
this.currentSelection = newSelection;
}
/**
* Called when a <select> value is changed
*/
, onSelectChange: function (e) {
var elem = this.$(e.target)
, key = elem.attr('class').split('-').pop()
, slot = this.slots[key];
this.getValues();
this.trigger('change:' + key, clone(this.currentSelection[key], true), slot, key);
this.trigger('change', clone(this.currentSelection, true));
}
, onChange: function (newSelection, slotKey, slot) {
if(typeof this.userOnChange == 'function')
this.userOnChange.apply(this, arguments);
if(slotKey && slot) {
this.trigger('change:' + slotKey, newSelection[slotKey], slot, slotKey);
}
else {
this.trigger('change', clone(this.currentSelection, true));
}
}
, getValues: function () {
var self = this
, count
, index
, i
, response = {};
// If there is no slotmachine, read from the select input
if(!this.slotMachineCapable) {
each(this.slots, function (slot, key) {
i = self.$('.picker-select-' + key).val();
response[key] = {key: i, value: slot.values[i]};
slot.currentOffset = keys(slot.values).indexOf(i); // Needed for setSlot
});
this.currentSelection = response;
return clone(response, true);
}
// Not ready! Return defaults!
if(this.$el.width() <= 0) {
return clone(this.currentSelection, true);
}
this.calculateSlotWidths();
each(this.slots, function (slot, key) {
// Remove any residual animation
slot.el.removeEventListener('webkitTransitionEnd', slot.returnToValidRange, false);
slot.el.style.webkitTransitionDuration = '0';
if (slot.currentOffset > 0) {
self.setSlotOffset(key, 0);
} else if (slot.currentOffset < slot.maxOffset) {
self.setSlotOffset(key, slot.maxOffset);
}
index = -Math.round(slot.currentOffset / self.cellHeight);
count = 0;
for (var i in slot.values) {
if (count == index) {
response[key] = {key: i, value: slot.values[i]};
break;
}
count += 1;
}
});
return response;
}
});
module.exports = Picker;