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