Ionic Wheel


Ionic Wheel - Part 1

Ionic Wheel - Part 1 is the first of a three post mini-series on creating the Ionic Wheel module.

It's been a little while since my last post but I come back presenting gifts. The first of such gifts (if you're an Ionic fanboy/fangirl) is a module based out of my want to create a menu that is positioned center screen and has clickable menu items that rotate on drag. Initially, I wanted to also create incorporate an animation that recreates decaying velocity when a user drags at a certain velocity, but that will have to be for another post.

This post will not be a full discussion of how to use Ionic Wheel, for that you can reference the repo that is included as a link at the bottom of the page, but will be a quick discussion of how the directive works.

By the way, this module is not comprehensive and is therefore ripe for people to use and improve on.

The View

First off, the markup is built using standard html with some angular/ionic spice. The entirety of the resulting menu is encapsulated by the angular directive 'ion-wheel and included within is a set of div with the class 'circle' which are the menu items and a div with an id of 'activate' for the center.

When the activate button is clicked, the circles, which will be positioned absolutely within the directive around the perimeter of ion-wheel, are shown. Inversely, if it's clicked again, the circles will shrink and blend into the center.

Moving on...

<ion-wheel>  
  <div id="activate" ng-click="showCircles()"><i ng-class="circlesHidden ? 'ion-arrow-expand' : 'ion-arrow-shrink'"></i></div>
  <div class="circle"><i class="icon ion-home"></i></div>
  <div class="circle"><i class="icon ion-alert-circled"></i></div>
  <div class="circle"><i class="icon ion-heart-broken"></i></div>
  <div class="circle"><i class="icon ion-trash-a"></i></div>
  <div class="circle"><i class="icon ion-email"></i></div>
  <div class="circle"><i class="icon ion-at"></i></div>
  <div class="circle"><i class="icon ion-pin"></i></div>
  <div class="circle"><i class="icon ion-lock-combination"></i></div>
</ion-wheel>  


The JS

The first task undertaken within the directive is that of positioning the circles dynamically to follow the perimeter of its circular parent while also positioning them equadistant to eachother. Ideally, this would be done dynamically and agnostic of how many menu items there are.

And that's exactly what it's doing. It's counting the number of divs with class 'circle' and positioning them appropriately. I would go more in to depth with this explanation but it is done oh-so beautifully by chipChocolate.py on this stack overflow question, from which this functionality is taken from. I must give kudos to it's depth of explanation.

Also, I'd like to mention that it would have been a mistake to use the transform CSS property to position the individual circles because that property is already used in the second part of this module to rotate the circles so that they are always upright. The reasoning is that transform may take several property values such as translate (which may have been used to position) and rotate, and a string is attached with only rotation defined and attached to the style.transform property of every element, overriding any translate property that might have been included.

angular.module('ionic.wheel', [])  
  .directive('ionWheel', function($ionicGesture) {

    return {
      restrict: 'E',
      template: '<div id="ionic-wheel" ng-transclude></div>',
      transclude: true,
      link: function($scope, $element, $attr) {

        /**
         * Get elements
         */
        var circle = $element[0],
            circles = document.getElementsByClassName('circle'),
            circleDimensions = circle.getBoundingClientRect(),
            transcludeDiv = document.getElementById('ionic-wheel'),
            centerCircle = document.getElementById('activate');

        /**
         * Position circles around parent circle
         */

        var theta = [];

        var n = circles.length;

        var r = (window.getComputedStyle(transcludeDiv).height.slice(0, -2) / 2) - (window.getComputedStyle(circles[0]).height.slice(0, -2) / 2);

        var frags = 360 / n;
        for (var i = 0; i <= n; i++) {
            theta.push((frags / 180) * i * Math.PI);
        }

        var mainHeight = parseInt(window.getComputedStyle(transcludeDiv).height.slice(0, -2)) / 1.2;

        var circleArray = [];

        for (var i = 0; i < circles.length; i++) {
          circles[i].posx = Math.round(r * (Math.cos(theta[i]))) + 'px';
          circles[i].posy = Math.round(r * (Math.sin(theta[i]))) + 'px';
          circles[i].style.top = ((mainHeight / 2) - parseInt(circles[i].posy.slice(0, -2))) + 'px';
          circles[i].style.left = ((mainHeight/ 2 ) + parseInt(circles[i].posx.slice(0, -2))) + 'px';
        }


The next task to undertake after all the circles (menu items) have been positioned around the center is to add drag event listeners to rotate the circles. I promise I'm not Math inept but this answer was also beautifully solved following a quick google search by rawiro on this stack overflow question.

First thing, the absolute center of the outer circle, ion-wheel, in relation to the client bounding box is calculated. This is needed because whenever a user triggers a drag event, the event property exposes the position of the touch in relation to the same client bounding box. Knowing where the absolute center of ion-wheel and the absolute position of the drag event, it can derive the arc or in this case the number of degrees the ion-wheel needs to rotate which each subsequent drag event.

Now, this function only affects the rotation of ion-wheel which is also rotating the smaller circles (menu items) but not in the fashion that might be expected. They're not maintaining their upright position but this is quickly resolved by rotating each individual item by the numerical opposite in degrees.


/**
         * Rotate circle on drag
         */

        var center = {
          x: circleDimensions.left + circleDimensions.width / 2,
          y: circleDimensions.top + circleDimensions.height / 2
        };

        var getAngle = function(x, y){
          var deltaX = x - center.x,
              deltaY = y - center.y,
              angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI;

          if(angle < 0) {
            angle = angle + 360;
          }

          return angle;
        };

        var updatedAngle = 0,
            originalAngle = 0,
            currentAngle = 0;

        $ionicGesture.on('dragstart', function(e){
          var pageX = e.gesture.touches[0].pageX;
          var pageY = e.gesture.touches[0].pageY;
          updatedAngle = getAngle(pageX, pageY);
        }, angular.element(circle));

        $ionicGesture.on('drag', function(e){

          e.gesture.srcEvent.preventDefault();

          var pageX = e.gesture.touches[0].pageX;
          var pageY = e.gesture.touches[0].pageY;

          currentAngle = getAngle(pageX, pageY) - updatedAngle + originalAngle;

          circle.style.transform = circle.style.webkitTransform  = 'rotate(' + currentAngle + 'deg)';

          for (var i = 0; i < circles.length; i++) {
            circles[i].style.transform = circles[i].style.webkitTransform = 'rotate(' + -currentAngle + 'deg)';
          }

        }, angular.element(circle));

        $ionicGesture.on('dragend', function(e){
          originalAngle = currentAngle;
        }, angular.element(circle));


And voila, the menu items rotate around in perfect tandem with whichever finger, thumb, or abendage you use to operate your mobile device.

Demo GitHub