Saturday, June 18, 2011

Flex 4 Circular Slider

I have been a Flex developer since 2007, and I finally decided that I needed to start blogging about some of my work.  Every once in a while I do something that I think is pretty cool, so I think it is about time I start sharing these experiences.

I have worked on some projects with Flex that involve the user needing to transform objects on the screen.  With these projects, the user always had the use of selection handles on a selection box.  The user could just select the rotation handle and start dragging the mouse to cause the object on the screen to rotate.  However, in a current Flex project, I needed to provide some kind of component to allow the user to rotate an image on the screen without the use of a selection box and selection handles.  In the past I have seen something similar using just a horizontal slider or even up and down arrows to increase or decrease the rotation.  As I thought about what I should use, I came up with the idea of doing a circular slider.  Basically the idea is to have a slider component that rotates onto itself and the user can drag the thumb around the track continuously.  So I set about to accomplish this task. 

To make this happen, I needed to create a new class that extends from spark.components.supportClasses.SliderBase.  Then I needed to override two functions: pointToValue(x:Number, y:Number):Number, and updateSkinDisplayList().

The function pointToValue is called when the slider is being moved.  The purpose is to take a mouse x,y coordinate and convert that to a value on the slider track between the minimum and maximum set on the slider.  To accomplish the circular nature of the the slider, I gather the x,y position of the ellipse at the top, and the center.  Then using the mouse x,y position, I use some fancy trigonometry to find the angle of rotation and convert that number according to the minimum and maximum set for the control.

The function updateSkinDisplayList is called continuously for setting the bounds of skin parts, typically the thumb, whose geometry isn't fully specified by the skin's layout.  If you look at the HSlider or VSlider, you will see that it mostly is used for positioning the thumb somewhere along the track according the value set by the pointToValue function.

So in the circular slider, I needed to take some value and figure out where to place the thumb along the circular track.  This is accomplished by using matrix transformations.  I take the current value on the track, and convert to something in the 0 to 359 range and then I find the x,y coordinate of the center of the ellipse.  I create a new matrix, and set the rotation to the converted value from the track.  I then take the point at the top of the ellipse and use the matrix to convert it.  Since the ellipse may not always be a perfect circle, I also check for any scaling and further adjust the position of the thumb. 

You can also set the width and height of the slider thumb in the MXML.  This is to override the default functionality where the component height would determine the height the thumb.

Another important factor in all of this is the skin file.  The original skins for the HSlider and VSlider use Rects to draw the track.  In order for all of this to look correctly, you need to create a skin for the track that draws it using Ellipses.

In my demo below, I show three different circular sliders.  The one being used is the one selected using the radio buttons below the sliders.  I hope this doesn't confuse anybody.



Below is the source for the Circular slider.  You can download the demo here.

UPDATE 1/5/2011

I now have a new demo to download that includes three separate projects.  I split the Circle Slider into a library project with the skins that can be used for web applications.  There is also a web demo project.  The third project is a mobile demo project that includes skins written for mobile use.  These skins should improve performance on mobile devices.  There still seems to be some lag with the thumb slider but that appears to be normal with Flex mobile applications.  Let me know if you have any questions and the new demos can be downloaded here.

 package com.jerry.components  
 {  
      import flash.display.DisplayObject;  
      import flash.geom.Matrix;  
      import flash.geom.Point;  
      import flash.geom.Rectangle;  
      import mx.core.IDataRenderer;  
      import mx.core.LayoutDirection;  
      import spark.components.supportClasses.SliderBase;  
      /**  
       *   
       * @author Jeremiah  
       */  
      public class CircularSlider extends SliderBase  
      {  
           /**  
            *   
            */  
           public function CircularSlider()  
           {  
                super();  
           }  
           private var _thumbHeight:Number = 11;  
           /**  
            *   
            * @return   
            */  
           public function get thumbHeight():Number { return _thumbHeight; }  
           /**  
            *   
            * @param value  
            */  
           public function set thumbHeight(value:Number):void { _thumbHeight = value; }  
           private var _thumbWidth:Number = 11;  
           /**  
            *   
            * @return   
            */  
           public function get thumbWidth():Number { return _thumbWidth; }  
           /**  
            *   
            * @param value  
            */  
           public function set thumbWidth(value:Number):void { _thumbWidth = value; }  
           /**  
            * @private  
            */  
           override protected function pointToValue(x:Number, y:Number):Number  
           {  
                if (!thumb || !track)  
                     return 0;  
                // find the center of the ellipse  
                var center:Point = new Point((track.getLayoutBoundsWidth() - thumb.getLayoutBoundsWidth()) / 2, (track.getLayoutBoundsHeight() - thumb.getLayoutBoundsHeight()) / 2);  
                // by default we want to begin at the top so we need to find the top of the ellipse  
                var top:Point = new Point(((track.getLayoutBoundsWidth() - thumb.getLayoutBoundsWidth()) / 2) - center.x, -center.y);                 
                // adjust the x,y coordinates  
                var point:Point = new Point(x - center.x, y - center.y);       
                var m:Matrix = new Matrix();  
                // check if the ellipse is an oval  
                if (track.getLayoutBoundsHeight() < track.getLayoutBoundsWidth()) {  
                     m.scale(track.getLayoutBoundsHeight() / track.getLayoutBoundsWidth(), 1);  
                } else if (track.getLayoutBoundsWidth() < track.getLayoutBoundsHeight()) {  
                     m.scale(1, track.getLayoutBoundsWidth() / track.getLayoutBoundsHeight());  
                }  
                // calculate the adjusted point if there is any scaling  
                point = m.transformPoint(point);  
                // calculate the angle between the top of the circle and where the mouse is  
                var degrees:Number = (Math.atan2(top.x * point.y - top.y * point.x, top.x * point.x + top.y * point.y)) * (180 / Math.PI);  
                // adjust the value to account for what is set as the maximum and minimum  
                return (((degrees < 0) ? 360 + degrees : degrees) / (360 / (maximum - minimum))) + minimum;  
           }  
           /**  
            * @private  
            */  
           override protected function updateSkinDisplayList():void  
           {  
                if (!thumb || !track)  
                     return;  
                // adjust the pending value to account for maximum and minimum  
                var pend:Number = ((360 * (pendingValue - minimum)) / (maximum - minimum));  
                // make sure the thumb is the correct size  
                thumb.height = _thumbHeight;  
                thumb.width = _thumbWidth;  
                // find the center of the ellipse  
                var center:Point = new Point(track.getLayoutBoundsWidth() / 2, track.getLayoutBoundsHeight() / 2);  
                // set up a matrix to transform the top of the ellipse based on the angle of rotation  
                var m:Matrix = new Matrix();  
                m.translate(-center.x, -center.y);  
                m.rotate(pend * (Math.PI / 180));  
                m.translate(center.x, center.y);  
                // find the top of the ellipse  
                var top:Point = new Point((track.getLayoutBoundsWidth() / 2), 0);  
                // now transform the point based on the rotation  
                var p:Point = m.transformPoint(new Point(top.x, top.y));  
                // if the ellipse is more of an oval then we need to further adjust the point  
                if (track.getLayoutBoundsWidth() > track.getLayoutBoundsHeight() || track.getLayoutBoundsHeight() > track.getLayoutBoundsWidth()) {  
                     m = new Matrix();  
                     m.translate(-center.x, -center.y);  
                     m.scale(track.getLayoutBoundsWidth() / track.getLayoutBoundsHeight(), 1);  
                     m.translate(center.x, center.y);  
                     p = m.transformPoint(p);  
                }  
                // convert to parent's coordinates.  
                var thumbPos:Point = track.localToGlobal(p);  
                var thumbPosParentX:Number = thumb.parent.globalToLocal(thumbPos).x - (thumb.getLayoutBoundsWidth() / 2);  
                var thumbPosParentY:Number = thumb.parent.globalToLocal(thumbPos).y - (thumb.getLayoutBoundsHeight() / 2);  
                thumb.setLayoutBoundsPosition(Math.round(thumbPosParentX), Math.round(thumbPosParentY));  
           }  
      }  
 }  

5 comments:

R. Petz said...

awesome work, thanks! this will work perfectly for a color wheel picker component I'm working on

Anonymous said...

Thanks for this .

dhaval said...

hi i am using your circular slider but i have one issue.
when i am running on my PC slider works fine but when i deploy the slide on ipad its performance is not good.

when u touch and drag the slider on ipad its shows the lag time.
means when you slide the slider faster the slider thumb remains back,
on actual touch when we move faster the slider thumb is moving very slow.

Jeremiah said...

Dhavel,

The reason it probably isn't working very good on mobile is because of the skins. The skins were written in mxml. For the mobile platform, component skins are more optimal when they are written in all action-script. I have spent a lot of time working in Flex mobile projects in the last few months so I should be able to write new skin files for this component quickly that will be more optimal for mobile. I'll find some time this week to do that. Thanks for bringing that up.

Jeremiah said...

I finished working on mobile skins for the component and I have update the post with the link to the new demo.