Verve
Blog
Home > Blog > How To Build A Text Spinner Ui Widget
@lou_loud
01-10-2015

How to build a Text Spinner UI Widget

TextSpinner-UI-widget Spinner widgets are one of the UI components that are often used in games, in which either a user interaction or an internal process changes the content of the widget, but instead of a static change, the widget transitions smoothly by spinning from the old value to the new one. You might recognise the widget from its wide use in Casinos, iOS (for Date/Time selection), etc.

Recently, I needed to build a similar widget; despite the fact that I only needed the widget to spin digits, I decided it would be fun to challenge myself, getting the widget to spin any character set.

The widget uses jQuery for DOM (Document Object Model) manipulation (as it makes it a lot easier to deal with the DOM), but the dependency will be removed in future releases.

Firstly, I’ll take you through the process I followed to build the widget.

1. I listed the functional requirements (what exactly that the widget needs to do). In my case, the requirements were:

  • Adaptable for any piece of content. I wanted the widget to be flexible enough to be plugged into content, to flow inline with text.
  • Support different font faces/sizes. Since the widget can be plugged into any piece of content, it needs to match any styles set by the content, specifically font-face and font-size.
  • State transition. The widget needs to smoothly transition from one state (old value) to another (new value).
  • Configurable to different languages. Because we’re an international agency, doing business in a range of languages, I thought why not make the character set configurable so that it can spin text in any language.

Clearly stating the widget’s requirements in advance will help you stay focussed on the task at hand and not to get distracted by new features or trends that can be added. This will also enable you to have a clear division between core functionality and additional features.

2. The second step is to map these requirements with corresponding code specs:

  • Content pluggability: the widget needs to follow the flow of the page and content, so an “inline-block” with no prior assumptions on positioning of the widget i.e. use relative positioning.
  • Configurable font face/size: calculations needs to be done with respect to the font-size i.e. use “em” units.
  • Smooth transitions: use of either JavaScript animation or even better CSS animations/transitions.
  • Configurable character set: use JavaScript to generate the HTML structure of the widget.

3. The next step is to create the code. To keep things simple, for this example we’ll assume that we only want to spin digits from 1 through 6 and that we only have 3 places to spin i.e. “000” “666” “123”, etc.

First the HTML code of the widget:

<div class=“text-spinner”>
     <div class=“text-spinner-char”><!-- first spinnable place -->
          <div class=“text-spinner-viewport”><!-- movable viewport --> 
               <div class=“single-character char-1”>1</div>
               <div class=“single-character char-2”>2</div>
               <div class=“single-character char-3”>3</div>
               <div class=“single-character char-4”>4</div>
   	        <div class=“single-character char-5”>5</div>
   	        <div class=“single-character char-6”>6</div>
          </div>
     </div>
     <div class=“text-spinner-char”><!-- second spinnable place -->
          <div class=“text-spinner-viewport”>
               <div class=“single-character char-1”>1</div>
               <div class=“single-character char-2”>2</div>
               <div class=“single-character char-3”>3</div>
               <div class=“single-character char-4”>4</div>
   	        <div class=“single-character char-5”>5</div>
   	        <div class=“single-character char-6”>6</div>
          </div>
     </div>
     <div class=“text-spinner-char”><!-- third spinnable place -->
          <div class=“text-spinner-viewport”>
               <div class=“single-character char-1”>1</div>
               <div class=“single-character char-2”>2</div>
               <div class=“single-character char-3”>3</div>
               <div class=“single-character char-4”>4</div>
   	        <div class=“single-character char-5”>5</div>
   	        <div class=“single-character char-6”>6</div>
         </div>
     </div>
</div>

 

If you’re confused by what the HTML code represents, then the following diagram should help clarify what’s what: HTML-TEXTSPINNER

For the CSS code I use SASS, which among lots of other cool features, makes it easier to generate repetitive CSS code blocks. Below, you’ll find the SASS code where I’ve made comments to make it easier to follow:

.vs-text-spinner{
    //set the font-size here to be 1 times the font-size of the parent container. 
    font-size:1em;
    //set the height to be 1.3em, the extra 0.3em is to cover for lowercase characters cases. 
    height:1.3em;
    //set the container to be an inline-block element, such that it behaves like a block element,
      //but flows with text
    display:inline-block;
    //this is optional 
    text-align:right;
    //add padding space from each side
    padding-right:2px;
    padding-left:2px;

    .text-spinner-char{
        //set the position of this element to be relative. This is
        //important because the viewport element will be positioned
        //absolutely with respect to this element. 
        position:relative;
        //again set each spinnable character to behave like a block, 
        //but flow with text 
        display:inline-block;
        //set the width of each character with respect to the font-size 
        width:0.8em;
        height:100%;
        margin-left:1px;
        overflow:hidden;

        .text-spinner-char-viewport{
            position:relative;
            display:block;
            width:0.8em;
            -webkit-transform: translateZ(0); 
            -ms-transform: translateZ(0); 
            -o-transform: translateZ(0); 
            transform: translateZ(0); 
            -webkit-transition: margin-top 1s cubic-bezier(0,0,0,1);
            -o-transition: margin-top 1s cubic-bezier(0,0,0,1);
            transition: margin-top 1s cubic-bezier(0,0,0,1);

            //we can optimise the animation if css-transforms are supported. 
            //in the JavaScript code, we'll use modernizr to check if transforms
            //are supported will transition the transform property rather than
            //using the "margin-top" property. 
            .csstransforms.csstransforms3d &{
                -webkit-transition: transform 1s cubic-bezier(0, 0, 0, 1);
                -o-transition: transform 1s cubic-bezier(0, 0, 0, 1);
                transition: transform 1s cubic-bezier(0, 0, 0, 1);
            }

        }

        .single-char{
            position:absolute;
            -webkit-box-sizing: border-box;
            -moz-box-sizing: border-box;
            box-sizing: border-box;
            text-align:center;
            top:0px;
            width:0.8em;
            //set the height of each character/digit to be 1em 
            height:1em;
            //add margins (again margins specified with respect to the font-size)
            margin:0.2em 0;
            left:0px;
        }
    }
}

The CSS is easy to follow, but it’s worth emphasising that in this case we use transforms instead of top/margin-top when moving the viewport. This is a technique often used to ensure smooth animations in CSS. To put it simply, changing top/margin-top requires the browser to perform a relayout which then forces the browser to perform a repaint, while transforms don’t. Less repaints equates to higher performance! The logic behind it is much more elaborate, for more information about transforms/relayout/repaint, I recommend these posts by Paul Irish and Chris Coyier.

Next, we code in JavaScript:

(function($W,$D,$){
    //Modernizr check omitted for space
    //A simple jQuery plugin that basically reverses the order of the selected set of elements. 
    jQuery.fn.reverse = jQuery.fn.reverse || function(){
        return $(Array.prototype.reverse.call(this));
    };
    /**
     * @name TextSpinner
     * @constructor 
     * @param places {number} initial number of spinnable places 
     * @param charSet {string|Array<string>} the character set provided as either a string or an array. 
     * @description constructs a new TextSpinner widget instance 
     */ 
    var TextSpinner = function(places,charSet){
        /**
         * @name places
         * @propertyOf TextSpinner
         * @description the number of spinnable places 
         */ 
        this.places = places; 
        /**
         * @name charSet
         * @propertyOf TextSpinner
         * @description the character set to use
         * @type {Array<string>}
         */ 
        this.charSet = (typeof charSet === "string")?charSet.split(''):charSet;
        /**
         * @name el
         * @propertyOf TextSpinner
         * @description a reference to the DOM element of the widget 
         * @type {DOMElement}
         */
        this.el = null;
        /**
         * @name viewportEl 
         * @propertyOf TextSpinner
         * @description a reference to the viewport DOM element 
         * @type {DOMElement}
         */
        this.viewportEl = null;
    };

    TextSpinner.prototype = {
        /**
         * @name TextSpinner#setText
         * @param {number|string} the new text to transition to
         * @description smoothly transitions the text spinner from the old value to the 
         * provided value. 
         * @methodOf TextSpinner
         */
        setText:function(num){
            var txt = (num+''),
                itm,
                n = txt.split('').reverse(),
                clz = 'char-',idx,i,l; 
            if (n.length !== this.places){
                this.render(null,n.length);
                this.places = n.length;
            }
            for(i=0,l=this.places;i<l;i++){
                idx = this.charSet.indexOf(n[i]); 
                itm = this.items
                    .eq(i)
                    .children();
                if (idx !== -1){
                    if (Modernizr.csstransforms && Modernizr.csstransforms3d){
                        itm.css('transform','translateZ(0) translateY('+(-idx*1.4)+'em)');
                    }else {
                        itm.css('margin-top',(-idx*1.4)+'em');
                    }
                }else {
                    if (Modernizr.csstransforms && Modernizr.csstransforms3d){
                        itm.css('transform','translateZ(0)');
                    }else {
                        itm.css('margin-top','0'); 
                    }
                }
            }
        },
        /**
         * @name TextSpinner#addChar
         * @description Adds a new spinnable place, this method is called every time a new 
         * spinnable place is required. 
         * @methodOf TextSpinner
         */
        addChar:function(){
            var charEl = $('<div>')
                .attr('class','text-spinner-char char-0')
                .prepend($('<div>')
                    .addClass('text-spinner-char-viewport'))
            this.addCharSetToChar(charEl);
            charEl.appendTo(this.el);
            return charEl;
        },
        /**
         * @name TextSpinner#removeChar
         * @description Removes a single spinnable place from the widget, this method is called
         * to remove spinnable places when not in use.  
         * @methodOf TextSpinner
         */
        removeChar:function(){
            this.el.find('.text-spinner-char').first().remove();
        },
        /**
         * @name TextSpinner
         * @description Adds DOMElements for each charset to the given spinnable place. 
         * The method is called every time a new spinnable place is inserted. 
         * @param {DOMElement} charEl the new spinnable place element. 
         * @methodOf TextSpinner
         */
        addCharSetToChar:function(charEl){
            var viewportEl = charEl.children('.text-spinner-char-viewport'); 
            for(i=0,l=this.charSet.length;i<l;i++){
                viewportEl.append($('<div>')
                    .addClass('single-char char-'+i)
                    .css('top',(1.4*i)+'em')
                    .html(this.charSet[i]));
            }
        },
        /**
         * @name TextSpinner#render
         * @description Renders the @link{TextSpinner}[TextSpinner] widget.
         * The first call to this method must pass a DOMElement to add the widget to. 
         * All subsequent calls do not need a parentEl. Also note if no parent container
         * is passed during the first call, the widget will be added to the body of the page. 
         * @param {DOMElement} parentEl the element to add the widget to. 
         * @param {number} the number of places to render
         * @methodOf TextSpinner
         */
        render:function(parentEl,places){
            var charEl,i,l,viewportEl, par = parentEl || $('body');
            this.places = places || this.places;
            if (!this.el){
                this.el = $('<div>')
                    .addClass('vs-text-spinner')
                    .appendTo(par); 
                for(i=0,l=this.places;i<l;i++){
                    this.addChar();
                }
            }else if (places && (places !== this.places)){
                var dif = places - this.places;
                if (dif > 0){
                    //add places
                    for(i=this.places;i<places;i++){
                        this.addChar();                    
                    }
                }else {
                    //remove places
                    for(i=0,l=Math.abs(dif);i<l;i++){
                        this.removeChar();
                    }
                }
            }
            this.items = this.el.find('.text-spinner-char').reverse();
        },
        /**
         * @name TextSpinner#removeClass
         * @methodOf TextSpinner
         * @param {string} clz the class to remove from the widget's element
         * @description provides a way to remove custom CSS classes from the widget
         */
        removeClass:function(clz){
            this.el.removeClass(clz); 
        },
        /**     
         * @name TextSpinner#addClass
         * @methodOf TextSpinner
         * @param {string} clz the class to add to the widget's element
         * @description provides a way to add own CSS classes.
         * The method is intended for users to add their own style to the 
         * widget. 
         */
        addClass:function(clz){
            this.el.addClass(clz); 
        }

    };

    $W.vs = $W.vs || {};
    $W.vs.TextSpinner = TextSpinner;
})(window,document,jQuery);

The two most important points here are: “render” and “setText”. The “render” method renders the TextSpinner widget on screen and is also called upon every time the spinnable place counter changes. The “setText” method is used to set the current text of the widget. The method first converts whatever is passed to it into string, then it checks if the available spinnable places are enough to accommodate the new value. If the new value requires more spaces, the render method is called to create those places. Similarly, if the available places are more than the required places, the render method is called to remove the extra spinnable places. Internally, the “setText” method moves the viewport element of each spinnable place up/down depending on the character that needs to be shown in that place. The movement is done through controlling either the “transform” property and if not supported it will fallback to the “margin-top” property.

Here is a demo:

See the Pen meRvoJ by Suhail Abood (@suhdev) on CodePen.0

For the sake of this blog post I have simplified the process, however, the fully implemented/tested widget is available here.

Please do use it, test it, and let me know how you get on.

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>