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.content.Context;
20 import android.content.res.TypedArray;
21 import android.graphics.Point;
22 import android.util.AttributeSet;
23 import android.view.MotionEvent;
24 import android.view.View;
25 import android.view.ViewTreeObserver;
26 
27 import androidx.core.view.ViewCompat;
28 import androidx.recyclerview.widget.RecyclerView;
29 import androidx.wear.R;
30 
31 import org.jspecify.annotations.Nullable;
32 
33 /**
34  * Wearable specific implementation of the {@link RecyclerView} enabling {@link
35  * #setCircularScrollingGestureEnabled(boolean)} circular scrolling} and semi-circular layouts.
36  *
37  * @see #setCircularScrollingGestureEnabled(boolean)
38  */
39 public class WearableRecyclerView extends RecyclerView {
40     private static final String TAG = "WearableRecyclerView";
41 
42     private static final int NO_VALUE = Integer.MIN_VALUE;
43 
44     private final ScrollManager mScrollManager = new ScrollManager();
45     private boolean mCircularScrollingEnabled;
46     private boolean mEdgeItemsCenteringEnabled;
47     boolean mCenterEdgeItemsWhenThereAreChildren;
48 
49     private int mOriginalPaddingTop = NO_VALUE;
50     private int mOriginalPaddingBottom = NO_VALUE;
51 
52     /** Pre-draw listener which is used to adjust the padding on this view before its first draw. */
53     private final ViewTreeObserver.OnPreDrawListener mPaddingPreDrawListener =
54             new ViewTreeObserver.OnPreDrawListener() {
55                 @Override
56                 public boolean onPreDraw() {
57                     if (mCenterEdgeItemsWhenThereAreChildren && getChildCount() > 0) {
58                         setupCenteredPadding();
59                         mCenterEdgeItemsWhenThereAreChildren = false;
60                     }
61                     return true;
62                 }
63             };
64 
WearableRecyclerView(Context context)65     public WearableRecyclerView(Context context) {
66         this(context, null);
67     }
68 
WearableRecyclerView(Context context, @Nullable AttributeSet attrs)69     public WearableRecyclerView(Context context, @Nullable AttributeSet attrs) {
70         this(context, attrs, 0);
71     }
72 
WearableRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle)73     public WearableRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
74         this(context, attrs, defStyle, 0);
75     }
76 
WearableRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle, int defStyleRes)77     public WearableRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle,
78             int defStyleRes) {
79         super(context, attrs, defStyle);
80 
81         setHasFixedSize(true);
82         // Padding is used to center the top and bottom items in the list, don't clip to padding to
83         // allows the items to draw in that space.
84         setClipToPadding(false);
85 
86         if (attrs != null) {
87             TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.WearableRecyclerView,
88                     defStyle, defStyleRes);
89             ViewCompat.saveAttributeDataForStyleable(
90                     this, context, R.styleable.WearableRecyclerView, attrs, a, defStyle,
91                     defStyleRes);
92 
93             setCircularScrollingGestureEnabled(
94                     a.getBoolean(
95                             R.styleable.WearableRecyclerView_circularScrollingGestureEnabled,
96                             mCircularScrollingEnabled));
97             setBezelFraction(
98                     a.getFloat(R.styleable.WearableRecyclerView_bezelWidth,
99                             mScrollManager.getBezelWidth()));
100             setScrollDegreesPerScreen(
101                     a.getFloat(
102                             R.styleable.WearableRecyclerView_scrollDegreesPerScreen,
103                             mScrollManager.getScrollDegreesPerScreen()));
104             a.recycle();
105         }
106     }
107 
setupCenteredPadding()108     void setupCenteredPadding() {
109         if (getChildCount() < 1 || !mEdgeItemsCenteringEnabled) {
110             return;
111         }
112         // All the children in the view are the same size, as we set setHasFixedSize
113         // to true, so the height of the first child is the same as all of them.
114         View child = getChildAt(0);
115         int height = child.getHeight();
116         // This is enough padding to center the child view in the parent.
117         int desiredPadding = (int) ((getHeight() * 0.5f) - (height * 0.5f));
118 
119         if (getPaddingTop() != desiredPadding) {
120             mOriginalPaddingTop = getPaddingTop();
121             mOriginalPaddingBottom = getPaddingBottom();
122             // The view is symmetric along the vertical axis, so the top and bottom
123             // can be the same.
124             setPadding(getPaddingLeft(), desiredPadding, getPaddingRight(), desiredPadding);
125 
126             // The focused child should be in the center, so force a scroll to it.
127             View focusedChild = getFocusedChild();
128             int focusedPosition =
129                     (focusedChild != null) ? getLayoutManager().getPosition(
130                             focusedChild) : 0;
131             getLayoutManager().scrollToPosition(focusedPosition);
132         }
133     }
134 
setupOriginalPadding()135     private void setupOriginalPadding() {
136         if (mOriginalPaddingTop == NO_VALUE) {
137             return;
138         } else {
139             setPadding(getPaddingLeft(), mOriginalPaddingTop, getPaddingRight(),
140                     mOriginalPaddingBottom);
141         }
142     }
143 
144     @Override
onTouchEvent(MotionEvent event)145     public boolean onTouchEvent(MotionEvent event) {
146         if (mCircularScrollingEnabled && mScrollManager.onTouchEvent(event)) {
147             return true;
148         }
149         return super.onTouchEvent(event);
150     }
151 
152     @Override
153     @SuppressWarnings("deprecation") /* Display.getSize() */
onAttachedToWindow()154     protected void onAttachedToWindow() {
155         super.onAttachedToWindow();
156         Point screenSize = new Point();
157         getDisplay().getSize(screenSize);
158         mScrollManager.setRecyclerView(this, screenSize.x, screenSize.y);
159         getViewTreeObserver().addOnPreDrawListener(mPaddingPreDrawListener);
160     }
161 
162     @Override
onDetachedFromWindow()163     protected void onDetachedFromWindow() {
164         super.onDetachedFromWindow();
165         mScrollManager.clearRecyclerView();
166         getViewTreeObserver().removeOnPreDrawListener(mPaddingPreDrawListener);
167     }
168 
169     /**
170      * Enables/disables circular touch scrolling for this view. When enabled, circular touch
171      * gestures around the edge of the screen will cause the view to scroll up or down. Related
172      * methods let you specify the characteristics of the scrolling, like the speed of the scroll
173      * or the are considered for the start of this scrolling gesture.
174      *
175      * @see #setScrollDegreesPerScreen(float)
176      * @see #setBezelFraction(float)
177      */
setCircularScrollingGestureEnabled(boolean circularScrollingGestureEnabled)178     public void setCircularScrollingGestureEnabled(boolean circularScrollingGestureEnabled) {
179         mCircularScrollingEnabled = circularScrollingGestureEnabled;
180     }
181 
182     /**
183      * Returns whether circular scrolling is enabled for this view.
184      *
185      * @see #setCircularScrollingGestureEnabled(boolean)
186      */
isCircularScrollingGestureEnabled()187     public boolean isCircularScrollingGestureEnabled() {
188         return mCircularScrollingEnabled;
189     }
190 
191     /**
192      * Sets how many degrees the user has to rotate by to scroll through one screen height when they
193      * are using the circular scrolling gesture.The default value equates 180 degrees scroll to one
194      * screen.
195      *
196      * @see #setCircularScrollingGestureEnabled(boolean)
197      *
198      * @param degreesPerScreen the number of degrees to rotate by to scroll through one whole
199      *                         height of the screen,
200      */
setScrollDegreesPerScreen(float degreesPerScreen)201     public void setScrollDegreesPerScreen(float degreesPerScreen) {
202         mScrollManager.setScrollDegreesPerScreen(degreesPerScreen);
203     }
204 
205     /**
206      * Returns how many degrees does the user have to rotate for to scroll through one screen
207      * height.
208      *
209      * @see #setCircularScrollingGestureEnabled(boolean)
210      * @see #setScrollDegreesPerScreen(float).
211      */
getScrollDegreesPerScreen()212     public float getScrollDegreesPerScreen() {
213         return mScrollManager.getScrollDegreesPerScreen();
214     }
215 
216     /**
217      * Taps within this radius and the radius of the screen are considered close enough to the
218      * bezel to be candidates for circular scrolling. Expressed as a fraction of the screen's
219      * radius. The default is the whole screen i.e 1.0f.
220      */
setBezelFraction(float fraction)221     public void setBezelFraction(float fraction) {
222         mScrollManager.setBezelWidth(fraction);
223     }
224 
225     /**
226      * Returns the current bezel width for circular scrolling as a fraction of the screen's
227      * radius.
228      *
229      * @see #setBezelFraction(float)
230      */
getBezelFraction()231     public float getBezelFraction() {
232         return mScrollManager.getBezelWidth();
233     }
234 
235     /**
236      * Use this method to configure the {@link WearableRecyclerView} on round watches to always
237      * align the first and last items with the vertical center of the screen. This effectively moves
238      * the start and end of the list to the middle of the screen if the user has scrolled so far.
239      * It takes the height of the children into account so that they are correctly centered.
240      * On nonRound watches, this method has no effect and original padding is used.
241      *
242      * @param isEnabled set to true if you wish to align the edge children (first and last)
243      *                        with the center of the screen.
244      */
setEdgeItemsCenteringEnabled(boolean isEnabled)245     public void setEdgeItemsCenteringEnabled(boolean isEnabled) {
246         if (!getResources().getConfiguration().isScreenRound()) {
247             mEdgeItemsCenteringEnabled = false;
248             return;
249         }
250         mEdgeItemsCenteringEnabled = isEnabled;
251         if (mEdgeItemsCenteringEnabled) {
252             if (getChildCount() > 0) {
253                 setupCenteredPadding();
254             } else {
255                 mCenterEdgeItemsWhenThereAreChildren = true;
256             }
257         } else {
258             setupOriginalPadding();
259             mCenterEdgeItemsWhenThereAreChildren = false;
260         }
261     }
262 
263     /**
264      * Returns whether the view is currently configured to center the edge children. See {@link
265      * #setEdgeItemsCenteringEnabled} for details.
266      */
isEdgeItemsCenteringEnabled()267     public boolean isEdgeItemsCenteringEnabled() {
268         return mEdgeItemsCenteringEnabled;
269     }
270 }
271