WearableRecyclerView.java

/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.wear.widget;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Point;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewTreeObserver;

import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import androidx.recyclerview.widget.RecyclerView;
import androidx.wear.R;

/**
 * Wearable specific implementation of the {@link RecyclerView} enabling {@link
 * #setCircularScrollingGestureEnabled(boolean)} circular scrolling} and semi-circular layouts.
 *
 * @see #setCircularScrollingGestureEnabled(boolean)
 */
public class WearableRecyclerView extends RecyclerView {
    private static final String TAG = "WearableRecyclerView";

    private static final int NO_VALUE = Integer.MIN_VALUE;

    private final ScrollManager mScrollManager = new ScrollManager();
    private boolean mCircularScrollingEnabled;
    private boolean mEdgeItemsCenteringEnabled;
    boolean mCenterEdgeItemsWhenThereAreChildren;

    private int mOriginalPaddingTop = NO_VALUE;
    private int mOriginalPaddingBottom = NO_VALUE;

    /** Pre-draw listener which is used to adjust the padding on this view before its first draw. */
    private final ViewTreeObserver.OnPreDrawListener mPaddingPreDrawListener =
            new ViewTreeObserver.OnPreDrawListener() {
                @Override
                public boolean onPreDraw() {
                    if (mCenterEdgeItemsWhenThereAreChildren && getChildCount() > 0) {
                        setupCenteredPadding();
                        mCenterEdgeItemsWhenThereAreChildren = false;
                    }
                    return true;
                }
            };

    public WearableRecyclerView(Context context) {
        this(context, null);
    }

    public WearableRecyclerView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public WearableRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
        this(context, attrs, defStyle, 0);
    }

    public WearableRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle,
            int defStyleRes) {
        super(context, attrs, defStyle);

        setHasFixedSize(true);
        // Padding is used to center the top and bottom items in the list, don't clip to padding to
        // allows the items to draw in that space.
        setClipToPadding(false);

        if (attrs != null) {
            TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.WearableRecyclerView,
                    defStyle, defStyleRes);
            ViewCompat.saveAttributeDataForStyleable(
                    this, context, R.styleable.WearableRecyclerView, attrs, a, defStyle,
                    defStyleRes);

            setCircularScrollingGestureEnabled(
                    a.getBoolean(
                            R.styleable.WearableRecyclerView_circularScrollingGestureEnabled,
                            mCircularScrollingEnabled));
            setBezelFraction(
                    a.getFloat(R.styleable.WearableRecyclerView_bezelWidth,
                            mScrollManager.getBezelWidth()));
            setScrollDegreesPerScreen(
                    a.getFloat(
                            R.styleable.WearableRecyclerView_scrollDegreesPerScreen,
                            mScrollManager.getScrollDegreesPerScreen()));
            a.recycle();
        }
    }

    void setupCenteredPadding() {
        if (getChildCount() < 1 || !mEdgeItemsCenteringEnabled) {
            return;
        }
        // All the children in the view are the same size, as we set setHasFixedSize
        // to true, so the height of the first child is the same as all of them.
        View child = getChildAt(0);
        int height = child.getHeight();
        // This is enough padding to center the child view in the parent.
        int desiredPadding = (int) ((getHeight() * 0.5f) - (height * 0.5f));

        if (getPaddingTop() != desiredPadding) {
            mOriginalPaddingTop = getPaddingTop();
            mOriginalPaddingBottom = getPaddingBottom();
            // The view is symmetric along the vertical axis, so the top and bottom
            // can be the same.
            setPadding(getPaddingLeft(), desiredPadding, getPaddingRight(), desiredPadding);

            // The focused child should be in the center, so force a scroll to it.
            View focusedChild = getFocusedChild();
            int focusedPosition =
                    (focusedChild != null) ? getLayoutManager().getPosition(
                            focusedChild) : 0;
            getLayoutManager().scrollToPosition(focusedPosition);
        }
    }

    private void setupOriginalPadding() {
        if (mOriginalPaddingTop == NO_VALUE) {
            return;
        } else {
            setPadding(getPaddingLeft(), mOriginalPaddingTop, getPaddingRight(),
                    mOriginalPaddingBottom);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mCircularScrollingEnabled && mScrollManager.onTouchEvent(event)) {
            return true;
        }
        return super.onTouchEvent(event);
    }

    @Override
    @SuppressWarnings("deprecation") /* Display.getSize() */
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        Point screenSize = new Point();
        getDisplay().getSize(screenSize);
        mScrollManager.setRecyclerView(this, screenSize.x, screenSize.y);
        getViewTreeObserver().addOnPreDrawListener(mPaddingPreDrawListener);
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        mScrollManager.clearRecyclerView();
        getViewTreeObserver().removeOnPreDrawListener(mPaddingPreDrawListener);
    }

    /**
     * Enables/disables circular touch scrolling for this view. When enabled, circular touch
     * gestures around the edge of the screen will cause the view to scroll up or down. Related
     * methods let you specify the characteristics of the scrolling, like the speed of the scroll
     * or the are considered for the start of this scrolling gesture.
     *
     * @see #setScrollDegreesPerScreen(float)
     * @see #setBezelFraction(float)
     */
    public void setCircularScrollingGestureEnabled(boolean circularScrollingGestureEnabled) {
        mCircularScrollingEnabled = circularScrollingGestureEnabled;
    }

    /**
     * Returns whether circular scrolling is enabled for this view.
     *
     * @see #setCircularScrollingGestureEnabled(boolean)
     */
    public boolean isCircularScrollingGestureEnabled() {
        return mCircularScrollingEnabled;
    }

    /**
     * Sets how many degrees the user has to rotate by to scroll through one screen height when they
     * are using the circular scrolling gesture.The default value equates 180 degrees scroll to one
     * screen.
     *
     * @see #setCircularScrollingGestureEnabled(boolean)
     *
     * @param degreesPerScreen the number of degrees to rotate by to scroll through one whole
     *                         height of the screen,
     */
    public void setScrollDegreesPerScreen(float degreesPerScreen) {
        mScrollManager.setScrollDegreesPerScreen(degreesPerScreen);
    }

    /**
     * Returns how many degrees does the user have to rotate for to scroll through one screen
     * height.
     *
     * @see #setCircularScrollingGestureEnabled(boolean)
     * @see #setScrollDegreesPerScreen(float).
     */
    public float getScrollDegreesPerScreen() {
        return mScrollManager.getScrollDegreesPerScreen();
    }

    /**
     * Taps within this radius and the radius of the screen are considered close enough to the
     * bezel to be candidates for circular scrolling. Expressed as a fraction of the screen's
     * radius. The default is the whole screen i.e 1.0f.
     */
    public void setBezelFraction(float fraction) {
        mScrollManager.setBezelWidth(fraction);
    }

    /**
     * Returns the current bezel width for circular scrolling as a fraction of the screen's
     * radius.
     *
     * @see #setBezelFraction(float)
     */
    public float getBezelFraction() {
        return mScrollManager.getBezelWidth();
    }

    /**
     * Use this method to configure the {@link WearableRecyclerView} on round watches to always
     * align the first and last items with the vertical center of the screen. This effectively moves
     * the start and end of the list to the middle of the screen if the user has scrolled so far.
     * It takes the height of the children into account so that they are correctly centered.
     * On nonRound watches, this method has no effect and original padding is used.
     *
     * @param isEnabled set to true if you wish to align the edge children (first and last)
     *                        with the center of the screen.
     */
    public void setEdgeItemsCenteringEnabled(boolean isEnabled) {
        if (!getResources().getConfiguration().isScreenRound()) {
            mEdgeItemsCenteringEnabled = false;
            return;
        }
        mEdgeItemsCenteringEnabled = isEnabled;
        if (mEdgeItemsCenteringEnabled) {
            if (getChildCount() > 0) {
                setupCenteredPadding();
            } else {
                mCenterEdgeItemsWhenThereAreChildren = true;
            }
        } else {
            setupOriginalPadding();
            mCenterEdgeItemsWhenThereAreChildren = false;
        }
    }

    /**
     * Returns whether the view is currently configured to center the edge children. See {@link
     * #setEdgeItemsCenteringEnabled} for details.
     */
    public boolean isEdgeItemsCenteringEnabled() {
        return mEdgeItemsCenteringEnabled;
    }
}