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
17package androidx.wear.widget;
18
19import android.view.MotionEvent;
20import android.view.VelocityTracker;
21
22import androidx.annotation.RestrictTo;
23import androidx.annotation.RestrictTo.Scope;
24import androidx.recyclerview.widget.RecyclerView;
25
26/**
27 * Class adding circular scrolling support to {@link WearableRecyclerView}.
28 *
29 * @hide
30 */
31@RestrictTo(Scope.LIBRARY)
32class 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