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