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