8

I have a combination lock rotating in a 360 degrees circle.

The combination lock has numerical values on it, these are purely graphical.

I need a way to translate the image's rotation to the 0-99 values on the graphic.

In this first graphic, the value should be able to tell me "0"

In this graphic, after the user has rotated the image, the value should be able to tell me "72"

Here is the code:

package co.sts.combinationlock;

import android.os.Bundle;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
import android.util.Log;
import android.view.GestureDetector;
import android.view.Menu;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.GestureDetector.SimpleOnGestureListener;
import android.view.View.OnTouchListener;
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import android.widget.ImageView;
import android.support.v4.app.NavUtils;

public class ComboLock extends Activity{

        private static Bitmap imageOriginal, imageScaled;
        private static Matrix matrix;

        private ImageView dialer;
        private int dialerHeight, dialerWidth;

        private GestureDetector detector;

        // needed for detecting the inversed rotations
        private boolean[] quadrantTouched;

        private boolean allowRotating;

        @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_combo_lock);

        // load the image only once
        if (imageOriginal == null) {
                imageOriginal = BitmapFactory.decodeResource(getResources(), R.drawable.numbers);
        }

        // initialize the matrix only once
        if (matrix == null) {
                matrix = new Matrix();
        } else {
                // not needed, you can also post the matrix immediately to restore the old state
                matrix.reset();
        }

        detector = new GestureDetector(this, new MyGestureDetector());

        // there is no 0th quadrant, to keep it simple the first value gets ignored
        quadrantTouched = new boolean[] { false, false, false, false, false };

        allowRotating = true;

        dialer = (ImageView) findViewById(R.id.locknumbers);
        dialer.setOnTouchListener(new MyOnTouchListener());
        dialer.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() {

                @Override
                        public void onGlobalLayout() {
                        // method called more than once, but the values only need to be initialized one time
                        if (dialerHeight == 0 || dialerWidth == 0) {
                                dialerHeight = dialer.getHeight();
                                dialerWidth = dialer.getWidth();

                                // resize
                                        Matrix resize = new Matrix();
                                        //resize.postScale((float)Math.min(dialerWidth, dialerHeight) / (float)imageOriginal.getWidth(), (float)Math.min(dialerWidth, dialerHeight) / (float)imageOriginal.getHeight());
                                        imageScaled = Bitmap.createBitmap(imageOriginal, 0, 0, imageOriginal.getWidth(), imageOriginal.getHeight(), resize, false);

                                        // translate to the image view's center
                                        float translateX = dialerWidth / 2 - imageScaled.getWidth() / 2;
                                        float translateY = dialerHeight / 2 - imageScaled.getHeight() / 2;
                                        matrix.postTranslate(translateX, translateY);

                                        dialer.setImageBitmap(imageScaled);
                                        dialer.setImageMatrix(matrix);
                        }
                        }
                });

    }

        /**
         * Rotate the dialer.
         *
         * @param degrees The degrees, the dialer should get rotated.
         */
        private void rotateDialer(float degrees) {
                matrix.postRotate(degrees, dialerWidth / 2, dialerHeight / 2);

                //need to print degrees

                dialer.setImageMatrix(matrix);
        }

        /**
         * @return The angle of the unit circle with the image view's center
         */
        private double getAngle(double xTouch, double yTouch) {
                double x = xTouch - (dialerWidth / 2d);
                double y = dialerHeight - yTouch - (dialerHeight / 2d);

                switch (getQuadrant(x, y)) {
                        case 1:
                                return Math.asin(y / Math.hypot(x, y)) * 180 / Math.PI;

                        case 2:
                        case 3:
                                return 180 - (Math.asin(y / Math.hypot(x, y)) * 180 / Math.PI);

                        case 4:
                                return 360 + Math.asin(y / Math.hypot(x, y)) * 180 / Math.PI;

                        default:
                                // ignore, does not happen
                                return 0;
                }
        }

        /**
         * @return The selected quadrant.
         */
        private static int getQuadrant(double x, double y) {
                if (x >= 0) {
                        return y >= 0 ? 1 : 4;
                } else {
                        return y >= 0 ? 2 : 3;
                }
        }

        /**
         * Simple implementation of an {@link OnTouchListener} for registering the dialer's touch events.
         */
        private class MyOnTouchListener implements OnTouchListener {

                private double startAngle;

                @Override
                public boolean onTouch(View v, MotionEvent event) {

                        switch (event.getAction()) {

                                case MotionEvent.ACTION_DOWN:

                                        // reset the touched quadrants
                                        for (int i = 0; i < quadrantTouched.length; i++) {
                                                quadrantTouched[i] = false;
                                        }

                                        allowRotating = false;

                                        startAngle = getAngle(event.getX(), event.getY());
                                        break;

                                case MotionEvent.ACTION_MOVE:
                                        double currentAngle = getAngle(event.getX(), event.getY());
                                        rotateDialer((float) (startAngle - currentAngle));
                                        startAngle = currentAngle;
                                        break;

                                case MotionEvent.ACTION_UP:
                                        allowRotating = true;
                                        break;
                        }

                        // set the touched quadrant to true
                        quadrantTouched[getQuadrant(event.getX() - (dialerWidth / 2), dialerHeight - event.getY() - (dialerHeight / 2))] = true;

                        detector.onTouchEvent(event);

                        return true;
                }
        }

        /**
         * Simple implementation of a {@link SimpleOnGestureListener} for detecting a fling event.
         */
        private class MyGestureDetector extends SimpleOnGestureListener {
                @Override
                public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {

                        // get the quadrant of the start and the end of the fling
                        int q1 = getQuadrant(e1.getX() - (dialerWidth / 2), dialerHeight - e1.getY() - (dialerHeight / 2));
                        int q2 = getQuadrant(e2.getX() - (dialerWidth / 2), dialerHeight - e2.getY() - (dialerHeight / 2));

                        // the inversed rotations
                        if ((q1 == 2 && q2 == 2 && Math.abs(velocityX) < Math.abs(velocityY))
                                        || (q1 == 3 && q2 == 3)
                                        || (q1 == 1 && q2 == 3)
                                        || (q1 == 4 && q2 == 4 && Math.abs(velocityX) > Math.abs(velocityY))
                                        || ((q1 == 2 && q2 == 3) || (q1 == 3 && q2 == 2))
                                        || ((q1 == 3 && q2 == 4) || (q1 == 4 && q2 == 3))
                                        || (q1 == 2 && q2 == 4 && quadrantTouched[3])
                                        || (q1 == 4 && q2 == 2 && quadrantTouched[3])) {

                                dialer.post(new FlingRunnable(-1 * (velocityX + velocityY)));
                        } else {
                                // the normal rotation
                                dialer.post(new FlingRunnable(velocityX + velocityY));
                        }

                        return true;
                }
        }

        /**
         * A {@link Runnable} for animating the the dialer's fling.
         */
        private class FlingRunnable implements Runnable {

                private float velocity;

                public FlingRunnable(float velocity) {
                        this.velocity = velocity;
                }

                @Override
                public void run() {
                        if (Math.abs(velocity) > 5 && allowRotating) {
                                //rotateDialer(velocity / 75);
                                //velocity /= 1.0666F;

                                // post this instance again
                                dialer.post(this);
                        }
                }
        }
}

I think I need to translate some information from the matrix to a 0-99 value.

Glorfindel
  • 21,988
  • 13
  • 81
  • 109
CQM
  • 42,592
  • 75
  • 224
  • 366

4 Answers4

8

You should reorganize your code completely. Post-multiplying new rotations into a matrix over and over again is a numerically unstable computation. Eventually the bitmap will become distorted. Trying to retrieve the rotation angle from the matrix is too complex and unnecessary.

First note that this is a useful prior article on drawing bitmaps with rotation about a chosen point.

Just maintain a single double dialAngle = 0 that is the current rotation angle of the dial.

You are doing way too much work to retrieve the angle from the touch location. Let (x0,y0) be the location where the touch starts. At that time,

// Record the angle at initial touch for use in dragging.
dialAngleAtTouch = dialAngle;
// Find angle from x-axis made by initial touch coordinate.
// y-coordinate might need to be negated due to y=0 -> screen top. 
// This will be obvious during testing.
a0 = Math.atan2(y0 - yDialCenter, x0 - xDialCenter);

This is the starting angle. When the touch drags to (x,y), use this coordinate to adjust the dial with respect to the initial touch. Then update the matrix and redraw:

// Find new angle to x-axis. Same comment as above on y coord.
a = Math.atan2(y - yDialCenter, x - xDialCenter);
// New dial angle is offset from the one at initial touch.
dialAngle = dialAngleAtTouch + (a - a0); 
// normalize angles to the interval [0..2pi)
while (dialAngle < 0) dialAngle += 2 * Math.PI;
while (dialAngle >= 2 * Math.PI) dialAngle -= 2 * Math.PI;

// Set the matrix for every frame drawn. Matrix API has a call
// for rotation about a point. Use it!
matrix.setRotate((float)dialAngle * (180 / 3.1415926f), xDialCenter, yDialCenter);

// Invalidate the view now so it's redrawn in with the new matrix value.

Note Math.atan2(y, x) does all of what you're doing with quadrants and arcsines.

To get the "tick" of the current angle, you need 2 pi radians to correspond to 100, so it's very simple:

double fractionalTick = dialAngle / (2 * Math.Pi) * 100;

To find the actual nearest tick as an integer, round the fraction and mod by 100. Note you can ignore the matrix!

 int tick = (int)(fractionalTick + 0.5) % 100;

This will always work because dialAngle is in [0..2pi). The mod is needed to map a rounded value of 100 back to 0.

Community
  • 1
  • 1
Gene
  • 46,253
  • 4
  • 58
  • 96
  • Gene is right. You shouldn't be accumulating into a transformation matrix. Take the user input, accumulate it into a "dialRotation" value and compute fresh rotation matrices from that every time. – Edward Falk Jul 22 '12 at 16:59
5

To better understand what the matrix does, it's helpful to understand 2d graphics transform matrices: http://en.wikipedia.org/wiki/Transformation_matrix#Examples_in_2D_graphics . If the only thing that you are doing is rotating (not, say, transforming or scaling) it is relatively easy to extract rotation. But, more practically, you may modify the rotation code, and store a state variable

    private float rotationDegrees = 0;

    /**
     * Rotate the dialer.
     *
     * @param degrees The degrees, the dialer should get rotated.
     */
    private void rotateDialer(float degrees)
            matrix.postRotate(degrees, dialerWidth / 2, dialerHeight / 2);

            this.rotationDegrees += degrees;

            // Make sure we don't go over 360
            this.rotationDegrees = this.rotationDegrees % 360

            dialer.setImageMatrix(matrix);
    }

Keep a variable to store the total rotation in degrees, which you increment in your rotate function. Now, we know 3.6 degrees is a tick. Simple math yields

tickNumber = (int)rotation*100/360
// It could be negative
if (tickNumber < 0)
    tickNumber = 100 - tickNumber

The one last thing you have to check for: If you have a rotation of exactly 360 degrees, or a tick number of 100, you have to treat it as 0 (since there is no tick 100)

dmi_
  • 1,187
  • 2
  • 12
  • 26
  • there are some issues with this, I think it has to do with the variables that rotateDialer is actually taking in. can you look at this in MotionEvent.Action_Move – CQM Jul 12 '12 at 21:04
  • The variable that rotateDialer accepts is a change in angle, which is what the code you indicated calculates. That's why we store a variable rotationDegrees: the person dialing can move +90 degrees and then -180, leaving us at -90. One thing that may be wrong, though, is that positive rotation is counter-clockwise, in which case `if (tickNumber > 0) tickNumber = 100 - tickNumber` instead. – dmi_ Jul 16 '12 at 15:05
4

This should be a simple multiplication with a "scale" factor that scales down your degree value (0-359) to your 0-99 scale:

float factor = 99f / 359f;
float scaled = rotationDegree * factor;

EDIT: Correcting the getAngle function

For getAngle you could use the atan2 function instead, which transforms cartesian coordinates into an angle.

Just store the first touch coordinate on touch down and on move you can apply the following calculation:

            // PointF a = touch start point
            // PointF b = current touch move point

            // Translate to origin:
            float x = b.x - a.x;
            float y = b.y - a.y;

            float radians = (float) ((Math.atan2(-y, x) + Math.PI + HALF_PI) % TWO_PI);

The radians have a range of two pi. the modulo calculations rotate it so a value of 0 points up. The rotation direction is counter-clockwise.

So you'd need to convert that to degrees and change rotation direction for getting the correct angle.

tiguchi
  • 5,392
  • 1
  • 33
  • 39
  • very cool, there is something wrong with the variables generated in my MotionEvent.Action_Move case, this screws up and translation a little, can you look at that? – CQM Jul 12 '12 at 21:14
  • 1
    @CQM I updated my comment by copy-pasting something from my code base that is pretty similar to what you need. It is possible that you need to adjust it, because I didn't verify the maths behind. – tiguchi Jul 12 '12 at 21:37
2

The dial should be rotated exactly 3.6 degrees to go from one mark to the next or previous. Everytime the user's touch rotates(around the center) by 3.6 degrees, the dial should be rotated by 1 mark(3.6 degrees).

Code snippet:

float touchAngle = getTouchAngle();
float mark = touchAngle / 3.6f;
if (mCurrentMark != mark) { 
    setDialMark(mark); 
}
  • getTouchAngle() calculates the angle of user's touch point wrt to dial center using atan2.
  • setDialMark rotates the dial by number of marks changed.

.

void setDialMark(int mark) {  
    rotateDialBy(mCurrentMark - mark);  
    mCurrentMark = mark;    
}

void rotateDialBy(int rotateMarks) {
    rotationAngle = rotateMarks * 3.6;
    ...
    /* Rotate the dial by rotationAngle.   */
}
Ron
  • 24,175
  • 8
  • 56
  • 97