/* * Copyright (C) 2016 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package androidx.wear.widget; import android.view.MotionEvent; import android.view.VelocityTracker; import androidx.annotation.RestrictTo; import androidx.annotation.RestrictTo.Scope; import androidx.recyclerview.widget.RecyclerView; /** * Class adding circular scrolling support to {@link WearableRecyclerView}. * * @hide */ @RestrictTo(Scope.LIBRARY) class ScrollManager { // One second in milliseconds. private static final int ONE_SEC_IN_MS = 1000; private static final float VELOCITY_MULTIPLIER = 1.5f; private static final float FLING_EDGE_RATIO = 1.5f; /** * Taps beyond this radius fraction are considered close enough to the bezel to be candidates * for circular scrolling. */ private float mMinRadiusFraction = 0.0f; private float mMinRadiusFractionSquared = mMinRadiusFraction * mMinRadiusFraction; /** How many degrees you have to drag along the bezel to scroll one screen height. */ private float mScrollDegreesPerScreen = 180; private float mScrollRadiansPerScreen = (float) Math.toRadians(mScrollDegreesPerScreen); /** Radius of screen in pixels, ignoring insets, if any. */ private float mScreenRadiusPx; private float mScreenRadiusPxSquared; /** How many pixels to scroll for each radian of bezel scrolling. */ private float mScrollPixelsPerRadian; /** Whether an {@link MotionEvent#ACTION_DOWN} was received near the bezel. */ private boolean mDown; /** * Whether the user tapped near the bezel and dragged approximately tangentially to initiate * bezel scrolling. */ private boolean mScrolling; /** * The angle of the user's finger relative to the center of the screen for the last {@link * MotionEvent} during bezel scrolling. */ private float mLastAngleRadians; private RecyclerView mRecyclerView; VelocityTracker mVelocityTracker; /** Should be called after the window is attached to the view. */ void setRecyclerView(RecyclerView recyclerView, int width, int height) { mRecyclerView = recyclerView; mScreenRadiusPx = Math.max(width, height) / 2f; mScreenRadiusPxSquared = mScreenRadiusPx * mScreenRadiusPx; mScrollPixelsPerRadian = height / mScrollRadiansPerScreen; mVelocityTracker = VelocityTracker.obtain(); } /** Remove the binding with a {@link RecyclerView} */ void clearRecyclerView() { mRecyclerView = null; } /** * Method dealing with touch events intercepted from the attached {@link RecyclerView}. * * @param event the intercepted touch event. * @return true if the even was handled, false otherwise. */ boolean onTouchEvent(MotionEvent event) { float deltaX = event.getRawX() - mScreenRadiusPx; float deltaY = event.getRawY() - mScreenRadiusPx; float radiusSquared = deltaX * deltaX + deltaY * deltaY; final MotionEvent vtev = MotionEvent.obtain(event); mVelocityTracker.addMovement(vtev); vtev.recycle(); switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: if (radiusSquared / mScreenRadiusPxSquared > mMinRadiusFractionSquared) { mDown = true; return true; // Consume the event. } break; case MotionEvent.ACTION_MOVE: if (mScrolling) { float angleRadians = (float) Math.atan2(deltaY, deltaX); float deltaRadians = angleRadians - mLastAngleRadians; deltaRadians = normalizeAngleRadians(deltaRadians); int scrollPixels = Math.round(deltaRadians * mScrollPixelsPerRadian); if (scrollPixels != 0) { mRecyclerView.scrollBy(0 /* x */, scrollPixels /* y */); // Recompute deltaRadians in terms of rounded scrollPixels. deltaRadians = scrollPixels / mScrollPixelsPerRadian; mLastAngleRadians += deltaRadians; mLastAngleRadians = normalizeAngleRadians(mLastAngleRadians); } // Always consume the event so that we never break the circular scrolling // gesture. return true; } if (mDown) { float deltaXFromCenter = event.getRawX() - mScreenRadiusPx; float deltaYFromCenter = event.getRawY() - mScreenRadiusPx; float distFromCenter = (float) Math.hypot(deltaXFromCenter, deltaYFromCenter); if (distFromCenter != 0) { deltaXFromCenter /= distFromCenter; deltaYFromCenter /= distFromCenter; mScrolling = true; mRecyclerView.invalidate(); mLastAngleRadians = (float) Math.atan2(deltaYFromCenter, deltaXFromCenter); return true; // Consume the event. } } else { // Double check we're not missing an event we should really be handling. if (radiusSquared / mScreenRadiusPxSquared > mMinRadiusFractionSquared) { mDown = true; return true; // Consume the event. } } break; case MotionEvent.ACTION_UP: mDown = false; mScrolling = false; mVelocityTracker.computeCurrentVelocity(ONE_SEC_IN_MS, mRecyclerView.getMaxFlingVelocity()); int velocityY = (int) mVelocityTracker.getYVelocity(); if (event.getX() < FLING_EDGE_RATIO * mScreenRadiusPx) { velocityY = -velocityY; } mVelocityTracker.clear(); if (Math.abs(velocityY) > mRecyclerView.getMinFlingVelocity()) { return mRecyclerView.fling(0, (int) (VELOCITY_MULTIPLIER * velocityY)); } break; case MotionEvent.ACTION_CANCEL: if (mDown) { mDown = false; mScrolling = false; mRecyclerView.invalidate(); return true; // Consume the event. } break; } return false; } /** * Normalizes an angle to be in the range [-pi, pi] by adding or subtracting 2*pi if necessary. * * @param angleRadians an angle in radians. Must be no more than 2*pi out of normal range. * @return an angle in radians in the range [-pi, pi] */ private static float normalizeAngleRadians(float angleRadians) { if (angleRadians < -Math.PI) { angleRadians = (float) (angleRadians + Math.PI * 2); } if (angleRadians > Math.PI) { angleRadians = (float) (angleRadians - Math.PI * 2); } return angleRadians; } /** * Set how many degrees you have to drag along the bezel to scroll one screen height. * * @param degreesPerScreen desired degrees per screen scroll. */ public void setScrollDegreesPerScreen(float degreesPerScreen) { mScrollDegreesPerScreen = degreesPerScreen; mScrollRadiansPerScreen = (float) Math.toRadians(mScrollDegreesPerScreen); } /** * Sets the width of a virtual 'bezel' close to the edge of the screen within which taps can be * recognized as belonging to a rotary scrolling gesture. * * @param fraction desired fraction of the width of the screen to be treated as a valid rotary * scrolling target. */ public void setBezelWidth(float fraction) { mMinRadiusFraction = 1 - fraction; mMinRadiusFractionSquared = mMinRadiusFraction * mMinRadiusFraction; } /** * Returns how many degrees you have to drag along the bezel to scroll one screen height. See * {@link #setScrollDegreesPerScreen(float)} for details. */ public float getScrollDegreesPerScreen() { return mScrollDegreesPerScreen; } /** * Returns the current bezel width for circular scrolling. See {@link #setBezelWidth(float)} * for details. */ public float getBezelWidth() { return 1 - mMinRadiusFraction; } }