A Scrollbar for the Jo App Framework
(An update as of 3/30/2013: I finally got around to tidying up the code presented in this post and putting it on GitHub. Better late than never).
One of the reasons to choose Jo as an HTML5 mobile application framework is that it does a good job of using CSS3 transforms to replicate the user experience of native view scrolling. That's a sufficently fiddly and annoying line item that even if you're not using Jo you should definitely reference the code in joScroller rather than try to do it yourself. An iOS-style Jo app might construct its screen as follows:
var screen, nav, stack, tabBar; screen = new joScreen( new joContainer([ nav = new joNavbar(), stack = new joStack(), tabBar = new joToolbar([ "Some buttons would usually go here" ]) ]).setStyle({position: "absolute", top: "0", left: "0", bottom: "0", right: "0"}) ); nav.setStack(stack); stack.push( new joScroller([ new joCard([ "Typically a bunch of content objects are defined here." ]) ]).setTitle("View Title to Appear in Nav"); )
Views here are joCards wrapped in joScrollers. The joCard contains the view content proper, while the joScroller manages scrolling largely by updating the CSS3 -webkit-transform property of the joCard using translate3d(x, y, z) - dynamically changing the y-axis translation distance in response to user drag and flick actions. As a reminder, translate3d is used with z = 0 for 2-d translations in order to ensure that hardware acceleration is used where present. The DOM elements inserted by the Jo Javascript above are much as follows:
<jocontainer> <jonavbar> ... </jonavbar> <jostack> <joscroller> <jocard> ... </jocard> </joscroller> </jostack> <jotoolbar> ... </jotoolbar> </jocontainer>
In the Jo model, views are changed by manipulating the joStack, which in turn will swap out that inner joscroller element and its contents. So this all works fairly well, but Jo doesn't yet have a joScrollbar class: if you set things up as above, there will be no visual indicator that a view has content that extends off-screen, or how far that content goes. So I hacked one together, as follows:
var joScrollbar = function(data, hasTabBar) { joView.apply(this, arguments); this.hasTabBar = hasTabBar; this.scroller = null; this.calibrate(); }; joScrollbar.extend(joView, { tagName: "joscrollbar", createContainer: function() { var o = joDOM.create(this.tagName); if (o) o.setAttribute("tabindex", "1"); var w = joDOM.create("joscrollbarpadding"); o.appendChild(w); var s = joDOM.create("joscrollbarslider"); w.appendChild(s); this.slider = s; return o; }, /* * Move the scrollbar slider. * * scaledPosition - how far to move expressed as a fraction of * visible page size/scrollbar size */ setSliderPosition: function(scaledPosition) { var y = -1 * Math.floor(scaledPosition * this.slider.clientHeight); this.slider.style.webkitTransform = "translate3d(0, " + y + "px, 0)"; }, /* * We must size the scrollbar correctly: this function must be called * after the scrollbar is inserted into the DOM, and every time the * page changes size - e.g. on orientation change. * * a) Set the height of the joScrollbar to a useful size * b) Set the relative height of the inner slider by looking at the window * and viewport height. */ calibrate: function() { if( !this.scroller ) { return; }; // find the jocard element so we can measure its height var view = null; for( var i = 0, l = this.scroller.container.children.length; i < l; i++ ) { if( this.scroller.container.children[i].tagName == "JOCARD" ) { view = this.scroller.container.children[i]; } } if( !view ) { // the thing is not in the DOM yet, so do nothing; return; } var viewportHeight = window.innerHeight; var viewportWidth = window.innerWidth; var viewHeight = view.clientHeight; if( this.hasTabBar ) { // 59px is the height of the tab bar in iOS; if you are replicating // that with a joToolbar, then you need to account for its height // in figuring this lot out, otherwise you'll be noticably off. viewHeight -= 59; } // some fudge factors to get the scrollbar entirely in the visible page, // below the header and above the footer if( viewportHeight > viewportWidth ) { var heightFactor = 0.75; } else { var heightFactor = 0.65; } var setHeight = Math.floor(viewportHeight * heightFactor); this.container.style.height = "" + setHeight + "px"; this.container.firstChild.style.height = "" + (setHeight - 10) + "px"; var scrollerHeight = this.container.clientHeight; var sliderHeight = Math.floor( viewportHeight * (scrollerHeight - 10) / viewHeight ); this.slider.style.height = "" + sliderHeight + "px"; if( sliderHeight < scrollerHeight - 10 ) { this.setStyle("active"); } else { this.setStyle("inactive"); } } });
This joScrollbar class must then be usefully integrated with the joScroller, which can be done by manipulating the prototype for that class so that whenever the joScroller takes action, the joScrollbar is also notified:
/* * A convenience method: give this joScroller a scrollbar. It only works if * there are already contents, as making a scrollbar the first child of the * container node would cause interesting undesirable behavior. */ joScroller.prototype.addScrollbar = function(joscrollbar) { if( this.container.firstChild && joscrollbar instanceof joScrollbar ) { this.push(joscrollbar); this.scrollbar = joscrollbar; joscrollbar.scroller = this; } return this; } joScroller.prototype.originalSetPosition = joScroller.prototype.setPosition; joScroller.prototype.setPosition = function(x, y, node) { if( this.scrollbar ) { scaled = y / window.innerHeight; this.scrollbar.setSliderPosition(scaled); } return this.originalSetPosition(x, y, node); }
Then the construction of a Jo screen with a joScrollbar works this way:
var screen, nav, stack, tabBar, scrollbar; screen = new joScreen( new joContainer([ nav = new joNavbar(), stack = new joStack(), tabBar = new joToolbar([ "Some buttons would usually go here" ]) ]).setStyle({position: "absolute", top: "0", left: "0", bottom: "0", right: "0"}) ); nav.setStack(stack); stack.push( new joScroller([ new joCard([ "Typically a bunch of content objects are defined here." ]) ]).setTitle("View Title to Appear in Nav").addScrollbar( scrollbar = new joScrollbar(null, true) ); ) scrollbar.calibrate();
And leads to DOM elements structured much as follows
<jocontainer> <jonavbar> ... </jonavbar> <jostack> <joscroller> <jocard> ... </jocard> <joscrollbar> <joscrollbarpadding> <joscrollbarslider></joscrollbarslider> </jscrollbarpadding> </joscrollbar> </joscroller> </jostack> <jotoolbar> ... </jotoolbar> </jocontainer>
Now on to the styling: the following CSS gives a scrollbar that only appears when there is enough content to overflow the height of the device viewport, and is somewhat reminiscent of the native iOS scrollbar in translucent white.
joscrollbar { visibility: hidden; z-index: -9999; border: 1px solid rgba(255,255,255,0.7); -webkit-border-radius: 2px; -moz-border-radius: 2px; border-radius: 2px; width: 4px; position: absolute; top: 10px; right: 2px; height: 70%; } joscrollbar.active { visibility: visible; z-index: 9999; } joscrollbarpadding { margin: 5px 1px; overflow: hidden; display: -webkit-box; display: -moz-box; display: -o-box; display: box; -moz-box-align: stretch; -o-box-align: stretch; box-align: stretch; width: 2px; -webkit-box-orient: vertical; -webkit-box-align: stretch; -moz-box-orient: vertical; -moz-box-align: stretch; -o-box-orient: vertical; -o-box-align: stretch; box-orient: vertical; box-align: stretch; } joscrollbarslider { display: block; background-color: rgba(255,255,255,0.7); width: 4px; height: 50%; -webkit-animation-fill-mode: forwards; -webkit-transition: -webkit-transform 100ms ease; -moz-animation-fill-mode: forwards; -moz-transition: -moz-transform 100ms ease; -o-animation-fill-mode: forwards; -o-transition: -o-transform 100ms ease; -ms-animation-fill-mode: forwards; -ms-transition: -ms-transform 100ms ease; }
There you go. This is very much a first pass at a joScrollbar, where each joScrollbar instance is associated with a specific joScroller, and thrown together for a specific iOS HTML5 application project. Thus you will probably find that you have to tinker with the sizing and scrolling behavior in order to use it for your own projects; hopefully not by too much. In addition, you must ensure that the calibrate() function is called when a scrollbar is first placed into the DOM, and again later whenever the device orientation changes. Given the present state of the Jo framework, this will probably be managed in your project by some combination of PhoneGap and your own code, so I won't offer an example. Something has to be left as an exercise for the reader.