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