How would I implement a swipe-based circular control like this?
Asked Answered
P

10

20

I am working on an Android application, and I have a TextView where I display a price (for example 50$).

I would like to have a circular control similar to this picture:

enter image description here

  • Swiping a finger clockwise on the dial increases the amount by $1 steps
  • Swiping a finger counter-clockwise on the dial decreases the amount by $1 steps

I did some research but couldn't find a working implementation of something to do this.

How could you create such a circular control driven by swipes?

Pacifa answered 10/3, 2014 at 11:51 Comment(2)
This might help you as well . #11466315Gesso
Not a dupe, suggested duplicate talks about circular navigation, not a control.Clotheshorse
M
9

I've modified the source of circularseekbar to work as you want.

You can get the mofidied class from modified cirucularseekbar

First Include the control in your layout and set your dial as a background

            <com.yourapp.CircularSeekBar
                android:id="@+id/circularSeekBar"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:background="@drawable/amount_wheel_bg" />

Then, in your activity (it should implement OnCircularSeekBarChangeListener) add the following:

//This is a reference to the layout control
private CircularSeekBar circularSeekBar;
//This is a reference to the textbox where you want to display the amount
private EditText amountEditText;
private int previousProgress = -1;

And add the following callback methods:

@Override
   public void onProgressChanged(CircularSeekBar circularSeekBar,
                 int progress, boolean fromUser) {
          if(previousProgress == -1)
          {
                 //This is the first user touch we take it as a reference
                 previousProgress = progress;
          }
          else
          {
                 //The user is holding his finger down
                 if(progress == previousProgress)
                 {
                       //he is still in the same position, we don't do anything
                 }
                 else
                 {
                       //The user is moving his finger we need to get the differences

                       int difference = progress - previousProgress;                        

                       if(Math.abs(difference) > CircularSeekBar.DEFAULT_MAX/2)
                       {
                              //The user is at the top of the wheel he is either moving from the 0 -> MAx or Max -> 0
                              //We have to consider this as 1 step 

                              //to force it to be 1 unit and reverse sign;
                              difference /= Math.abs(difference); 
                              difference -= difference;

                       }                          
                       //update the amount
                       selectedAmount += difference;
                        previousProgress= progress;
                       updateAmountText();
                 }
          }

   }

   @Override
   public void onStopTrackingTouch(CircularSeekBar seekBar) {

          //reset the tracking progress
          previousProgress = -1;

   }

   @Override
   public void onStartTrackingTouch(CircularSeekBar seekBar) {

   }

   private void updateAmountText()
   {
          amountEditText.setText(String.format("%.2f", selectedAmount));
   }

selectedAmount is a double property to store the amount selected.

I hope this can help you.

Mcfarlin answered 19/3, 2014 at 13:25 Comment(2)
Where is the modified circularseekbar source code? It seems your link to Dropbox is broken.Burglarious
as of today (yes, much later) link is broken. Any chance to put it on a gist?Ibbie
T
20

DialView Class :

public abstract class DialView extends View {

    private float centerX;
    private float centerY;
    private float minCircle;
    private float maxCircle;
    private float stepAngle;

    public DialView(Context context) {
        super(context);
        stepAngle = 1;
        setOnTouchListener(new OnTouchListener() {
            private float startAngle;
            private boolean isDragging;
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                float touchX = event.getX();
                float touchY = event.getY();
                switch (event.getActionMasked()) {
                case MotionEvent.ACTION_DOWN:
                    startAngle = touchAngle(touchX, touchY);
                    isDragging = isInDiscArea(touchX, touchY);
                    break;
                case MotionEvent.ACTION_MOVE:
                    if (isDragging) {
                        float touchAngle = touchAngle(touchX, touchY);
                        float deltaAngle = (360 + touchAngle - startAngle + 180) % 360 - 180;
                        if (Math.abs(deltaAngle) > stepAngle) {
                            int offset = (int) deltaAngle / (int) stepAngle;
                            startAngle = touchAngle;
                            onRotate(offset);
                        }
                    }
                    break;
                case MotionEvent.ACTION_UP:
                case MotionEvent.ACTION_CANCEL:
                    isDragging = false;
                    break;
                }
                return true;
            }
        });
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        centerX = getMeasuredWidth() / 2f;
        centerY = getMeasuredHeight() / 2f;
        super.onLayout(changed, l, t, r, b);
    }

    @SuppressLint("DrawAllocation")
    @Override
    protected void onDraw(Canvas canvas) {
        float radius = Math.min(getMeasuredWidth(), getMeasuredHeight()) / 2f;
        Paint paint = new Paint();
        paint.setDither(true);
        paint.setAntiAlias(true);
        paint.setStyle(Style.FILL);
        paint.setColor(0xFFFFFFFF);
        paint.setXfermode(null);
        LinearGradient linearGradient = new LinearGradient(
            radius, 0, radius, radius, 0xFFFFFFFF, 0xFFEAEAEA, Shader.TileMode.CLAMP);
        paint.setShader(linearGradient);
        canvas.drawCircle(centerX, centerY, maxCircle * radius, paint);
        paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
        canvas.drawCircle(centerX, centerY, minCircle * radius, paint);
        paint.setXfermode(null);
        paint.setShader(null);
        paint.setColor(0x15000000);
        for (int i = 0, n =  360 / (int) stepAngle; i < n; i++) {
            double rad = Math.toRadians((int) stepAngle * i);
            int startX = (int) (centerX + minCircle * radius * Math.cos(rad));
            int startY = (int) (centerY + minCircle * radius * Math.sin(rad));
            int stopX = (int) (centerX + maxCircle * radius * Math.cos(rad));
            int stopY = (int) (centerY + maxCircle * radius * Math.sin(rad));
            canvas.drawLine(startX, startY, stopX, stopY, paint);
        }
        super.onDraw(canvas);
    }

    /**
     * Define the step angle in degrees for which the
     * dial will call {@link #onRotate(int)} event
     * @param angle : angle between each position
     */
    public void setStepAngle(float angle) {
        stepAngle = Math.abs(angle % 360);
    }

    /**
     * Define the draggable disc area with relative circle radius
     * based on min(width, height) dimension (0 = center, 1 = border)
     * @param radius1 : internal or external circle radius
     * @param radius2 : internal or external circle radius
     */
    public void setDiscArea(float radius1, float radius2) {
        radius1 = Math.max(0, Math.min(1, radius1));
        radius2 = Math.max(0, Math.min(1, radius2));
        minCircle = Math.min(radius1, radius2);
        maxCircle = Math.max(radius1, radius2);
    }

    /**
     * Check if touch event is located in disc area
     * @param touchX : X position of the finger in this view
     * @param touchY : Y position of the finger in this view
     */
    private boolean isInDiscArea(float touchX, float touchY) {
        float dX2 = (float) Math.pow(centerX - touchX, 2);
        float dY2 = (float) Math.pow(centerY - touchY, 2);
        float distToCenter = (float) Math.sqrt(dX2 + dY2);
        float baseDist = Math.min(centerX, centerY);
        float minDistToCenter = minCircle * baseDist;
        float maxDistToCenter = maxCircle * baseDist;
        return distToCenter >= minDistToCenter && distToCenter <= maxDistToCenter;
    }

    /**
     * Compute a touch angle in degrees from center
     * North = 0, East = 90, West = -90, South = +/-180
     * @param touchX : X position of the finger in this view
     * @param touchY : Y position of the finger in this view
     * @return angle
     */
    private float touchAngle(float touchX, float touchY) {
        float dX = touchX - centerX;
        float dY = centerY - touchY;
        return (float) (270 - Math.toDegrees(Math.atan2(dY, dX))) % 360 - 180;
    }

    protected abstract void onRotate(int offset);

}

Use it :

public class DialActivity extends Activity {

    @Override
    protected void onCreate(Bundle state) {
        setContentView(new RelativeLayout(this) {
            private int value = 0;
            private TextView textView;
            {
                addView(new DialView(getContext()) {
                    {
                        // a step every 20°
                        setStepAngle(20f);
                        // area from 30% to 90%
                        setDiscArea(.30f, .90f);
                    }
                    @Override
                    protected void onRotate(int offset) {
                        textView.setText(String.valueOf(value += offset));
                    }
                }, new RelativeLayout.LayoutParams(0, 0) {
                    {
                        width = MATCH_PARENT;
                        height = MATCH_PARENT;
                        addRule(RelativeLayout.CENTER_IN_PARENT);
                    }
                });
                addView(textView = new TextView(getContext()) {
                    {
                        setText(Integer.toString(value));
                        setTextColor(Color.WHITE);
                        setTextSize(30);
                    }
                }, new RelativeLayout.LayoutParams(0, 0) {
                    {
                        width = WRAP_CONTENT;
                        height = WRAP_CONTENT;
                        addRule(RelativeLayout.CENTER_IN_PARENT);
                    }
                });
            }
        });
        super.onCreate(state);
    }

}

Result :

result

Trimeter answered 19/3, 2014 at 18:24 Comment(2)
THank you for taking the time to write this, but unfortunatly I have already tried Maystro's answer and it worked. so he deserves the answer. too bad I can't split bounty as your answer works too. CheersPacifa
No problem, I also needed it but in my case, with extra-large touch-start zone. I shared my work, maybe others will be interestedTrimeter
M
9

I've modified the source of circularseekbar to work as you want.

You can get the mofidied class from modified cirucularseekbar

First Include the control in your layout and set your dial as a background

            <com.yourapp.CircularSeekBar
                android:id="@+id/circularSeekBar"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:background="@drawable/amount_wheel_bg" />

Then, in your activity (it should implement OnCircularSeekBarChangeListener) add the following:

//This is a reference to the layout control
private CircularSeekBar circularSeekBar;
//This is a reference to the textbox where you want to display the amount
private EditText amountEditText;
private int previousProgress = -1;

And add the following callback methods:

@Override
   public void onProgressChanged(CircularSeekBar circularSeekBar,
                 int progress, boolean fromUser) {
          if(previousProgress == -1)
          {
                 //This is the first user touch we take it as a reference
                 previousProgress = progress;
          }
          else
          {
                 //The user is holding his finger down
                 if(progress == previousProgress)
                 {
                       //he is still in the same position, we don't do anything
                 }
                 else
                 {
                       //The user is moving his finger we need to get the differences

                       int difference = progress - previousProgress;                        

                       if(Math.abs(difference) > CircularSeekBar.DEFAULT_MAX/2)
                       {
                              //The user is at the top of the wheel he is either moving from the 0 -> MAx or Max -> 0
                              //We have to consider this as 1 step 

                              //to force it to be 1 unit and reverse sign;
                              difference /= Math.abs(difference); 
                              difference -= difference;

                       }                          
                       //update the amount
                       selectedAmount += difference;
                        previousProgress= progress;
                       updateAmountText();
                 }
          }

   }

   @Override
   public void onStopTrackingTouch(CircularSeekBar seekBar) {

          //reset the tracking progress
          previousProgress = -1;

   }

   @Override
   public void onStartTrackingTouch(CircularSeekBar seekBar) {

   }

   private void updateAmountText()
   {
          amountEditText.setText(String.format("%.2f", selectedAmount));
   }

selectedAmount is a double property to store the amount selected.

I hope this can help you.

Mcfarlin answered 19/3, 2014 at 13:25 Comment(2)
Where is the modified circularseekbar source code? It seems your link to Dropbox is broken.Burglarious
as of today (yes, much later) link is broken. Any chance to put it on a gist?Ibbie
A
3

I've just written the following code and only tested it theoretically.

private final double stepSizeAngle = Math.PI / 10f; //Angle diff to increase/decrease dial by 1$
private final double dialStartValue = 50.0;

//Center of your dial
private float dialCenterX = 500;
private float dialCenterY = 500;

private float fingerStartDiffX;
private float fingerStartDiffY;

private double currentDialValueExact = dialStartValue;


public boolean onTouchEvent(MotionEvent event) {
    int eventaction = event.getAction();

    switch (eventaction) {
        case MotionEvent.ACTION_DOWN: 
            //Vector between startpoint and center
            fingerStartDiffX = event.getX() - dialCenterX;
            fingerStartDiffY = event.getY() - dialCenterY;
            break;

        case MotionEvent.ACTION_MOVE:
            //Vector between current point and center
            float xDiff = event.getX() - dialCenterX;
            float yDiff = event.getY() - dialCenterY;

            //Range from -PI to +PI
            double alpha = Math.atan2(fingerStartDiffY, yDiff) - Math.atan2(fingerStartDiffX, xDiff);

            //calculate exact difference between last move and current move.
            //This will take positive and negative direction into account.
            double dialIncrease = alpha / stepSizeAngle;        
            currentDialValueExact += dialIncrease;

            //Round down if we're above the start value and up if we are below
            setDialValue((int)(currentDialValueExact > dialStartValue ? Math.floor(currentDialValueExact) : Math.ceil(currentDialValueExact));

            //set fingerStartDiff to the current position to allow multiple rounds on the dial
            fingerStartDiffX = xDiff;
            fingerStartDiffY = yDiff;
            break;
    }

    // tell the system that we handled the event and no further processing is required
    return true; 
}

private void setDialValue(int value) {
    //assign value
}

If you would like to change the direction, simply do alpha = -alpha.

Antefix answered 12/3, 2014 at 13:26 Comment(2)
Could you please elaborate more on the dialCenterX and dialCenterY? how can I get them and what is the unit for them?Pacifa
dialCenterX and dialCenterY are the circles' center-point coordinates of the image which you are using as interface (the example-image you have preserved).Antefix
S
1

Perhaps you could look into the onTouchEvent(MotionEvent) of the view. Keep a track of the x and y coordinates as you move the finger. Notice the pattern of the coordinate changes as you move the finger. You can use that to achieve the increase/decrease in the price. See this link.

Schnapps answered 12/3, 2014 at 11:11 Comment(1)
I thought of this solution at the start but it turned out to be very complex, as the x and y movement will be different based on what area of the wheel you are swiping. I couldn't get the right calculations so I was hoping there will be an easier way, or if someone has done it before.Pacifa
L
1

You can use the Android Gesture Builder from the Android SDK samples.

I can't test it right now, but you should be able to create the app from the sample, run it, create the custom gestures you want (circular clockwise and circular counter clockwise), and then get the gestures raw file from the device/emulator internal storage (it is created by the app after you make the gestures).

With that, you can import it to your project and use the Gesture Library to intercept, register and recognize the specific gestures. You basically add an overlay layout where you want the gesture to be captured and then you decide what to do with it.

See more in depth, step-by-step guide in the following link: http://www.techotopia.com/index.php/Implementing_Android_Custom_Gesture_and_Pinch_Recognition

Leoleod answered 12/3, 2014 at 14:6 Comment(0)
D
1

The OvalSeekbar lib does something like that,I suggest you have a look at how the motion events are done in it.Here is the link to its git https://github.com/kshoji/AndroidCustomViews

Deimos answered 12/3, 2014 at 14:44 Comment(0)
J
1

I wrote this custom FrameLayout to detect circular movement around its center point. I'm using orientation of three points on a plane and the angle between them to determine when the user has made half a circle in one direction and then completes it in the same.

public class CircularDialView extends FrameLayout implements OnTouchListener {
    private TextView counter;
    private int count = 50;

    private PointF startTouch;
    private PointF currentTouch;
    private PointF center;
    private boolean turning;
    private boolean switched = false;

    public enum RotationOrientation {
        CW, CCW, LINEAR;
    }
    private RotationOrientation lastRotatationDirection;

    public CircularDialView(Context context) {
        super(context);
        init();
    }

    public CircularDialView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public CircularDialView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        this.startTouch = new PointF();
        this.currentTouch = new PointF();
        this.center = new PointF();
        this.turning = false;

        this.setBackgroundResource(R.drawable.dial);
        this.counter = new TextView(getContext());
        this.counter.setTextSize(20);
        FrameLayout.LayoutParams params = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
        params.gravity = Gravity.CENTER;
        addView(this.counter, params);

        updateCounter();
        this.setOnTouchListener(this);
    }

    private void updateCounter() {
        this.counter.setText(Integer.toString(count));
    }

    // need to keep the view square
    @Override
    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
        super.onMeasure(widthMeasureSpec, widthMeasureSpec);
        center.set(getWidth()/2, getWidth()/2);
    }

    @Override
    public boolean onTouch(View v, MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                startTouch.set(event.getX(), event.getY());
                turning = true;
                return true; 
            }
            case MotionEvent.ACTION_MOVE: {
                if(turning) {
                    currentTouch.set(event.getX(), event.getY());
                    RotationOrientation turningDirection = getOrientation(center, startTouch, currentTouch);

                    if (lastRotatationDirection != turningDirection) {
                        double angle = getRotationAngle(center, startTouch, currentTouch);
                        Log.d ("Angle", Double.toString(angle));
                        // the touch event has switched its orientation 
                        // and the current touch point is close to the start point
                        // a full cycle has been made
                        if (switched && angle < 10) {
                            if (turningDirection == RotationOrientation.CCW) {
                                count--;
                                updateCounter();
                                switched = false;
                            }
                            else if (turningDirection == RotationOrientation.CW) {
                                count++;
                                updateCounter();
                                switched = false;
                            }
                        }
                        // checking if the angle is big enough is needed to prevent
                        // the user from switching from the start point only
                        else if (!switched && angle > 170) {
                            switched = true;
                        }
                    }

                    lastRotatationDirection = turningDirection;
                    return true;
                }
            }

            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP: {
                turning  = false;
                return true;
            }
        }

        return false;
    }


    // checks the orientation of three points on a plane
    private RotationOrientation getOrientation(PointF a, PointF b, PointF c){
        double face = a.x * b.y + b.x * c.y + c.x * a.y - (c.x * b.y + b.x * a.y + a.x * c.y);
        if (face > 0)
             return RotationOrientation.CW;
        else if (face < 0)
             return RotationOrientation.CCW;
        else return RotationOrientation.LINEAR;
    }

    // using dot product to calculate the angle between the vectors ab and ac
    public double getRotationAngle(PointF a, PointF b, PointF c){
        double len1 = dist (a, b);
        double len2 = dist (a, c);
        double product = (b.x - a.x) * (c.x - a.x) + (b.y - a.y) * (c.y - a.y);

        return Math.toDegrees(Math.acos(product / (len1 * len2)));
    }

    // calculates the distance between two points on a plane
    public double dist (PointF a, PointF b) {
        return Math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y));
    }
}
Jejunum answered 18/3, 2014 at 14:40 Comment(0)
Q
0

I had a friend that needed to implement something similar like what you want.

He actually used gesture detection - GestureOverlayView and MotionEvent.

By creating his custom gestures he managed to implement this.

My friend mostly referenced this site. There is a sample code there too.

Hope you find this useful!

Quadroon answered 12/3, 2014 at 12:35 Comment(0)
G
0

you need to use Circular SeekBar you can find sample and librery from here

some another also may usefull here , this.

thanx i hope it helps.

Geraldina answered 13/3, 2014 at 8:40 Comment(0)
P
-1

Use a NumberPicker, which is simpler and then customize it!

NumberPicker

Pedanticism answered 19/3, 2014 at 12:12 Comment(3)
this is also less user friendlyTrimeter
If this is the case, you can customize it described like here custom number pickerPedanticism
gestures are important part of ergonomicsTrimeter

© 2022 - 2024 — McMap. All rights reserved.