Ionic Wheel 2 + Velocity Decay


Ionic Wheel 2 + Velocity Decay

This is Part Deux of a module called Ionic Wheel but this time incorporating velocity decay on dragend, also known as the event that occurs after a user swipes their digit across the screen.

The same general components (a rotating wheel on user drag) remain as the original Ionic Wheel but its internal workings have been refactored to be cleaner and to include the aforementioned velocity decay. The velocity decay is accomplished using the Collide animation engine which is included as a bower dependency.

If you haven't heard of collide (it's been mentioned a few times in previous blog posts of mine), it is a Javascript Animation Engine modeled after Facebook Pop, an animation engine for ios, providing a powerful way to control CSS animation and transitions by allowing users to control every frame. Additionally, it was built by the Ionic team specifically for web and hybrid apps but unfortunately, it's no longer maintained. Last significant commit was over a year ago but hey!...it still works great.

One day, I want to adopt it. But that day is not today. Today, is about how to implement this powerful module to recreate the appearance of velocity decay for Ionic Wheel. More specifically, when the user drags the wheel, the wheel should keep going at a proportionate velocity when the user lets go of the wheel and slowly decelerate to a halt.

Before, we discuss how to implement Collide, I think it's best to address the most significant differences between Ionic Wheel and Ionic Wheel 2.


Differences from Ionic Wheel 1


1. Introduced a new ion-wheel-item directive.

In the previous version of Ionic Wheel, the parent directive ionic-wheel identified menu items to position absolutely in a circle based on whether it had a CSS class of .circle. This was done out of laziness as .circle is way too generic to rely the directive upon. Instead, this version incorporates a new directive called ion-wheel-item which wraps any icon or text that should represent a single menu item. For clarification, each ion-wheel-item, when included as a child of ion-wheel will be positioned in the circle and will be rotate on drag.

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


2. Most of the Ionic Wheel logic has been moved to a constructor.

A new IonicWheel constructor has been created to hold most of the magic and placed outside of the directive, removing it from the encapsulation of the anonymous function in the second parameter of the directive. Why, you say? By placing outside of the directive and giving it a name, we ensure that it's read once at runtime and every time angular registers ion-wheel during the compilation of an HTML template, it will reference that function. Additionally, the entire directive is wrapped in an IFFE, so this new IonicWheel constructor is available only within the scope of this directive.

The code snippet below shows the code for ion-wheel and illustrates its two interal processes 1.) Create a new IonicWheel instance including the DOM element itself and all menu items, identified by their tag ion-wheel-item 2.) Setup a listener on the $destroy event. When the directive is destroyed (i.e. user goes to a different view), it will remove all event listeners associated with the directive (touch, dragstart, drag, dragend).

angular.module('ionic.wheel', ['ionic'])

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

      var ionicWheel = new IonicWheel({
        el: $element[0],
        menuItems: $element[0].querySelectorAll('ion-wheel-item')
      });

      $scope.$on('$destroy', function() {
        var gestures = ionicWheel._gestures;
        ionic.offGesture(gestures.touch, 'touch', ionicWheel.onTouch);
        ionic.offGesture(gestures.dragstart, 'dragstart', ionicWheel.onDragStart);
        ionic.offGesture(gestures.drag, 'drag', ionicWheel.onDrag);
        ionic.offGesture(gestures.dragend, 'dragend', ionicWheel.onDragEnd);
      });

    }
  }

});

Ionic Wheel 2 (Events)

The way the directive calculates the position of each menu item in a circle and how each menu item rotates on drag is covered somewhat (though admitedly crappily) in the Ionic Wheel blog post I wrote not too long ago. I won't be going over that aspect and am rather going to focus on how the all the event listeners work together to achieve th perception of velocity decay using Collide.

1.) Dragstart

onDragStart: function(e) {  
  var pageX = e.gesture.touches[0].pageX;
  var pageY = e.gesture.touches[0].pageY;
  this._updatedAngle = this._calculateAngle(pageX, pageY);
},

When a user touches the wheel, it's likely a expectation that the wheel will rotate the same proportion as the distance their finger is dragged across the screen. In other words, if they place their finger on an icon and drag their finger 180 degrees around in a circle, they'd probably expect that same icon to be under their finger, not at 200 degrees or 60 degrees but 180 degrees. There is a challenge associated with maintaining this consistency and the dragstart event is the first step in doing it.

On dragstart, the location of the very first touch (finger) in relation to the page (screen) are referenced and these are both used to calculate the angle of the touch in relation to the center of the wheel. This value is saved on the IonicWheel object as _updatedAngle and the angle may be anywhere from 0 to 360, both starting and ending on the horiziontal line jutting from the center (shown below). This is the grid that the module will map all movements to.

If you're wondering what _calculateAngle(pageX, pageY) does, check out the Ionic Wheel blog post

image

2.) Drag

onDrag: function(e) {  
  var pageX = e.gesture.touches[0].pageX;
  var pageY = e.gesture.touches[0].pageY;

  this._currentAngle = this._calculateAngle(pageX, pageY) - this._updatedAngle + this._originalAngle;

  this.el.style.transform = this.el.style.webkitTransform  = 'rotate(' + this._currentAngle + 'deg)';

  for (var i = 0; i < this.menuItems.length; i++) {
    this.menuItems[i].style.transform = this.menuItems[i].style.webkitTransform = 'rotate(' + -this._currentAngle + 'deg)';
  }
},

After the initial dragstart, the wheel must be responsive to the user's finger and rotate the wheel based on the direction of the finger movement. The rotation being a series of very small incremental steps constructed in the callback of the drag event. On each one of these small steps, the value of the _currentAngle is calculated, which is the angle of the current touch location(_calculatedAngle) minus the angle on dragstart (_updatedAngle) plus the angle of the wheel before the drag even took place (_originalAngle). That's a mouthful so I'll break it down in an example.

image

When the IonicWheel module is instantiated, the _originalAngle is 0, because the user has not interacted with the wheel yet. Let's say the user touches the screen with their finger at approximately the 90 degree marker of our grid and then drags their finger across until they finally release their finger at the 180 degree marker. In this case, the wheel should rotate a value equal to the difference of those two values, 90 degrees. Visually, the pin icon should now be where the mail icon was located. The rotation movement is created by incrementing the degree values in the CSS property transform: rotate(0deg).

Remember the supplied callback to the drag event is invoked on every step, so we need a simple equation to derive how far the wheel should move from 0:

_currentAngle = _calculatedAngle + _updatedAngle - _originalAngle  

And here are the results of the equation from dragging from 90 to 180 degrees:

  • 1 = 91 - 90 - 0
  • 2 = 92 - 90 - 0
  • 3 = 93 - 90 - 0
  • ...
  • 90 = 180 - 90 - 0

It might seem quite odd to include _originalAngle if it's always equal to zero because it doesn't affect the outcome of the equation. But this is only true for very first drag event. This is not the case for subsequent events because the originalAngle is updated to the current angle of the wheel on dragend and touch.

image

Following the example the above, let's say the user dragged the screen a second time. The first drag was exactly 90 degrees, therefore the _.originalAngle was updated to be 90 degrees, serving as a starting point for the second drag event.

And here are the results of dragging from 180 to 270 degrees:

  • 91 = 181 - 180 - 90
  • 92 = 182 - 180 - 90
  • 93 = 183 - 180 - 90
  • ...
  • 180 = 270 - 180 - 90

The end values are then used to update the rotate value of the CSS transform.

this.el.style.transform = this.el.style.webkitTransform  = 'rotate(' + this._currentAngle + 'deg)';  


3.) Dragend

onDragEnd: function(e) {  
  var self = this;

  var velocity = e.gesture.velocityX + e.gesture.velocityY / 2,
      distance = velocity * 200;

  if(velocity > 0.5) {
    self._animation = collide.animation()

    .on('start', function() {
      self._inProgress = true;
    })

    .on('step', function(v) {
      distance = distance*0.95;
      var animateAngle = (self._currentAngle > 0) ? (self._currentAngle + (-distance)) : (self._currentAngle + (distance));
      self.el.style.transform = self.el.style.webkitTransform  = 'rotate(' + animateAngle + 'deg)';
    })
    .on('complete', function() {
      self._inProgress = false;
      self._originalAngle = self._currentAngle;
    })

    .velocity(velocity)
  }
},

When the user releases their finger from the drag, they can do so in one of two ways, while their finger is in motion and when it's at a standstill. This distinction is important because releasing the drag while in motion would create the expectation that the velocity of the drag would carry through at a decaying rate whereas releasing the drag while at a standstill would create the expectation that it would not. But how do we determine the velocity of a drag event?

Velocity is solved using the equation:

Velocity = Distance * Time  

Knowing that distance multiplied by time equals velocity we could probably set up a couple performance.now() between the dragstart and dragend events, then measure the distance travelled and the time in milliseconds. That might work in simple cases but there are edge cases. For example, a user might drag their finger slowly across the screen and at the very end flick at a very fast velocity. Or the opposite, they might start with a quick flick and suddenly stop for a period. The end velocity animation should be a result of the velocity at the end, not the sum of everything up to and including the flick.

Fortunately, Ionic provides velocity as part of the gesture property (e.gesture) which is attached to the event object supplied as the first parameter of a gesture even (Ionic ported over Hammer.js). It's separated into two variables e.gesture.velocityY and e.gesture.velocityX which as you might have guessed measures velocity vertically and horizontally. Those simply need to be summed and divided by 2 to get an average velocity in px/ms, usually anywhere between 0 and 5. Additionally, the velocity takes into account those flick movements.

The distance in our equation is calculated as a multiple of velocity:

distance = velocity * 200  

Don't pull your hair out if it doesn't make much sense considering the above mentioned equation for velocity. Shoot for a number that best represents to you how many degrees the wheel should rotate for any given velocity produced from a drag event. You might want something fast or slower/more responsive or less responsive. For me, 200 felt about right.

Once velocity is retrieved, we want to apply a cutoff for any velocity that doesn't exceed 0.5 px/ms. Applying a velocity decay animation to anything below 0.5 px/ms appears unnatural and will leave users wondering why the wheel is moving when they're finger wasn't moving at all at the end of the drag. However, if the velocity is above the cutoff, we'll create a new collide animation instance:

self._animation = collide.animation()  

On every step of the animation, we'll mutiply the distance by a a decay constant of 0.95. And once again, the decay constant is a value that can tinkered with to what feels most natural. As such, the animation is no longer linear, moving 1 degree on each step but rather in progressively smaller intervals.

The distance (in degrees) is then added to the _.currentAngle and optionally made negative depending on the direction of the swipe, determined if the _.currentAngle is greater than 0. If _.currentAngle is above 0, it's a clockwise swipe and if it's negative, it's a counter-clockwise swipe.

.on('step', function(v) {
    distance = distance*0.95;
    var animateAngle = (self._currentAngle > 0) ? (self._currentAngle + (-distance)) : (self._currentAngle + (distance));
    self.el.style.transform = self.el.style.webkitTransform  = 'rotate(' + animateAngle + 'deg)';
})


4.) Touch

onTouch: function(e){  
  if(this._inProgress) this._animation.stop();
},

It's likely expected of a user that while the wheel is rotating (as a result of the dragend animation) that if they touch the screen again, the animation will end. If the wheel were allowed to rotate, decay in velocity, and slowly stop on every dragend event, it might seem like the wheel is broken especialy if the user is trying to stop the animation and is pounding the wheel like a Nintendo controller.

With this in mind, we should probably add a touch event that stops the animation completely. Below, I've added an touch listener that will pause the animation, this._animation being the collide animation object and .stop() being a method on that object.


That concludes this portion of the Ionic Wheel walkthrough. As a final piece, I'll have a third post on how to incorporate a menu item manager with Ionic Wheel, a way to have 100 menu items in the wheel while only showing those within view and in a more performant way than just using ng-repeat.

...and I'll probably redo the first Ionic Wheel post.

...because it's that bad.

Demo GitHub