Home | History | Annotate | Download | only in widget
      1 /*
      2  * Copyright (C) 2016 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package androidx.wear.widget;
     18 
     19 import android.view.MotionEvent;
     20 import android.view.VelocityTracker;
     21 
     22 import androidx.annotation.RestrictTo;
     23 import androidx.annotation.RestrictTo.Scope;
     24 import androidx.recyclerview.widget.RecyclerView;
     25 
     26 /**
     27  * Class adding circular scrolling support to {@link WearableRecyclerView}.
     28  *
     29  * @hide
     30  */
     31 @RestrictTo(Scope.LIBRARY)
     32 class ScrollManager {
     33     // One second in milliseconds.
     34     private static final int ONE_SEC_IN_MS = 1000;
     35     private static final float VELOCITY_MULTIPLIER = 1.5f;
     36     private static final float FLING_EDGE_RATIO = 1.5f;
     37 
     38     /**
     39      * Taps beyond this radius fraction are considered close enough to the bezel to be candidates
     40      * for circular scrolling.
     41      */
     42     private float mMinRadiusFraction = 0.0f;
     43 
     44     private float mMinRadiusFractionSquared = mMinRadiusFraction * mMinRadiusFraction;
     45 
     46     /** How many degrees you have to drag along the bezel to scroll one screen height. */
     47     private float mScrollDegreesPerScreen = 180;
     48 
     49     private float mScrollRadiansPerScreen = (float) Math.toRadians(mScrollDegreesPerScreen);
     50 
     51     /** Radius of screen in pixels, ignoring insets, if any. */
     52     private float mScreenRadiusPx;
     53 
     54     private float mScreenRadiusPxSquared;
     55 
     56     /** How many pixels to scroll for each radian of bezel scrolling. */
     57     private float mScrollPixelsPerRadian;
     58 
     59     /** Whether an {@link MotionEvent#ACTION_DOWN} was received near the bezel. */
     60     private boolean mDown;
     61 
     62     /**
     63      * Whether the user tapped near the bezel and dragged approximately tangentially to initiate
     64      * bezel scrolling.
     65      */
     66     private boolean mScrolling;
     67     /**
     68      * The angle of the user's finger relative to the center of the screen for the last {@link
     69      * MotionEvent} during bezel scrolling.
     70      */
     71     private float mLastAngleRadians;
     72 
     73     private RecyclerView mRecyclerView;
     74     VelocityTracker mVelocityTracker;
     75 
     76     /** Should be called after the window is attached to the view. */
     77     void setRecyclerView(RecyclerView recyclerView, int width, int height) {
     78         mRecyclerView = recyclerView;
     79         mScreenRadiusPx = Math.max(width, height) / 2f;
     80         mScreenRadiusPxSquared = mScreenRadiusPx * mScreenRadiusPx;
     81         mScrollPixelsPerRadian = height / mScrollRadiansPerScreen;
     82         mVelocityTracker = VelocityTracker.obtain();
     83     }
     84 
     85     /** Remove the binding with a {@link RecyclerView} */
     86     void clearRecyclerView() {
     87         mRecyclerView = null;
     88     }
     89 
     90     /**
     91      * Method dealing with touch events intercepted from the attached {@link RecyclerView}.
     92      *
     93      * @param event the intercepted touch event.
     94      * @return true if the even was handled, false otherwise.
     95      */
     96     boolean onTouchEvent(MotionEvent event) {
     97         float deltaX = event.getRawX() - mScreenRadiusPx;
     98         float deltaY = event.getRawY() - mScreenRadiusPx;
     99         float radiusSquared = deltaX * deltaX + deltaY * deltaY;
    100         final MotionEvent vtev = MotionEvent.obtain(event);
    101         mVelocityTracker.addMovement(vtev);
    102         vtev.recycle();
    103 
    104         switch (event.getActionMasked()) {
    105             case MotionEvent.ACTION_DOWN:
    106                 if (radiusSquared / mScreenRadiusPxSquared > mMinRadiusFractionSquared) {
    107                     mDown = true;
    108                     return true; // Consume the event.
    109                 }
    110                 break;
    111 
    112             case MotionEvent.ACTION_MOVE:
    113                 if (mScrolling) {
    114                     float angleRadians = (float) Math.atan2(deltaY, deltaX);
    115                     float deltaRadians = angleRadians - mLastAngleRadians;
    116                     deltaRadians = normalizeAngleRadians(deltaRadians);
    117                     int scrollPixels = Math.round(deltaRadians * mScrollPixelsPerRadian);
    118                     if (scrollPixels != 0) {
    119                         mRecyclerView.scrollBy(0 /* x */, scrollPixels /* y */);
    120                         // Recompute deltaRadians in terms of rounded scrollPixels.
    121                         deltaRadians = scrollPixels / mScrollPixelsPerRadian;
    122                         mLastAngleRadians += deltaRadians;
    123                         mLastAngleRadians = normalizeAngleRadians(mLastAngleRadians);
    124                     }
    125                     // Always consume the event so that we never break the circular scrolling
    126                     // gesture.
    127                     return true;
    128                 }
    129 
    130                 if (mDown) {
    131                     float deltaXFromCenter = event.getRawX() - mScreenRadiusPx;
    132                     float deltaYFromCenter = event.getRawY() - mScreenRadiusPx;
    133                     float distFromCenter = (float) Math.hypot(deltaXFromCenter, deltaYFromCenter);
    134                     if (distFromCenter != 0) {
    135                         deltaXFromCenter /= distFromCenter;
    136                         deltaYFromCenter /= distFromCenter;
    137 
    138                         mScrolling = true;
    139                         mRecyclerView.invalidate();
    140                         mLastAngleRadians = (float) Math.atan2(deltaYFromCenter, deltaXFromCenter);
    141                         return true; // Consume the event.
    142                     }
    143                 } else {
    144                     // Double check we're not missing an event we should really be handling.
    145                     if (radiusSquared / mScreenRadiusPxSquared > mMinRadiusFractionSquared) {
    146                         mDown = true;
    147                         return true; // Consume the event.
    148                     }
    149                 }
    150                 break;
    151 
    152             case MotionEvent.ACTION_UP:
    153                 mDown = false;
    154                 mScrolling = false;
    155                 mVelocityTracker.computeCurrentVelocity(ONE_SEC_IN_MS,
    156                         mRecyclerView.getMaxFlingVelocity());
    157                 int velocityY = (int) mVelocityTracker.getYVelocity();
    158                 if (event.getX() < FLING_EDGE_RATIO * mScreenRadiusPx) {
    159                     velocityY = -velocityY;
    160                 }
    161                 mVelocityTracker.clear();
    162                 if (Math.abs(velocityY) > mRecyclerView.getMinFlingVelocity()) {
    163                     return mRecyclerView.fling(0, (int) (VELOCITY_MULTIPLIER * velocityY));
    164                 }
    165                 break;
    166 
    167             case MotionEvent.ACTION_CANCEL:
    168                 if (mDown) {
    169                     mDown = false;
    170                     mScrolling = false;
    171                     mRecyclerView.invalidate();
    172                     return true; // Consume the event.
    173                 }
    174                 break;
    175         }
    176 
    177         return false;
    178     }
    179 
    180     /**
    181      * Normalizes an angle to be in the range [-pi, pi] by adding or subtracting 2*pi if necessary.
    182      *
    183      * @param angleRadians an angle in radians. Must be no more than 2*pi out of normal range.
    184      * @return an angle in radians in the range [-pi, pi]
    185      */
    186     private static float normalizeAngleRadians(float angleRadians) {
    187         if (angleRadians < -Math.PI) {
    188             angleRadians = (float) (angleRadians + Math.PI * 2);
    189         }
    190         if (angleRadians > Math.PI) {
    191             angleRadians = (float) (angleRadians - Math.PI * 2);
    192         }
    193         return angleRadians;
    194     }
    195 
    196     /**
    197      * Set how many degrees you have to drag along the bezel to scroll one screen height.
    198      *
    199      * @param degreesPerScreen desired degrees per screen scroll.
    200      */
    201     public void setScrollDegreesPerScreen(float degreesPerScreen) {
    202         mScrollDegreesPerScreen = degreesPerScreen;
    203         mScrollRadiansPerScreen = (float) Math.toRadians(mScrollDegreesPerScreen);
    204     }
    205 
    206     /**
    207      * Sets the width of a virtual 'bezel' close to the edge of the screen within which taps can be
    208      * recognized as belonging to a rotary scrolling gesture.
    209      *
    210      * @param fraction desired fraction of the width of the screen to be treated as a valid rotary
    211      *                 scrolling target.
    212      */
    213     public void setBezelWidth(float fraction) {
    214         mMinRadiusFraction = 1 - fraction;
    215         mMinRadiusFractionSquared = mMinRadiusFraction * mMinRadiusFraction;
    216     }
    217 
    218     /**
    219      * Returns how many degrees you have to drag along the bezel to scroll one screen height. See
    220      * {@link #setScrollDegreesPerScreen(float)} for details.
    221      */
    222     public float getScrollDegreesPerScreen() {
    223         return mScrollDegreesPerScreen;
    224     }
    225 
    226     /**
    227      * Returns the current bezel width for circular scrolling. See {@link #setBezelWidth(float)}
    228      * for details.
    229      */
    230     public float getBezelWidth() {
    231         return 1 - mMinRadiusFraction;
    232     }
    233 }
    234