package com.jmedeisis.bugstick; import android.annotation.TargetApi; import android.content.Context; import android.content.res.TypedArray; import android.os.Build; import android.support.annotation.NonNull; import android.util.AttributeSet; import android.util.Log; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.animation.DecelerateInterpolator; import android.view.animation.Interpolator; import android.widget.FrameLayout; /** * A simple and flexible joystick. * Extends FrameLayout and should host one direct child to act as the draggable stick. * Use {@link #setJoystickListener(JoystickListener)} to observe user inputs. */ public class Joystick extends FrameLayout { private static final String LOG_TAG = Joystick.class.getSimpleName(); private static final int STICK_SETTLE_DURATION_MS = 100; private static final Interpolator STICK_SETTLE_INTERPOLATOR = new DecelerateInterpolator(); private int touchSlop; private float centerX, centerY; private float radius; private View draggedChild; private boolean detectingDrag; private boolean dragInProgress; private float downX, downY; private static final int INVALID_POINTER_ID = -1; private int activePointerId = INVALID_POINTER_ID; private boolean locked; private boolean startOnFirstTouch = true; private boolean forceSquare = true; private boolean hasFixedRadius = false; public enum MotionConstraint { NONE, HORIZONTAL, VERTICAL } private MotionConstraint motionConstraint = MotionConstraint.NONE; private JoystickListener listener; public Joystick(Context context) { super(context); init(context, null); } public Joystick(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs); } public Joystick(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs); } @SuppressWarnings("unused") @TargetApi(Build.VERSION_CODES.LOLLIPOP) public Joystick(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); init(context, attrs); } private void init(Context context, AttributeSet attrs) { final ViewConfiguration configuration = ViewConfiguration.get(context); touchSlop = configuration.getScaledTouchSlop(); if (null != attrs) { TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Joystick); startOnFirstTouch = a.getBoolean(R.styleable.Joystick_start_on_first_touch, startOnFirstTouch); forceSquare = a.getBoolean(R.styleable.Joystick_force_square, forceSquare); hasFixedRadius = a.hasValue(R.styleable.Joystick_radius); if (hasFixedRadius) { radius = a.getDimensionPixelOffset(R.styleable.Joystick_radius, (int) radius); } motionConstraint = MotionConstraint.values()[a.getInt(R.styleable.Joystick_motion_constraint, motionConstraint.ordinal())]; a.recycle(); } } @Override public boolean shouldDelayChildPressedState() { return true; } @Override public void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); centerX = (float) w / 2; centerY = (float) h / 2; } @Override public void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); if (changed && !hasFixedRadius) { recalculateRadius(right - left, bottom - top); } } private void recalculateRadius(int width, int height) { float stickHalfWidth = 0; float stickHalfHeight = 0; if (hasStick()) { final View stick = getChildAt(0); stickHalfWidth = (float) stick.getWidth() / 2; stickHalfHeight = (float) stick.getHeight() / 2; } switch (motionConstraint) { case NONE: radius = (float) Math.min(width, height) / 2 - Math.max(stickHalfWidth, stickHalfHeight); break; case HORIZONTAL: radius = (float) width / 2 - stickHalfWidth; break; case VERTICAL: radius = (float) height / 2 - stickHalfHeight; break; } } public void setJoystickListener(JoystickListener listener) { this.listener = listener; if (!hasStick()) { Log.w(LOG_TAG, LOG_TAG + " has no draggable stick, and is therefore not functional. " + "Consider adding a child view to act as the stick."); } } /** * Locks the stick position when next the user releases it. * Note that {@link JoystickListener#onUp()} will not be called upon release. * Resets to unlocked state after subsequent touch. */ @SuppressWarnings("unused") public void lock() { locked = true; } /** * @return Distance in pixels a touch can wander before the joystick thinks the user is * manipulating the stick. */ @SuppressWarnings("unused") public int getTouchSlop() { return touchSlop; } /** * @param touchSlop Distance in pixels a touch can wander before the joystick thinks the user is * manipulating the stick. */ @SuppressWarnings("unused") public void setTouchSlop(int touchSlop) { this.touchSlop = touchSlop; } @SuppressWarnings("unused") public MotionConstraint getMotionConstraint() { return motionConstraint; } @SuppressWarnings("unused") public void setMotionConstraint(MotionConstraint motionConstraint) { this.motionConstraint = motionConstraint; if (!hasFixedRadius) recalculateRadius(getWidth(), getHeight()); } @SuppressWarnings("unused") public float getRadius() { return radius; } /** * @param radius The maximum offset in pixels from the center that the stick is allowed to move. */ @SuppressWarnings("unused") public void setRadius(float radius) { this.radius = radius; } @SuppressWarnings("unused") public boolean isStartOnFirstTouch() { return startOnFirstTouch; } /** * @param startOnFirstTouch If true, the stick activates immediately on the initial touch. * Else, the user must begin to drag their finger across the joystick * for the stick to activate. */ @SuppressWarnings("unused") public void setStartOnFirstTouch(boolean startOnFirstTouch) { this.startOnFirstTouch = startOnFirstTouch; } /* TOUCH EVENT HANDLING */ @Override public boolean onInterceptTouchEvent(MotionEvent event) { if (!isEnabled()) return false; switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: { if (detectingDrag || !hasStick()) return false; downX = event.getX(0); downY = event.getY(0); activePointerId = event.getPointerId(0); onStartDetectingDrag(); break; } case MotionEvent.ACTION_MOVE: { if (INVALID_POINTER_ID == activePointerId) break; if (detectingDrag && dragExceedsSlop(event)) { onDragStart(); return true; } break; } case MotionEvent.ACTION_POINTER_UP: { final int pointerIndex = event.getActionIndex(); final int pointerId = event.getPointerId(pointerIndex); if (pointerId != activePointerId) break; // if active pointer, fall through and cancel! } case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: { onTouchEnded(); onStopDetectingDrag(); break; } } return false; } @Override public boolean onTouchEvent(@NonNull MotionEvent event) { if (!isEnabled()) return false; switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: { if (!detectingDrag) return false; if (startOnFirstTouch) onDragStart(); return true; } case MotionEvent.ACTION_MOVE: { if (INVALID_POINTER_ID == activePointerId) break; if (dragInProgress) { int pointerIndex = event.findPointerIndex(activePointerId); float latestX = event.getX(pointerIndex); float latestY = event.getY(pointerIndex); float deltaX = latestX - downX; float deltaY = latestY - downY; onDrag(deltaX, deltaY); return true; } else if (detectingDrag && dragExceedsSlop(event)) { onDragStart(); return true; } break; } case MotionEvent.ACTION_POINTER_UP: { final int pointerIndex = event.getActionIndex(); final int pointerId = event.getPointerId(pointerIndex); if (pointerId != activePointerId) break; // if active pointer, fall through and cancel! } case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: { onTouchEnded(); if (dragInProgress) { onDragStop(); } else { onStopDetectingDrag(); } return true; } } return false; } private boolean dragExceedsSlop(MotionEvent event) { final int pointerIndex = event.findPointerIndex(activePointerId); final float x = event.getX(pointerIndex); final float y = event.getY(pointerIndex); final float dx = Math.abs(x - downX); final float dy = Math.abs(y - downY); switch (motionConstraint) { case NONE: return dx * dx + dy * dy > touchSlop * touchSlop; case HORIZONTAL: return dx > touchSlop; case VERTICAL: return dy > touchSlop; } return false; } private void onTouchEnded() { activePointerId = INVALID_POINTER_ID; } private boolean hasStick() { return getChildCount() > 0; } private void onStartDetectingDrag() { detectingDrag = true; if (null != listener) listener.onDown(); } private void onStopDetectingDrag() { detectingDrag = false; if (!locked && null != listener) listener.onUp(); locked = false; } private void onDragStart() { dragInProgress = true; draggedChild = getChildAt(0); draggedChild.animate().cancel(); onDrag(0, 0); } private void onDragStop() { dragInProgress = false; if (!locked) { draggedChild.animate() .translationX(0).translationY(0) .setDuration(STICK_SETTLE_DURATION_MS) .setInterpolator(STICK_SETTLE_INTERPOLATOR) .start(); } onStopDetectingDrag(); draggedChild = null; } /** * Where most of the magic happens. What, basic trigonometry isn't magic?! */ private void onDrag(float dx, float dy) { float x = downX + dx - centerX; float y = downY + dy - centerY; switch (motionConstraint) { case HORIZONTAL: y = 0; break; case VERTICAL: x = 0; break; } float offset = (float) Math.sqrt(x * x + y * y); if (x * x + y * y > radius * radius) { x = radius * x / offset; y = radius * y / offset; offset = radius; } final double radians = Math.atan2(-y, x); final float degrees = (float) (180 * radians / Math.PI); if (null != listener) listener.onDrag(degrees, 0 == radius ? 0 : offset / radius); draggedChild.setTranslationX(x); draggedChild.setTranslationY(y); } /* FORCE SQUARE */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (!forceSquare) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); return; } int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); int size; if (widthMode == MeasureSpec.EXACTLY && widthSize > 0) { size = widthSize; } else if (heightMode == MeasureSpec.EXACTLY && heightSize > 0) { size = heightSize; } else { size = widthSize < heightSize ? widthSize : heightSize; } int finalMeasureSpec = MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY); super.onMeasure(finalMeasureSpec, finalMeasureSpec); } /* CENTER CHILD BY DEFAULT */ @Override public LayoutParams generateLayoutParams(AttributeSet attrs) { LayoutParams params = new LayoutParams(getContext(), attrs); params.gravity = Gravity.CENTER; return params; } @Override protected ViewGroup.LayoutParams generateLayoutParams(@NonNull ViewGroup.LayoutParams p) { LayoutParams params = new LayoutParams(p); params.gravity = Gravity.CENTER; return params; } @Override public LayoutParams generateDefaultLayoutParams() { return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER); } @Override protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { return p instanceof LayoutParams; } /* ENSURE MAX ONE DIRECT CHILD */ @Override public void addView(@NonNull View child, int index, ViewGroup.LayoutParams params) { if (getChildCount() > 0) { throw new IllegalStateException(LOG_TAG + " can host only one direct child"); } super.addView(child, index, params); } }