package view.joystick; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.util.AttributeSet; import android.util.Log; import android.view.HapticFeedbackConstants; import android.view.MotionEvent; import android.view.View; public class JoystickView extends View { public static final int INVALID_POINTER_ID = -1; // ========================================= // Private Members // ========================================= private final boolean D = false; String TAG = "JoystickView"; private Paint dbgPaint1; private Paint dbgPaint2; private Paint bgPaint; private Paint handlePaint; private Paint bgPaint1; private Paint handlePaint1; private int innerPadding; private int bgRadius; private int handleRadius; private int movementRadius; private int handleInnerBoundaries; private JoystickMovedListener moveListener; private JoystickClickedListener clickListener; // # of pixels movement required between reporting to the listener private float moveResolution; private boolean yAxisInverted; private boolean autoReturnToCenter; private boolean autoReturnToMid; // Max range of movement in user coordinate system public final static int CONSTRAIN_BOX = 0; public final static int CONSTRAIN_CIRCLE = 1; private int movementConstraint; private float movementRange; private float maxMovementRange; private float minMovementRange; public final static int COORDINATE_CARTESIAN = 0; // Regular cartesian // coordinates public final static int COORDINATE_DIFFERENTIAL = 1; // Uses polar rotation // of 45 degrees to // calc differential // drive paramaters private int userCoordinateSystem; // Records touch pressure for click handling private float touchPressure; private boolean clicked; private float clickThreshold; // Last touch point in view coordinates private int pointerId = INVALID_POINTER_ID; public void setTouchY(float touchY) { this.touchY = touchY; } private float touchX, touchY; // Last reported position in view coordinates (allows different reporting // sensitivities) private float reportX, reportY; // Handle center in view coordinates private float handleX, handleY; // Center of the view in view coordinates private int cX, cY; // Size of the view in view coordinates @SuppressWarnings("unused") private int dimX, dimY; // Cartesian coordinates of last touch point - joystick center is (0,0) private int cartX, cartY; // Polar coordinates of the touch point from joystick center private double radial; private double angle; // User coordinates of last touch point private int userX, userY; // Offset co-ordinates (used when touch events are received from parent's // coordinate origin) private int offsetX; private int offsetY; private boolean isInit = true; // ========================================= // Constructors // ========================================= public JoystickView(Context context) { super(context); initJoystickView(); } public JoystickView(Context context, AttributeSet attrs) { super(context, attrs); initJoystickView(); } public JoystickView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); initJoystickView(); } // ========================================= // Initialization // ========================================= private void initJoystickView() { setFocusable(true); dbgPaint1 = new Paint(Paint.ANTI_ALIAS_FLAG); dbgPaint1.setColor(Color.RED); dbgPaint1.setAlpha(60); dbgPaint1.setStrokeWidth(1); dbgPaint1.setStyle(Paint.Style.STROKE); dbgPaint2 = new Paint(Paint.ANTI_ALIAS_FLAG); dbgPaint2.setColor(Color.GRAY); dbgPaint2.setStrokeWidth(1); dbgPaint2.setStyle(Paint.Style.STROKE); bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG); bgPaint.setColor(Color.WHITE); bgPaint.setAlpha(140); bgPaint.setStrokeWidth(3); bgPaint.setStyle(Paint.Style.STROKE); bgPaint1 = new Paint(Paint.ANTI_ALIAS_FLAG); bgPaint1.setColor(Color.GRAY); bgPaint1.setAlpha(80); bgPaint1.setStyle(Paint.Style.FILL); handlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); handlePaint.setColor(Color.WHITE); handlePaint.setStrokeWidth(2); bgPaint.setAlpha(140); handlePaint.setStyle(Paint.Style.STROKE); handlePaint1 = new Paint(Paint.ANTI_ALIAS_FLAG); handlePaint1.setColor(Color.GRAY); handlePaint1.setAlpha(180); handlePaint1.setStyle(Paint.Style.FILL); innerPadding = 10; setMovementRange(500); setMoveResolution(1.0f); setClickThreshold(0.4f); setYAxisInverted(true); setUserCoordinateSystem(COORDINATE_CARTESIAN); setAutoReturnToCenter(true); setAutoReturnToMid(false); } public void setAutoReturnToMid(boolean b) { this.autoReturnToMid = b; } public boolean getAutoReturnToMid(){ return this.autoReturnToMid; } public void setAutoReturnToCenter(boolean autoReturnToCenter) { this.autoReturnToCenter = autoReturnToCenter; } public boolean isAutoReturnToCenter() { return autoReturnToCenter; } public void setUserCoordinateSystem(int userCoordinateSystem) { if (userCoordinateSystem < COORDINATE_CARTESIAN || movementConstraint > COORDINATE_DIFFERENTIAL) Log.e(TAG, "invalid value for userCoordinateSystem"); else this.userCoordinateSystem = userCoordinateSystem; } public int getUserCoordinateSystem() { return userCoordinateSystem; } public void setMovementConstraint(int movementConstraint) { if (movementConstraint < CONSTRAIN_BOX || movementConstraint > CONSTRAIN_CIRCLE) Log.e(TAG, "invalid value for movementConstraint"); else this.movementConstraint = movementConstraint; } public int getMovementConstraint() { return movementConstraint; } public boolean isYAxisInverted() { return yAxisInverted; } public void setYAxisInverted(boolean yAxisInverted) { this.yAxisInverted = yAxisInverted; } /** * Set the pressure sensitivity for registering a click * * @param clickThreshold * threshold 0...1.0f inclusive. 0 will cause clicks to never be * reported, 1.0 is a very hard click */ public void setClickThreshold(float clickThreshold) { if (clickThreshold < 0 || clickThreshold > 1.0f) Log.e(TAG, "clickThreshold must range from 0...1.0f inclusive"); else this.clickThreshold = clickThreshold; } public float getClickThreshold() { return clickThreshold; } public void setMovementRange(float movementRange) { this.movementRange = movementRange; this.maxMovementRange = movementRange / 2; this.minMovementRange = -maxMovementRange; } public float getMovementRange() { return movementRange; } public void setMoveResolution(float moveResolution) { this.moveResolution = moveResolution; } public float getMoveResolution() { return moveResolution; } public float getMoveResolutionMax() { return this.maxMovementRange; } public float getMoveResolutionMin() { return this.minMovementRange; } // ========================================= // Public Methods // ========================================= public void setOnJostickMovedListener(JoystickMovedListener listener) { this.moveListener = listener; } public void setOnJostickClickedListener(JoystickClickedListener listener) { this.clickListener = listener; } // ========================================= // Drawing Functionality // ========================================= @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // Here we make sure that we have a perfect circle int measuredWidth = measure(widthMeasureSpec); int measuredHeight = measure(heightMeasureSpec); setMeasuredDimension(measuredWidth, measuredHeight); } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); int d = Math.min(getMeasuredWidth(), getMeasuredHeight()); dimX = d; dimY = d; cX = d / 2; cY = d / 2; bgRadius = dimX / 2 - innerPadding; handleRadius = (int) (d * 0.25); handleInnerBoundaries = handleRadius; movementRadius = Math.min(cX, cY) - handleInnerBoundaries; if(isInit == true && autoReturnToMid == true){ isInit = false; touchY = movementRadius; } } private int measure(int measureSpec) { int result = 0; // Decode the measurement specifications. int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); if (specMode == MeasureSpec.UNSPECIFIED) { // Return a default size of 200 if no bounds are specified. result = 200; } else { // As you want to fill the available space // always return the full available bounds. result = specSize; } return result; } @Override protected void onDraw(Canvas canvas) { canvas.save(); // Draw the background canvas.drawCircle(cX, cY, bgRadius, bgPaint); canvas.drawCircle(cX, cY, bgRadius, bgPaint1); // Draw the handle handleX = touchX + cX; handleY = touchY + cY; canvas.drawCircle(handleX, handleY, handleRadius, handlePaint); canvas.drawCircle(handleX, handleY, handleRadius, handlePaint1); if (D) { canvas.drawRect(1, 1, getMeasuredWidth() - 1, getMeasuredHeight() - 1, dbgPaint1); canvas.drawCircle(handleX, handleY, 3, dbgPaint1); if (movementConstraint == CONSTRAIN_CIRCLE) { canvas.drawCircle(cX, cY, this.movementRadius, dbgPaint1); } else { canvas.drawRect(cX - movementRadius, cY - movementRadius, cX + movementRadius, cY + movementRadius, dbgPaint1); } // Origin to touch point canvas.drawLine(cX, cY, handleX, handleY, dbgPaint2); int baseY = (int) (touchY < 0 ? cY + handleRadius : cY - handleRadius); canvas.drawText( String.format("%s (%.0f,%.0f)", TAG, touchX, touchY), handleX - 20, baseY - 7, dbgPaint2); canvas.drawText( "(" + String.format("%.0f, %.1f", radial, angle * 57.2957795) + (char) 0x00B0 + ")", handleX - 20, baseY + 15, dbgPaint2); } // Log.d(TAG, String.format("touch(%f,%f)", touchX, touchY)); // Log.d(TAG, String.format("onDraw(%.1f,%.1f)\n\n", handleX, handleY)); canvas.restore(); } // Constrain touch within a box private void constrainBox() { touchX = Math.max(Math.min(touchX, movementRadius), -movementRadius); touchY = Math.max(Math.min(touchY, movementRadius), -movementRadius); } // Constrain touch within a circle private void constrainCircle() { float diffX = touchX; float diffY = touchY; double radial = Math.sqrt((diffX * diffX) + (diffY * diffY)); if (radial > movementRadius) { touchX = (int) ((diffX / radial) * movementRadius); touchY = (int) ((diffY / radial) * movementRadius); } } public void setPointerId(int id) { this.pointerId = id; } public int getPointerId() { return pointerId; } @Override public boolean onTouchEvent(MotionEvent ev) { final int action = ev.getAction(); switch (action & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_MOVE: { return processMoveEvent(ev); } case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: { if (pointerId != INVALID_POINTER_ID) { // Log.d(TAG, "ACTION_UP"); returnHandleToCenter(); setPointerId(INVALID_POINTER_ID); } break; } case MotionEvent.ACTION_POINTER_UP: { if (pointerId != INVALID_POINTER_ID) { final int pointerIndex = (action & MotionEvent.ACTION_POINTER_INDEX_MASK) >> MotionEvent.ACTION_POINTER_INDEX_SHIFT; final int pointerId = ev.getPointerId(pointerIndex); if (pointerId == this.pointerId) { // Log.d(TAG, "ACTION_POINTER_UP: " + pointerId); returnHandleToCenter(); setPointerId(INVALID_POINTER_ID); return true; } } break; } case MotionEvent.ACTION_DOWN: { if (pointerId == INVALID_POINTER_ID) { int x = (int) ev.getX(); if (x >= offsetX && x < offsetX + dimX ) { setPointerId(ev.getPointerId(0)); return true; } } break; } case MotionEvent.ACTION_POINTER_DOWN: { // Log.d("JoyStickView", "ACTION_POINTER_MOVE"); if (pointerId == INVALID_POINTER_ID) { final int pointerIndex = (action & MotionEvent.ACTION_POINTER_INDEX_MASK) >> MotionEvent.ACTION_POINTER_INDEX_SHIFT; final int pointerId = ev.getPointerId(pointerIndex); int x = (int) ev.getX(pointerId); if (x >= offsetX && x < offsetX + dimX) { // Log.d(TAG, "ACTION_POINTER_DOWN: " + pointerId); setPointerId(pointerId); return true; } } break; } } return false; } private boolean processMoveEvent(MotionEvent ev) { if (pointerId != INVALID_POINTER_ID) { final int pointerIndex = ev.findPointerIndex(pointerId); // Translate touch position to center of view float x = ev.getX(pointerIndex); touchX = x - cX - offsetX; float y = ev.getY(pointerIndex); touchY = y - cY - offsetY; // Log.d(TAG, // String.format("ACTION_MOVE: (%03.0f, %03.0f) => (%03.0f, %03.0f)", // x, y, touchX, touchY)); reportOnMoved(); invalidate(); touchPressure = ev.getPressure(pointerIndex); reportOnPressure(); return true; } return false; } private void reportOnMoved() { if (movementConstraint == CONSTRAIN_CIRCLE) constrainCircle(); else constrainBox(); calcUserCoordinates(); if (moveListener != null) { boolean rx = Math.abs(touchX - reportX) >= moveResolution; boolean ry = Math.abs(touchY - reportY) >= moveResolution; if (rx || ry) { this.reportX = touchX; this.reportY = touchY; // Log.d(TAG, String.format("moveListener.OnMoved(%d,%d)", // (int)userX, (int)userY)); moveListener.OnMoved(userX, userY); } } } private void calcUserCoordinates() { // First convert to cartesian coordinates cartX = (int) (touchX / movementRadius * movementRange); cartY = (int) (touchY / movementRadius * movementRange); radial = Math.sqrt((cartX * cartX) + (cartY * cartY)); angle = Math.atan2(cartY, cartX); // Invert Y axis if requested if (!yAxisInverted) cartY *= -1; if (userCoordinateSystem == COORDINATE_CARTESIAN) { userX = cartX; userY = cartY; } else if (userCoordinateSystem == COORDINATE_DIFFERENTIAL) { userX = cartY + cartX / 4; userY = cartY - cartX / 4; if (userX < -movementRange) userX = (int) -movementRange; if (userX > movementRange) userX = (int) movementRange; if (userY < -movementRange) userY = (int) -movementRange; if (userY > movementRange) userY = (int) movementRange; } } // Simple pressure click private void reportOnPressure() { // Log.d(TAG, String.format("touchPressure=%.2f", this.touchPressure)); if (clickListener != null) { if (clicked && touchPressure < clickThreshold) { clickListener.OnReleased(); this.clicked = false; // Log.d(TAG, "reset click"); invalidate(); } else if (!clicked && touchPressure >= clickThreshold) { clicked = true; clickListener.OnClicked(); // Log.d(TAG, "click"); invalidate(); performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); } } } public void setCoordinates(float touchX, float touchY) { if (!this.isPressed()) { this.touchX = touchX; this.touchY = touchY; reportOnMoved(); invalidate(); } } public void returnHandleToMid(){ final int numberOfFrames = 5; final double intervalsX = (0 - touchX) / numberOfFrames; for (int i = 0; i < numberOfFrames; i++) { final int j = i; postDelayed(new Runnable() { @Override public void run() { touchX += intervalsX; reportOnMoved(); invalidate(); if (moveListener != null && j == numberOfFrames - 1) { moveListener.OnReturnedToCenter(); } } }, i * 40); } if (moveListener != null) { moveListener.OnReleased(); } } public void returnHandleToCenter() { if (autoReturnToCenter) { final int numberOfFrames = 5; final double intervalsX = (0 - touchX) / numberOfFrames; final double intervalsY = (0 - touchY) / numberOfFrames; for (int i = 0; i < numberOfFrames; i++) { final int j = i; postDelayed(new Runnable() { @Override public void run() { touchX += intervalsX; touchY += intervalsY; reportOnMoved(); invalidate(); if (moveListener != null && j == numberOfFrames - 1) { moveListener.OnReturnedToCenter(); } } }, i * 40); } if (moveListener != null) { moveListener.OnReleased(); } } else if(autoReturnToMid){ this.returnHandleToMid(); } } public void setTouchOffset(int x, int y) { offsetX = x; offsetY = y; } public float getTouchY() { return this.touchY; } }