| package android.widget; |
| |
| import com.android.internal.R; |
| |
| import android.content.Context; |
| import android.content.res.Resources; |
| import android.graphics.Canvas; |
| import android.graphics.drawable.Drawable; |
| import android.graphics.drawable.RotateDrawable; |
| import android.os.Handler; |
| import android.os.Message; |
| import android.os.SystemClock; |
| import android.util.AttributeSet; |
| import android.util.Log; |
| import android.view.HapticFeedbackConstants; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.ViewConfiguration; |
| |
| /** |
| * @hide |
| */ |
| public class ZoomRing extends View { |
| |
| // TODO: move to ViewConfiguration? |
| static final int DOUBLE_TAP_DISMISS_TIMEOUT = ViewConfiguration.getDoubleTapTimeout(); |
| // TODO: get from theme |
| private static final String TAG = "ZoomRing"; |
| |
| // TODO: Temporary until the trail is done |
| private static final boolean DRAW_TRAIL = false; |
| |
| // TODO: xml |
| private static final int THUMB_DISTANCE = 63; |
| |
| /** To avoid floating point calculations, we multiply radians by this value. */ |
| public static final int RADIAN_INT_MULTIPLIER = 10000; |
| public static final int RADIAN_INT_ERROR = 100; |
| /** PI using our multiplier. */ |
| public static final int PI_INT_MULTIPLIED = (int) (Math.PI * RADIAN_INT_MULTIPLIER); |
| public static final int TWO_PI_INT_MULTIPLIED = PI_INT_MULTIPLIED * 2; |
| /** PI/2 using our multiplier. */ |
| private static final int HALF_PI_INT_MULTIPLIED = PI_INT_MULTIPLIED / 2; |
| |
| private int mZeroAngle = HALF_PI_INT_MULTIPLIED * 3; |
| |
| private static final int THUMB_GRAB_SLOP = PI_INT_MULTIPLIED / 8; |
| private static final int THUMB_DRAG_SLOP = PI_INT_MULTIPLIED / 12; |
| |
| /** |
| * Includes error because we compare this to the result of |
| * getDelta(getClosestTickeAngle(..), oldAngle) which ends up having some |
| * rounding error. |
| */ |
| private static final int MAX_ABS_JUMP_DELTA_ANGLE = (2 * PI_INT_MULTIPLIED / 3) + |
| RADIAN_INT_ERROR; |
| |
| /** The cached X of our center. */ |
| private int mCenterX; |
| /** The cached Y of our center. */ |
| private int mCenterY; |
| |
| /** The angle of the thumb (in int radians) */ |
| private int mThumbAngle; |
| private int mThumbHalfWidth; |
| private int mThumbHalfHeight; |
| |
| private int mThumbCwBound = Integer.MIN_VALUE; |
| private int mThumbCcwBound = Integer.MIN_VALUE; |
| private boolean mEnforceMaxAbsJump = true; |
| |
| /** The inner radius of the track. */ |
| private int mBoundInnerRadiusSquared = 0; |
| /** The outer radius of the track. */ |
| private int mBoundOuterRadiusSquared = Integer.MAX_VALUE; |
| |
| private int mPreviousWidgetDragX; |
| private int mPreviousWidgetDragY; |
| |
| private boolean mDrawThumb = true; |
| private Drawable mThumbDrawable; |
| |
| /** Shown beneath the thumb if we can still zoom in. */ |
| private Drawable mThumbPlusArrowDrawable; |
| /** Shown beneath the thumb if we can still zoom out. */ |
| private Drawable mThumbMinusArrowDrawable; |
| private static final int THUMB_ARROW_PLUS = 1 << 0; |
| private static final int THUMB_ARROW_MINUS = 1 << 1; |
| /** Bitwise-OR of {@link #THUMB_ARROW_MINUS} and {@link #THUMB_ARROW_PLUS} */ |
| private int mThumbArrowsToDraw; |
| private static final int THUMB_ARROWS_FADE_DURATION = 300; |
| private long mThumbArrowsFadeStartTime; |
| private int mThumbArrowsAlpha = 255; |
| |
| private static final int MODE_IDLE = 0; |
| |
| /** |
| * User has his finger down somewhere on the ring (besides the thumb) and we |
| * are waiting for him to move the slop amount before considering him in the |
| * drag thumb state. |
| */ |
| private static final int MODE_WAITING_FOR_DRAG_THUMB = 5; |
| private static final int MODE_DRAG_THUMB = 1; |
| /** |
| * User has his finger down, but we are waiting for him to pass the touch |
| * slop before going into the #MODE_MOVE_ZOOM_RING. This is a good time to |
| * show the movable hint. |
| */ |
| private static final int MODE_WAITING_FOR_MOVE_ZOOM_RING = 4; |
| private static final int MODE_MOVE_ZOOM_RING = 2; |
| private static final int MODE_TAP_DRAG = 3; |
| /** Ignore the touch interaction. Reset to MODE_IDLE after up/cancel. */ |
| private static final int MODE_IGNORE_UNTIL_UP = 6; |
| private int mMode; |
| |
| private long mPreviousUpTime; |
| private int mPreviousDownX; |
| private int mPreviousDownY; |
| |
| private int mWaitingForDragThumbDownAngle; |
| |
| private OnZoomRingCallback mCallback; |
| private int mPreviousCallbackAngle; |
| private int mCallbackThreshold = Integer.MAX_VALUE; |
| |
| private boolean mResetThumbAutomatically = true; |
| private int mThumbDragStartAngle; |
| |
| private final int mTouchSlop; |
| |
| private Drawable mTrail; |
| private double mAcculumalatedTrailAngle; |
| |
| private Scroller mThumbScroller; |
| |
| private static final int MSG_THUMB_SCROLLER_TICK = 1; |
| private static final int MSG_THUMB_ARROWS_FADE_TICK = 2; |
| private Handler mHandler = new Handler() { |
| @Override |
| public void handleMessage(Message msg) { |
| switch (msg.what) { |
| case MSG_THUMB_SCROLLER_TICK: |
| onThumbScrollerTick(); |
| break; |
| |
| case MSG_THUMB_ARROWS_FADE_TICK: |
| onThumbArrowsFadeTick(); |
| break; |
| } |
| } |
| }; |
| |
| public ZoomRing(Context context, AttributeSet attrs, int defStyle) { |
| super(context, attrs, defStyle); |
| |
| ViewConfiguration viewConfiguration = ViewConfiguration.get(context); |
| mTouchSlop = viewConfiguration.getScaledTouchSlop(); |
| |
| // TODO get drawables from style instead |
| Resources res = context.getResources(); |
| mThumbDrawable = res.getDrawable(R.drawable.zoom_ring_thumb); |
| mThumbPlusArrowDrawable = res.getDrawable(R.drawable.zoom_ring_thumb_plus_arrow_rotatable). |
| mutate(); |
| mThumbMinusArrowDrawable = res.getDrawable(R.drawable.zoom_ring_thumb_minus_arrow_rotatable). |
| mutate(); |
| if (DRAW_TRAIL) { |
| mTrail = res.getDrawable(R.drawable.zoom_ring_trail).mutate(); |
| } |
| |
| // TODO: add padding to drawable |
| setBackgroundResource(R.drawable.zoom_ring_track); |
| // TODO get from style |
| setRingBounds(43, Integer.MAX_VALUE); |
| |
| mThumbHalfHeight = mThumbDrawable.getIntrinsicHeight() / 2; |
| mThumbHalfWidth = mThumbDrawable.getIntrinsicWidth() / 2; |
| |
| mCallbackThreshold = PI_INT_MULTIPLIED / 6; |
| } |
| |
| public ZoomRing(Context context, AttributeSet attrs) { |
| this(context, attrs, 0); |
| } |
| |
| public ZoomRing(Context context) { |
| this(context, null); |
| } |
| |
| public void setCallback(OnZoomRingCallback callback) { |
| mCallback = callback; |
| } |
| |
| // TODO: rename |
| public void setCallbackThreshold(int callbackThreshold) { |
| mCallbackThreshold = callbackThreshold; |
| } |
| |
| // TODO: from XML too |
| public void setRingBounds(int innerRadius, int outerRadius) { |
| mBoundInnerRadiusSquared = innerRadius * innerRadius; |
| if (mBoundInnerRadiusSquared < innerRadius) { |
| // Prevent overflow |
| mBoundInnerRadiusSquared = Integer.MAX_VALUE; |
| } |
| |
| mBoundOuterRadiusSquared = outerRadius * outerRadius; |
| if (mBoundOuterRadiusSquared < outerRadius) { |
| // Prevent overflow |
| mBoundOuterRadiusSquared = Integer.MAX_VALUE; |
| } |
| } |
| |
| public void setThumbClockwiseBound(int angle) { |
| if (angle < 0) { |
| mThumbCwBound = Integer.MIN_VALUE; |
| } else { |
| mThumbCwBound = getClosestTickAngle(angle); |
| } |
| setEnforceMaxAbsJump(); |
| } |
| |
| public void setThumbCounterclockwiseBound(int angle) { |
| if (angle < 0) { |
| mThumbCcwBound = Integer.MIN_VALUE; |
| } else { |
| mThumbCcwBound = getClosestTickAngle(angle); |
| } |
| setEnforceMaxAbsJump(); |
| } |
| |
| private void setEnforceMaxAbsJump() { |
| // If there are bounds in both direction, there is no reason to restrict |
| // the amount that a user can absolute jump to |
| mEnforceMaxAbsJump = |
| mThumbCcwBound == Integer.MIN_VALUE || mThumbCwBound == Integer.MIN_VALUE; |
| } |
| |
| public int getThumbAngle() { |
| return mThumbAngle; |
| } |
| |
| public void setThumbAngle(int angle) { |
| angle = getValidAngle(angle); |
| mPreviousCallbackAngle = getClosestTickAngle(angle); |
| setThumbAngleAuto(angle, false, false); |
| } |
| |
| /** |
| * Sets the thumb angle. If already animating, will continue the animation, |
| * otherwise it will do a direct jump. |
| * |
| * @param angle |
| * @param useDirection Whether to use the ccw parameter |
| * @param ccw Whether going counterclockwise (only used if useDirection is true) |
| */ |
| private void setThumbAngleAuto(int angle, boolean useDirection, boolean ccw) { |
| if (mThumbScroller == null |
| || mThumbScroller.isFinished() |
| || Math.abs(getDelta(angle, getThumbScrollerAngle())) < THUMB_GRAB_SLOP) { |
| setThumbAngleInt(angle); |
| } else { |
| if (useDirection) { |
| setThumbAngleAnimated(angle, 0, ccw); |
| } else { |
| setThumbAngleAnimated(angle, 0); |
| } |
| } |
| } |
| |
| private void setThumbAngleInt(int angle) { |
| mThumbAngle = angle; |
| int unoffsetAngle = angle + mZeroAngle; |
| int thumbCenterX = (int) (Math.cos(1f * unoffsetAngle / RADIAN_INT_MULTIPLIER) * |
| THUMB_DISTANCE) + mCenterX; |
| int thumbCenterY = (int) (Math.sin(1f * unoffsetAngle / RADIAN_INT_MULTIPLIER) * |
| THUMB_DISTANCE) * -1 + mCenterY; |
| |
| mThumbDrawable.setBounds(thumbCenterX - mThumbHalfWidth, |
| thumbCenterY - mThumbHalfHeight, |
| thumbCenterX + mThumbHalfWidth, |
| thumbCenterY + mThumbHalfHeight); |
| |
| if (mThumbArrowsToDraw > 0) { |
| setThumbArrowsAngle(angle); |
| } |
| |
| if (DRAW_TRAIL) { |
| double degrees; |
| degrees = Math.min(359.0, Math.abs(mAcculumalatedTrailAngle)); |
| int level = (int) (10000.0 * degrees / 360.0); |
| |
| mTrail.setLevel((int) (10000.0 * |
| (-Math.toDegrees(angle / (double) RADIAN_INT_MULTIPLIER) - |
| degrees + 90) / 360.0)); |
| ((RotateDrawable) mTrail).getDrawable().setLevel(level); |
| } |
| |
| invalidate(); |
| } |
| |
| /** |
| * |
| * @param angle |
| * @param duration The animation duration, or 0 for the default duration. |
| */ |
| public void setThumbAngleAnimated(int angle, int duration) { |
| // The angle when going from the current angle to the new angle |
| int deltaAngle = getDelta(mThumbAngle, angle); |
| // Counter clockwise if the new angle is more the current angle |
| boolean counterClockwise = deltaAngle > 0; |
| |
| if (deltaAngle > PI_INT_MULTIPLIED || deltaAngle < -PI_INT_MULTIPLIED) { |
| // It's quicker to go the other direction |
| counterClockwise = !counterClockwise; |
| } |
| |
| setThumbAngleAnimated(angle, duration, counterClockwise); |
| } |
| |
| public void setThumbAngleAnimated(int angle, int duration, boolean counterClockwise) { |
| if (mThumbScroller == null) { |
| mThumbScroller = new Scroller(mContext); |
| } |
| |
| int startAngle = mThumbAngle; |
| int endAngle = getValidAngle(angle); |
| int deltaAngle = getDelta(startAngle, endAngle, counterClockwise); |
| if (startAngle + deltaAngle < 0) { |
| // Keep our angles positive |
| startAngle += TWO_PI_INT_MULTIPLIED; |
| } |
| |
| if (!mThumbScroller.isFinished()) { |
| duration = mThumbScroller.getDuration() - mThumbScroller.timePassed(); |
| } else if (duration == 0) { |
| duration = getAnimationDuration(deltaAngle); |
| } |
| mThumbScroller.startScroll(startAngle, 0, deltaAngle, 0, duration); |
| onThumbScrollerTick(); |
| } |
| |
| private int getAnimationDuration(int deltaAngle) { |
| if (deltaAngle < 0) deltaAngle *= -1; |
| return 300 + deltaAngle * 300 / RADIAN_INT_MULTIPLIER; |
| } |
| |
| private void onThumbScrollerTick() { |
| if (!mThumbScroller.computeScrollOffset()) return; |
| setThumbAngleInt(getThumbScrollerAngle()); |
| mHandler.sendEmptyMessage(MSG_THUMB_SCROLLER_TICK); |
| } |
| |
| private int getThumbScrollerAngle() { |
| return mThumbScroller.getCurrX() % TWO_PI_INT_MULTIPLIED; |
| } |
| |
| public void resetThumbAngle(int angle) { |
| mPreviousCallbackAngle = angle; |
| setThumbAngleInt(angle); |
| } |
| |
| public void resetThumbAngle() { |
| if (mResetThumbAutomatically) { |
| resetThumbAngle(0); |
| } |
| } |
| |
| public void setResetThumbAutomatically(boolean resetThumbAutomatically) { |
| mResetThumbAutomatically = resetThumbAutomatically; |
| } |
| |
| @Override |
| protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
| setMeasuredDimension(resolveSize(getSuggestedMinimumWidth(), widthMeasureSpec), |
| resolveSize(getSuggestedMinimumHeight(), heightMeasureSpec)); |
| } |
| |
| @Override |
| protected void onLayout(boolean changed, int left, int top, int right, |
| int bottom) { |
| super.onLayout(changed, left, top, right, bottom); |
| |
| // Cache the center point |
| mCenterX = (right - left) / 2; |
| mCenterY = (bottom - top) / 2; |
| |
| // Done here since we now have center, which is needed to calculate some |
| // aux info for thumb angle |
| if (mThumbAngle == Integer.MIN_VALUE) { |
| resetThumbAngle(); |
| } |
| |
| if (DRAW_TRAIL) { |
| mTrail.setBounds(0, 0, right - left, bottom - top); |
| } |
| |
| mThumbPlusArrowDrawable.setBounds(0, 0, right - left, bottom - top); |
| mThumbMinusArrowDrawable.setBounds(0, 0, right - left, bottom - top); |
| } |
| |
| @Override |
| public boolean onTouchEvent(MotionEvent event) { |
| return handleTouch(event.getAction(), event.getEventTime(), |
| (int) event.getX(), (int) event.getY(), (int) event.getRawX(), |
| (int) event.getRawY()); |
| } |
| |
| private void resetState() { |
| mMode = MODE_IDLE; |
| mPreviousWidgetDragX = mPreviousWidgetDragY = Integer.MIN_VALUE; |
| mAcculumalatedTrailAngle = 0.0; |
| } |
| |
| public void setTapDragMode(boolean tapDragMode, int x, int y) { |
| resetState(); |
| mMode = tapDragMode ? MODE_TAP_DRAG : MODE_IDLE; |
| |
| if (tapDragMode) { |
| onThumbDragStarted(getAngle(x - mCenterX, y - mCenterY)); |
| } |
| } |
| |
| public boolean handleTouch(int action, long time, int x, int y, int rawX, int rawY) { |
| switch (action) { |
| |
| case MotionEvent.ACTION_DOWN: |
| if (time - mPreviousUpTime <= DOUBLE_TAP_DISMISS_TIMEOUT) { |
| mCallback.onZoomRingDismissed(true); |
| onTouchUp(time); |
| |
| // Dismissing, so halt here |
| return true; |
| } |
| |
| mCallback.onUserInteractionStarted(); |
| mPreviousDownX = x; |
| mPreviousDownY = y; |
| resetState(); |
| // Fall through to code below switch (since the down is used for |
| // jumping to the touched tick) |
| break; |
| |
| case MotionEvent.ACTION_MOVE: |
| if (mMode == MODE_IGNORE_UNTIL_UP) return true; |
| |
| // Fall through to code below switch |
| break; |
| |
| case MotionEvent.ACTION_CANCEL: |
| case MotionEvent.ACTION_UP: |
| onTouchUp(time); |
| return true; |
| |
| default: |
| return false; |
| } |
| |
| // local{X,Y} will be where the center of the widget is (0,0) |
| int localX = x - mCenterX; |
| int localY = y - mCenterY; |
| boolean isTouchingThumb = true; |
| boolean isInRingBounds = true; |
| |
| int touchAngle = getAngle(localX, localY); |
| int radiusSquared = localX * localX + localY * localY; |
| if (radiusSquared < mBoundInnerRadiusSquared || |
| radiusSquared > mBoundOuterRadiusSquared) { |
| // Out-of-bounds |
| isTouchingThumb = false; |
| isInRingBounds = false; |
| } |
| |
| int deltaThumbAndTouch = getDelta(mThumbAngle, touchAngle); |
| int absoluteDeltaThumbAndTouch = deltaThumbAndTouch >= 0 ? |
| deltaThumbAndTouch : -deltaThumbAndTouch; |
| if (isTouchingThumb && |
| absoluteDeltaThumbAndTouch > THUMB_GRAB_SLOP) { |
| // Didn't grab close enough to the thumb |
| isTouchingThumb = false; |
| } |
| |
| if (mMode == MODE_IDLE) { |
| if (isTouchingThumb) { |
| // They grabbed the thumb |
| mMode = MODE_DRAG_THUMB; |
| onThumbDragStarted(touchAngle); |
| |
| } else if (isInRingBounds) { |
| // They tapped somewhere else on the ring |
| int tickAngle = getClosestTickAngle(touchAngle); |
| |
| int deltaThumbAndTick = getDelta(mThumbAngle, tickAngle); |
| int boundAngle = getBoundIfExceeds(mThumbAngle, deltaThumbAndTick); |
| |
| if (mEnforceMaxAbsJump) { |
| // Enforcing the max jump |
| if (deltaThumbAndTick > MAX_ABS_JUMP_DELTA_ANGLE || |
| deltaThumbAndTick < -MAX_ABS_JUMP_DELTA_ANGLE) { |
| // Trying to jump too far, ignore this touch interaction |
| mMode = MODE_IGNORE_UNTIL_UP; |
| return true; |
| } |
| |
| // Make sure we only let them jump within bounds |
| if (boundAngle != Integer.MIN_VALUE) { |
| tickAngle = boundAngle; |
| } |
| } else { |
| // Not enforcing the max jump, but we have to make sure |
| // we're getting to the tapped angle by going through the |
| // in-bounds region |
| if (boundAngle != Integer.MIN_VALUE) { |
| // Going this direction hits a bound, let's go the opposite direction |
| boolean oldDirectionIsCcw = deltaThumbAndTick > 0; |
| deltaThumbAndTick = getDelta(mThumbAngle, tickAngle, !oldDirectionIsCcw); |
| boundAngle = getBoundIfExceeds(mThumbAngle, deltaThumbAndTick); |
| if (boundAngle != Integer.MIN_VALUE) { |
| // Not allowed to be here, it is between two bounds |
| mMode = MODE_IGNORE_UNTIL_UP; |
| return true; |
| } |
| } |
| } |
| |
| mMode = MODE_WAITING_FOR_DRAG_THUMB; |
| mWaitingForDragThumbDownAngle = touchAngle; |
| boolean ccw = deltaThumbAndTick > 0; |
| setThumbAngleAnimated(tickAngle, 0, ccw); |
| |
| // Our thumb scrolling animation takes us from mThumbAngle to tickAngle |
| onThumbDragStarted(mThumbAngle); |
| onThumbDragged(tickAngle, true, ccw); |
| |
| } else { |
| // They tapped somewhere else |
| mMode = MODE_WAITING_FOR_MOVE_ZOOM_RING; |
| mCallback.onZoomRingSetMovableHintVisible(true); |
| } |
| |
| } else if (mMode == MODE_WAITING_FOR_DRAG_THUMB) { |
| int deltaDownAngle = getDelta(mWaitingForDragThumbDownAngle, touchAngle); |
| if ((deltaDownAngle < -THUMB_DRAG_SLOP || deltaDownAngle > THUMB_DRAG_SLOP) && |
| isDeltaInBounds(mWaitingForDragThumbDownAngle, deltaDownAngle)) { |
| mMode = MODE_DRAG_THUMB; |
| } |
| |
| } else if (mMode == MODE_WAITING_FOR_MOVE_ZOOM_RING) { |
| if (Math.abs(x - mPreviousDownX) > mTouchSlop || |
| Math.abs(y - mPreviousDownY) > mTouchSlop) { |
| /* Make sure the user has moved the slop amount before going into that mode. */ |
| mMode = MODE_MOVE_ZOOM_RING; |
| mCallback.onZoomRingMovingStarted(); |
| } |
| } |
| |
| // Purposefully not an "else if" |
| if (mMode == MODE_DRAG_THUMB || mMode == MODE_TAP_DRAG) { |
| if (isInRingBounds) { |
| onThumbDragged(touchAngle, false, false); |
| } |
| } else if (mMode == MODE_MOVE_ZOOM_RING) { |
| onZoomRingMoved(rawX, rawY); |
| } |
| |
| return true; |
| } |
| |
| private void onTouchUp(long time) { |
| if (mMode == MODE_MOVE_ZOOM_RING || mMode == MODE_WAITING_FOR_MOVE_ZOOM_RING) { |
| mCallback.onZoomRingSetMovableHintVisible(false); |
| if (mMode == MODE_MOVE_ZOOM_RING) { |
| mCallback.onZoomRingMovingStopped(); |
| } |
| } else if (mMode == MODE_DRAG_THUMB || mMode == MODE_TAP_DRAG || |
| mMode == MODE_WAITING_FOR_DRAG_THUMB) { |
| onThumbDragStopped(); |
| |
| if (mMode == MODE_DRAG_THUMB) { |
| // Animate back to a tick |
| setThumbAngleAnimated(mPreviousCallbackAngle, 0); |
| } |
| } |
| |
| mPreviousUpTime = time; |
| mCallback.onUserInteractionStopped(); |
| } |
| |
| private boolean isDeltaInBounds(int startAngle, int deltaAngle) { |
| return getBoundIfExceeds(startAngle, deltaAngle) == Integer.MIN_VALUE; |
| } |
| |
| private int getBoundIfExceeds(int startAngle, int deltaAngle) { |
| if (deltaAngle > 0) { |
| // Counterclockwise movement |
| if (mThumbCcwBound != Integer.MIN_VALUE && |
| getDelta(startAngle, mThumbCcwBound, true) < deltaAngle) { |
| return mThumbCcwBound; |
| } |
| } else if (deltaAngle < 0) { |
| // Clockwise movement, both of these will be negative |
| int deltaThumbAndBound = getDelta(startAngle, mThumbCwBound, false); |
| if (mThumbCwBound != Integer.MIN_VALUE && |
| deltaThumbAndBound > deltaAngle) { |
| // Tapped outside of the bound in that direction |
| return mThumbCwBound; |
| } |
| } |
| |
| return Integer.MIN_VALUE; |
| } |
| |
| private int getDelta(int startAngle, int endAngle, boolean useDirection, boolean ccw) { |
| return useDirection ? getDelta(startAngle, endAngle, ccw) : getDelta(startAngle, endAngle); |
| } |
| |
| /** |
| * Gets the smallest delta between two angles, and infers the direction |
| * based on the shortest path between the two angles. If going from |
| * startAngle to endAngle is counterclockwise, the result will be positive. |
| * If it is clockwise, the result will be negative. |
| * |
| * @param startAngle The start angle. |
| * @param endAngle The end angle. |
| * @return The difference in angles. |
| */ |
| private int getDelta(int startAngle, int endAngle) { |
| int largerAngle, smallerAngle; |
| if (endAngle > startAngle) { |
| largerAngle = endAngle; |
| smallerAngle = startAngle; |
| } else { |
| largerAngle = startAngle; |
| smallerAngle = endAngle; |
| } |
| |
| int delta = largerAngle - smallerAngle; |
| if (delta <= PI_INT_MULTIPLIED) { |
| // If going clockwise, negate the delta |
| return startAngle == largerAngle ? -delta : delta; |
| } else { |
| // The other direction is the delta we want (it includes the |
| // discontinuous 0-2PI angle) |
| delta = TWO_PI_INT_MULTIPLIED - delta; |
| // If going clockwise, negate the delta |
| return startAngle == smallerAngle ? -delta : delta; |
| } |
| } |
| |
| /** |
| * Gets the delta between two angles in the direction specified. |
| * |
| * @param startAngle The start angle. |
| * @param endAngle The end angle. |
| * @param counterClockwise The direction to take when computing the delta. |
| * @return The difference in angles in the given direction. |
| */ |
| private int getDelta(int startAngle, int endAngle, boolean counterClockwise) { |
| int delta = endAngle - startAngle; |
| |
| if (!counterClockwise && delta > 0) { |
| // Crossed the discontinuous 0/2PI angle, take the leftover slice of |
| // the pie and negate it |
| return -TWO_PI_INT_MULTIPLIED + delta; |
| } else if (counterClockwise && delta < 0) { |
| // Crossed the discontinuous 0/2PI angle, take the leftover slice of |
| // the pie (and ensure it is positive) |
| return TWO_PI_INT_MULTIPLIED + delta; |
| } else { |
| return delta; |
| } |
| } |
| |
| private void onThumbDragStarted(int startAngle) { |
| setThumbArrowsVisible(false); |
| mThumbDragStartAngle = startAngle; |
| mCallback.onZoomRingThumbDraggingStarted(); |
| } |
| |
| private void onThumbDragged(int touchAngle, boolean useDirection, boolean ccw) { |
| boolean animateThumbToNewAngle = false; |
| |
| int totalDeltaAngle; |
| totalDeltaAngle = getDelta(mPreviousCallbackAngle, touchAngle, useDirection, ccw); |
| int fuzzyCallbackThreshold = (int) (mCallbackThreshold * 0.65f); |
| if (totalDeltaAngle >= fuzzyCallbackThreshold |
| || totalDeltaAngle <= -fuzzyCallbackThreshold) { |
| |
| if (!useDirection) { |
| // Set ccw to match the direction found by getDelta |
| ccw = totalDeltaAngle > 0; |
| } |
| |
| /* |
| * When the user slides the thumb through the tick that corresponds |
| * to a zoom bound, we don't want to abruptly stop there. Instead, |
| * let the user slide it to the next tick, and then animate it back |
| * to the original zoom bound tick. Because of this, we make sure |
| * the delta from the bound is more than halfway to the next tick. |
| * We make sure the bound is between the touch and the previous |
| * callback to ensure we just passed the bound. |
| */ |
| int oldTouchAngle = touchAngle; |
| if (ccw && mThumbCcwBound != Integer.MIN_VALUE) { |
| int deltaCcwBoundAndTouch = |
| getDelta(mThumbCcwBound, touchAngle, useDirection, true); |
| if (deltaCcwBoundAndTouch >= mCallbackThreshold / 2) { |
| // The touch has past a bound |
| int deltaPreviousCbAndTouch = getDelta(mPreviousCallbackAngle, |
| touchAngle, useDirection, true); |
| if (deltaPreviousCbAndTouch >= deltaCcwBoundAndTouch) { |
| // The bound is between the previous callback angle and the touch |
| touchAngle = mThumbCcwBound; |
| // We're moving the touch BACK to the bound, so opposite direction |
| ccw = false; |
| } |
| } |
| } else if (!ccw && mThumbCwBound != Integer.MIN_VALUE) { |
| // See block above for general comments |
| int deltaCwBoundAndTouch = |
| getDelta(mThumbCwBound, touchAngle, useDirection, false); |
| if (deltaCwBoundAndTouch <= -mCallbackThreshold / 2) { |
| int deltaPreviousCbAndTouch = getDelta(mPreviousCallbackAngle, |
| touchAngle, useDirection, false); |
| /* |
| * Both of these will be negative since we got delta in |
| * clockwise direction, and we want the magnitude of |
| * deltaPreviousCbAndTouch to be greater than the magnitude |
| * of deltaCwBoundAndTouch |
| */ |
| if (deltaPreviousCbAndTouch <= deltaCwBoundAndTouch) { |
| touchAngle = mThumbCwBound; |
| ccw = true; |
| } |
| } |
| } |
| if (touchAngle != oldTouchAngle) { |
| // We bounded the touch angle |
| totalDeltaAngle = getDelta(mPreviousCallbackAngle, touchAngle, useDirection, ccw); |
| animateThumbToNewAngle = true; |
| mMode = MODE_IGNORE_UNTIL_UP; |
| } |
| |
| |
| // Prevent it from jumping too far |
| if (mEnforceMaxAbsJump) { |
| if (totalDeltaAngle <= -MAX_ABS_JUMP_DELTA_ANGLE) { |
| totalDeltaAngle = -MAX_ABS_JUMP_DELTA_ANGLE; |
| animateThumbToNewAngle = true; |
| } else if (totalDeltaAngle >= MAX_ABS_JUMP_DELTA_ANGLE) { |
| totalDeltaAngle = MAX_ABS_JUMP_DELTA_ANGLE; |
| animateThumbToNewAngle = true; |
| } |
| } |
| |
| /* |
| * We need to cover the edge case of a user grabbing the thumb, |
| * going into the center of the widget, and then coming out from the |
| * center to an angle that's slightly below the angle he's trying to |
| * hit. If we do int division, we'll end up with one level lower |
| * than the one he was going for. |
| */ |
| int deltaLevels = Math.round((float) totalDeltaAngle / mCallbackThreshold); |
| if (deltaLevels != 0) { |
| boolean canStillZoom = mCallback.onZoomRingThumbDragged( |
| deltaLevels, mThumbDragStartAngle, touchAngle); |
| |
| // TODO: we're trying the haptics to see how it goes with |
| // users, so we're ignoring the settings (for now) |
| performHapticFeedback(HapticFeedbackConstants.ZOOM_RING_TICK, |
| HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING | |
| HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING); |
| |
| // Set the callback angle to the actual angle based on how many delta levels we gave |
| mPreviousCallbackAngle = getValidAngle( |
| mPreviousCallbackAngle + (deltaLevels * mCallbackThreshold)); |
| } |
| } |
| |
| if (DRAW_TRAIL) { |
| int deltaAngle = getDelta(mThumbAngle, touchAngle, useDirection, ccw); |
| mAcculumalatedTrailAngle += Math.toDegrees(deltaAngle / (double) RADIAN_INT_MULTIPLIER); |
| } |
| |
| if (animateThumbToNewAngle) { |
| if (useDirection) { |
| setThumbAngleAnimated(touchAngle, 0, ccw); |
| } else { |
| setThumbAngleAnimated(touchAngle, 0); |
| } |
| } else { |
| setThumbAngleAuto(touchAngle, useDirection, ccw); |
| } |
| } |
| |
| private int getValidAngle(int invalidAngle) { |
| if (invalidAngle < 0) { |
| return (invalidAngle % TWO_PI_INT_MULTIPLIED) + TWO_PI_INT_MULTIPLIED; |
| } else if (invalidAngle >= TWO_PI_INT_MULTIPLIED) { |
| return invalidAngle % TWO_PI_INT_MULTIPLIED; |
| } else { |
| return invalidAngle; |
| } |
| } |
| |
| private int getClosestTickAngle(int angle) { |
| int smallerAngleDistance = angle % mCallbackThreshold; |
| int smallerAngle = angle - smallerAngleDistance; |
| if (smallerAngleDistance < mCallbackThreshold / 2) { |
| // Closer to the smaller angle |
| return smallerAngle; |
| } else { |
| // Closer to the bigger angle (premodding) |
| return (smallerAngle + mCallbackThreshold) % TWO_PI_INT_MULTIPLIED; |
| } |
| } |
| |
| private void onThumbDragStopped() { |
| mCallback.onZoomRingThumbDraggingStopped(); |
| } |
| |
| private void onZoomRingMoved(int x, int y) { |
| if (mPreviousWidgetDragX != Integer.MIN_VALUE) { |
| int deltaX = x - mPreviousWidgetDragX; |
| int deltaY = y - mPreviousWidgetDragY; |
| |
| mCallback.onZoomRingMoved(deltaX, deltaY); |
| } |
| |
| mPreviousWidgetDragX = x; |
| mPreviousWidgetDragY = y; |
| } |
| |
| @Override |
| public void onWindowFocusChanged(boolean hasWindowFocus) { |
| super.onWindowFocusChanged(hasWindowFocus); |
| |
| if (!hasWindowFocus) { |
| mCallback.onZoomRingDismissed(true); |
| } |
| } |
| |
| private int getAngle(int localX, int localY) { |
| int radians = (int) (Math.atan2(localY, localX) * RADIAN_INT_MULTIPLIER); |
| |
| // Convert from [-pi,pi] to {0,2pi] |
| if (radians < 0) { |
| radians = -radians; |
| } else if (radians > 0) { |
| radians = 2 * PI_INT_MULTIPLIED - radians; |
| } else { |
| radians = 0; |
| } |
| |
| radians = radians - mZeroAngle; |
| return radians >= 0 ? radians : radians + 2 * PI_INT_MULTIPLIED; |
| } |
| |
| @Override |
| protected void onDraw(Canvas canvas) { |
| super.onDraw(canvas); |
| |
| if (mDrawThumb) { |
| if (DRAW_TRAIL) { |
| mTrail.draw(canvas); |
| } |
| if ((mThumbArrowsToDraw & THUMB_ARROW_PLUS) != 0) { |
| mThumbPlusArrowDrawable.draw(canvas); |
| } |
| if ((mThumbArrowsToDraw & THUMB_ARROW_MINUS) != 0) { |
| mThumbMinusArrowDrawable.draw(canvas); |
| } |
| mThumbDrawable.draw(canvas); |
| } |
| } |
| |
| private void setThumbArrowsAngle(int angle) { |
| int level = -angle * 10000 / ZoomRing.TWO_PI_INT_MULTIPLIED; |
| mThumbPlusArrowDrawable.setLevel(level); |
| mThumbMinusArrowDrawable.setLevel(level); |
| } |
| |
| public void setThumbArrowsVisible(boolean visible) { |
| if (visible) { |
| mThumbArrowsAlpha = 255; |
| int callbackAngle = mPreviousCallbackAngle; |
| if (callbackAngle < mThumbCwBound - RADIAN_INT_ERROR || |
| callbackAngle > mThumbCwBound + RADIAN_INT_ERROR) { |
| mThumbPlusArrowDrawable.setAlpha(255); |
| mThumbArrowsToDraw |= THUMB_ARROW_PLUS; |
| } else { |
| mThumbArrowsToDraw &= ~THUMB_ARROW_PLUS; |
| } |
| if (callbackAngle < mThumbCcwBound - RADIAN_INT_ERROR || |
| callbackAngle > mThumbCcwBound + RADIAN_INT_ERROR) { |
| mThumbMinusArrowDrawable.setAlpha(255); |
| mThumbArrowsToDraw |= THUMB_ARROW_MINUS; |
| } else { |
| mThumbArrowsToDraw &= ~THUMB_ARROW_MINUS; |
| } |
| invalidate(); |
| } else if (mThumbArrowsAlpha == 255) { |
| // Only start fade if we're fully visible (otherwise another fade is happening already) |
| mThumbArrowsFadeStartTime = SystemClock.elapsedRealtime(); |
| onThumbArrowsFadeTick(); |
| } |
| } |
| |
| private void onThumbArrowsFadeTick() { |
| if (mThumbArrowsAlpha <= 0) { |
| mThumbArrowsToDraw = 0; |
| return; |
| } |
| |
| mThumbArrowsAlpha = (int) |
| (255 - (255 * (SystemClock.elapsedRealtime() - mThumbArrowsFadeStartTime) |
| / THUMB_ARROWS_FADE_DURATION)); |
| if (mThumbArrowsAlpha < 0) mThumbArrowsAlpha = 0; |
| if ((mThumbArrowsToDraw & THUMB_ARROW_PLUS) != 0) { |
| mThumbPlusArrowDrawable.setAlpha(mThumbArrowsAlpha); |
| invalidateDrawable(mThumbPlusArrowDrawable); |
| } |
| if ((mThumbArrowsToDraw & THUMB_ARROW_MINUS) != 0) { |
| mThumbMinusArrowDrawable.setAlpha(mThumbArrowsAlpha); |
| invalidateDrawable(mThumbMinusArrowDrawable); |
| } |
| |
| if (!mHandler.hasMessages(MSG_THUMB_ARROWS_FADE_TICK)) { |
| mHandler.sendEmptyMessage(MSG_THUMB_ARROWS_FADE_TICK); |
| } |
| } |
| |
| @Override |
| protected void onAttachedToWindow() { |
| super.onAttachedToWindow(); |
| |
| setThumbArrowsAngle(mThumbAngle); |
| setThumbArrowsVisible(true); |
| } |
| |
| public interface OnZoomRingCallback { |
| void onZoomRingSetMovableHintVisible(boolean visible); |
| |
| void onZoomRingMovingStarted(); |
| boolean onZoomRingMoved(int deltaX, int deltaY); |
| void onZoomRingMovingStopped(); |
| |
| void onZoomRingThumbDraggingStarted(); |
| boolean onZoomRingThumbDragged(int numLevels, int startAngle, int curAngle); |
| void onZoomRingThumbDraggingStopped(); |
| |
| void onZoomRingDismissed(boolean dismissImmediately); |
| |
| void onUserInteractionStarted(); |
| void onUserInteractionStopped(); |
| } |
| |
| private static void printAngle(String angleName, int angle) { |
| Log.d(TAG, angleName + ": " + (long) angle * 180 / PI_INT_MULTIPLIED); |
| } |
| } |