Home | History | Annotate | Download | only in widget
      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