Tuesday, February 28, 2012

Howto: A Rotation Knob Widget on Android

One of my projects includes a rotary knob which is missing in the
Android Api. This could be useful as a volume control.

This example demonstrates
  • how to extend ImageView for custom views
  • how to setup an appropriate listener to notify the Activity
  • how to calculate the angle theta of a unit vector w.r.t. the origin in a 2D coordinate system using spherical coordinates
First we need a picture for the view. I made a 300x300 png which is good enough for demonstration:

Save it in your res/drawable folder. I called it "jog.png".

Next, add some stuff to res/layout/main.xml:

 <myprojects.android.RotaryKnobView 
   android:id="@+id/jogView"
   android:layout_width="300px"
   android:layout_height="300px"
   android:layout_gravity="center"
 />

This is the java code for the rotary knob widget:
package myprojects.android;

import android.content.Context;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.ImageView;

public class RotaryKnobView extends ImageView  {

    private float angle = 0f;
    private float theta_old=0f;
 
    float width = 300;
    float height = 300;
  
    private RotaryKnobListener listener;
    
    public interface RotaryKnobListener {
      public void onKnobChanged(int arg);
    }
    
    public void setKnobListener(RotaryKnobListener l )
    {
      listener = l;
    }
    
    public RotaryKnobView(Context context) {
      super(context);
    // TODO Auto-generated constructor stub
    }
    
    public RotaryKnobView(Context context, AttributeSet attrs)
    {
      super(context, attrs);
      initialize();
    }
    
    public RotaryKnobView(Context context, AttributeSet attrs, int defStyle)
    {
      super(context, attrs, defStyle);
      initialize();
    }
    
    private float getTheta(float x, float y)
    {
      float sx = x - (width / 2.0f);
      float sy = y - (height / 2.0f);
 
      float length = (float)Math.sqrt( sx*sx + sy*sy);
      float nx = sx / length;
      float ny = sy / length;
      float theta = (float)Math.atan2( ny, nx );
 
      final float rad2deg = (float)(180.0/Math.PI);
      float theta2 = theta*rad2deg;
 
      return (theta2 < 0) ? theta2 + 360.0f : theta2;
    }
    
    public void initialize()
    {
 
      this.setImageResource(R.drawable.jog);
      setOnTouchListener(new OnTouchListener()
      {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
          // TODO Auto-generated method stub
          int action = event.getAction();
          int actionCode = action & MotionEvent.ACTION_MASK;
          if (actionCode == MotionEvent.ACTION_POINTER_DOWN)
          {
            float x = event.getX(0);
            float y = event.getY(0);
            theta_old = getTheta(x, y);
          }
          else if (actionCode == MotionEvent.ACTION_MOVE)
          {
            invalidate();
       
            float x = event.getX(0);
            float y = event.getY(0);
       
            float theta = getTheta(x,y);
            float delta_theta = theta - theta_old;
       
            theta_old = theta;
       
            int direction = (delta_theta > 0) ? 1 : -1;
            angle += 3*direction;
       
            notifyListener(direction);
 
          }
          return true;
        }

     });
    }
    
    private void notifyListener(int arg)
    {
      if (null!=listener)
       listener.onKnobChanged(arg);
    }
    
    protected void onDraw(Canvas c)
    {
      c.rotate(angle,150,150);   
      super.onDraw(c);
    }
    
}

Note that instead of the actual angle delta the argument to onKnobChanged is +1, when the knob is rotated right, and -1 otherwise. You could change that of course. The size of the widget is a bit cluttered all over the place ... you should clean that.

Finally, we create a listener in the main activity:

/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.main);
    
  RotaryKnobView jogView = (RotaryKnobView)findViewById(R.id.jogView);
  jogView.setKnobListener(new RotaryKnobView.RotaryKnobListener()
  {
     @Override
     public void onKnobChanged(int arg) {
  
      if (arg > 0)
        ; // rotate right 
      else
        ; // rotate left 
    
   });
    
}


That's it.

14 comments:

  1. Awesome piece of code dude, Just what I was looking for. i'm itching to get it up and running now.

    ReplyDelete
  2. thanks buddy!
    let me know if you can get it to work.
    You might also check out

    http://mindtherobot.com/blog/534/android-ui-making-an-analog-rotary-knob/

    - thomas

    ReplyDelete
  3. hi, yeah I tried checking out that link before but my browser gives me a malware warning on access. So I've been staying clear, maybe I@ll try from work tomorrow ;)

    While I'm back here though, and now I've had a play with it, yeah I got it working, The only thing I changed is the "c.rotate(angle,150,150);" to "c.rotate(angle,(width/2),(height/2)); " makes it a bit neater when adjusting the dimensions, which I note have to be in px, other wise all kinds of fun start happening ;)

    I have a question, I have a Radio player on my Sam Galaxy, and this has a rotary knob too, but their knob appears to focus under your finger constantly, I have to admit I would prefer this kind of control, have you seen it or would you know how to adapt your code to do this?

    TR.

    ReplyDelete
  4. Yeah I noticed the malware warning. This is odd, but I ignored it and opened the site.

    Changing angle,150,150 to the right thing is good of course.

    If I got you right (I don't know the player on the Galaxy) it should be simple to adjust my code
    to your needs: when the view is touched, you need to set the current angle to theta.
    It might be enough to add

    angle = theta_old;

    behind the line

    theta_old = getTheta(x, y);

    in the POINTER_DOWN branch; maybe there's more work to do, that also depends on what you want to do. I kept it simple and just send a +1 if it turns right, -1 otherwise. You might need to adjust that too.

    ReplyDelete
  5. Thanks for getting back so quick. I did try your suggestions, but it makes the knob a big shaky. I wish I could work out your code to have a go myself, but it really is a little above my level of java and maths. Its working well at the moment anyway, I added a different job image which minimizes the misalignment. Your implementation of +1, -1 is perfect for my need so all is good.

    cheers
    T.R.

    ReplyDelete
  6. hi, I worked out the finger tracking part. I noticed the angle and theta are 90deg off, so I added this bit of code, and all is well, of course I'm sure I could have just changed my png also!

    angle = theta - 270;

    anyway, cheers its working a treat.
    T.R.

    ReplyDelete
  7. Thanks for the code man!

    I am new to android programming and i want to make two knobs in the same activity. do you know how i can switch between the two ids ?

    i get the same value for both knob.

    ReplyDelete
  8. just give them individual ids, like

    android:id="@+id/jogView1"

    ...

    android:id="@+id/jogView2"

    in your code:

    RotaryKnobView firstJogView = (RotaryKnobView)findViewById(R.id.jogView1);
    ...
    RotaryKnobView secondJogView = (RotaryKnobView)findViewById(R.id.jogView2);

    give each of them his listener and it should work.

    thomas

    ReplyDelete
  9. First, thank you Thomas for sharing :) it means a lot.

    I'm complete novice so can you please tell me how can I pass value (angle)from rotaryKnob class to a textView in main activity. I know, it's dumb, sorry :)

    ReplyDelete
  10. you could add the knob-class a public method getAngle:

    public float getAngle() { return angle; }

    In the listener in your main activity, you can set the textview's text:

    public void onKnobChanged(int arg) {
    TextView tv = getResourceById(...);
    tv.setText("angle: " + jogView.getAngle());
    ...
    }

    I guess this should work.

    - thomas

    ReplyDelete
  11. Hello Thomas, is there a way to access MainActivity variables values from RotaryKnobView class? What ever I try all I get are original declared values.

    I use this in RotaryKnobView

    MainActivity myActiv = new MainActivity();
    float currentAngle = myActiv.getCurrentAngle();

    I'm looking for a way to reset knob to original or some predefined values. Sorry for newbie questions :)

    ReplyDelete
  12. hm, sorry but this makes no sense.
    What might work, though I did not test it, would be to add a method to the KnobView-Class, something like

    public void setAngle(float angle)
    {
    this.angle = angle;
    invalidate();
    }

    -thomas

    ReplyDelete
  13. Nice work.

    I've made a few modifications to support a dynamic size (in dp, or using fill_parent...) and to have the ability to listen to delta and angle. Code is available here : https://gist.github.com/4281823

    Thx!
    Yann

    ReplyDelete