Add WearableRecyclerView to support-wearable.

Bug:33035990
Test: Added in this CL

Change-Id: I63469f1e734c066f19c0017d915592dbefddef1c
diff --git a/wearable/Android.mk b/wearable/Android.mk
index 98ea45a..7339f55 100644
--- a/wearable/Android.mk
+++ b/wearable/Android.mk
@@ -19,7 +19,8 @@
 #
 #   LOCAL_STATIC_ANDROID_LIBRARIES := \
 #       android-support-wearable \
-#       android-support-core-ui
+#       android-support-core-ui \
+#       android-support-v7-recyclerview
 #
 # in their makefiles to include the resources and their dependencies in their package.
 include $(CLEAR_VARS)
@@ -29,9 +30,10 @@
 LOCAL_SRC_FILES := $(call all-java-files-under,src)
 LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res
 LOCAL_SHARED_ANDROID_LIBRARIES := \
-    android-support-core-ui \
-    android-support-annotations \
-    android-support-v7-recyclerview
+        android-support-core-ui \
+        android-support-annotations \
+        android-support-v7-recyclerview \
+        android-support-v4
 LOCAL_JAR_EXCLUDE_FILES := none
 LOCAL_JAVA_LANGUAGE_VERSION := 1.7
 LOCAL_AAPT_FLAGS := --add-javadoc-annotation doconly
diff --git a/wearable/build.gradle b/wearable/build.gradle
index 74b4c89..81ec665 100644
--- a/wearable/build.gradle
+++ b/wearable/build.gradle
@@ -5,13 +5,15 @@
     compile project(':support-annotations')
     compile project(':support-core-ui')
     compile project(':support-recyclerview-v7')
-
     androidTestCompile (libs.test_runner) {
         exclude module: 'support-annotations'
     }
     androidTestCompile (libs.espresso_core) {
         exclude module: 'support-annotations'
     }
+    androidTestCompile libs.mockito_core
+    androidTestCompile libs.dexmaker
+    androidTestCompile libs.dexmaker_mockito
 }
 
 android {
diff --git a/wearable/res-public/values/public_attrs.xml b/wearable/res-public/values/public_attrs.xml
index afb7bfe..a8a2909 100644
--- a/wearable/res-public/values/public_attrs.xml
+++ b/wearable/res-public/values/public_attrs.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
-<!-- Copyright (C) 2016 The Android Open Source Project
+<!-- Copyright (C) 2017 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.
@@ -16,5 +16,11 @@
 
 <!-- Definitions of attributes to be exposed as public -->
 <resources>
+    <!-- BoxInsetLayout -->
     <public type="attr" name="boxedEdges" />
+
+    <!-- WearableRecyclerView -->
+    <public type="attr" name="bezelWidth" />
+    <public type="attr" name="circularScrollingGestureEnabled" />
+    <public type="attr" name="scrollDegreesPerScreen" />
 </resources>
\ No newline at end of file
diff --git a/wearable/res/values/attrs.xml b/wearable/res/values/attrs.xml
index e52dee9..bdf3675 100644
--- a/wearable/res/values/attrs.xml
+++ b/wearable/res/values/attrs.xml
@@ -47,4 +47,21 @@
             <flag name="all" value="0x0F" />
         </attr>
     </declare-styleable>
+
+    <!-- Attributes that can be used with any
+        {@link android.support.wearable.view.WearableRecyclerView}.
+        These attributes relate to the circular scrolling gesture of the view. -->
+    <declare-styleable name="WearableRecyclerView">
+        <!-- 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 -->
+        <attr name="bezelWidth" format="fraction" />
+        <!-- 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. -->
+        <attr name="circularScrollingGestureEnabled" format="boolean" />
+        <!-- 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.-->
+        <attr name="scrollDegreesPerScreen" format="float" />
+    </declare-styleable>
 </resources>
diff --git a/wearable/res/values/dimens.xml b/wearable/res/values/dimens.xml
new file mode 100644
index 0000000..4b95511
--- /dev/null
+++ b/wearable/res/values/dimens.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2017 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.
+-->
+<resources>
+    <!-- Values for the WearableRecyclerView. -->
+    <dimen name="wrv_curve_default_x_offset">24dp</dimen>
+</resources>
\ No newline at end of file
diff --git a/wearable/src/android/support/wearable/view/CurvedOffsettingHelper.java b/wearable/src/android/support/wearable/view/CurvedOffsettingHelper.java
new file mode 100644
index 0000000..a1f4e3b
--- /dev/null
+++ b/wearable/src/android/support/wearable/view/CurvedOffsettingHelper.java
@@ -0,0 +1,141 @@
+/*
+ * 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 android.support.wearable.view;
+
+import android.graphics.Path;
+import android.graphics.PathMeasure;
+import android.support.wearable.R;
+import android.view.View;
+
+/**
+ * This implementation of the {@link WearableRecyclerView.OffsettingHelper} provides basic
+ * offsetting logic for updating child layout. For round devices it offsets the children
+ * horizontally to make them appear to travel around a circle. For square devices it aligns them
+ * in a straight list.
+ */
+public class CurvedOffsettingHelper extends WearableRecyclerView.OffsettingHelper {
+    private static final float EPSILON = 0.001f;
+
+    private final Path mCurvePath;
+    private final PathMeasure mPathMeasure;
+    private int mCurvePathHeight;
+    private int mXCurveOffset;
+    private float mPathLength;
+    private float mCurveBottom;
+    private float mCurveTop;
+    private float mLineGradient;
+    private final float[] mPathPoints = new float[2];
+    private final float[] mPathTangent = new float[2];
+    private final float[] mAnchorOffsetXY = new float[2];
+
+    private WearableRecyclerView mParentView;
+    private boolean mIsScreenRound;
+    private int mLayoutWidth;
+    private int mLayoutHeight;
+
+    public CurvedOffsettingHelper() {
+        mCurvePath = new Path();
+        mPathMeasure = new PathMeasure();
+    }
+
+    @Override
+    public void updateChild(View child, WearableRecyclerView parent) {
+        if (mParentView != parent || (mParentView != null
+                && (mParentView.getWidth() != parent.getWidth()
+                        || mParentView.getHeight() != parent.getHeight()))) {
+            mParentView = parent;
+            mIsScreenRound =
+                    mParentView.getContext().getResources().getConfiguration().isScreenRound();
+            mXCurveOffset =
+                    mParentView.getResources().getDimensionPixelSize(
+                            R.dimen.wrv_curve_default_x_offset);
+            mLayoutWidth = mParentView.getWidth();
+            mLayoutHeight = mParentView.getHeight();
+        }
+        if (mIsScreenRound) {
+            maybeSetUpCircularInitialLayout(mLayoutWidth, mLayoutHeight);
+            mAnchorOffsetXY[0] = mXCurveOffset;
+            mAnchorOffsetXY[1] = child.getHeight() / 2.0f;
+            adjustAnchorOffsetXY(child, mAnchorOffsetXY);
+            float minCenter = -(float) child.getHeight() / 2;
+            float maxCenter = mLayoutHeight + (float) child.getHeight() / 2;
+            float range = maxCenter - minCenter;
+            float verticalAnchor = (float) child.getTop() + mAnchorOffsetXY[1];
+            float mYScrollProgress = (verticalAnchor + Math.abs(minCenter)) / range;
+
+            mPathMeasure.getPosTan(mYScrollProgress * mPathLength, mPathPoints, mPathTangent);
+
+            boolean topClusterRisk =
+                    Math.abs(mPathPoints[1] - mCurveBottom) < EPSILON && minCenter < mPathPoints[1];
+            boolean bottomClusterRisk =
+                    Math.abs(mPathPoints[1] - mCurveTop) < EPSILON && maxCenter > mPathPoints[1];
+            // Continue offsetting the child along the straight-line part of the curve, if it has
+            // not gone off the screen when it reached the end of the original curve.
+            if (topClusterRisk || bottomClusterRisk) {
+                mPathPoints[1] = verticalAnchor;
+                mPathPoints[0] = (Math.abs(verticalAnchor) * mLineGradient);
+            }
+
+            // Offset the View to match the provided anchor point.
+            int newLeft = (int) (mPathPoints[0] - mAnchorOffsetXY[0]);
+            child.offsetLeftAndRight(newLeft - child.getLeft());
+            float verticalTranslation = mPathPoints[1] - verticalAnchor;
+            child.setTranslationY(verticalTranslation);
+        }
+    }
+
+    /**
+     * Override this method if you wish to adjust the anchor coordinates for each child view during
+     * a layout pass. In the override set the new desired anchor coordinates in the provided array.
+     * The coordinates should be provided in relation to the child view.
+     *
+     * @param child          The child view to which the anchor coordinates will apply.
+     * @param anchorOffsetXY The anchor coordinates for the provided child view, by default set to
+     *                       a pre-defined constant on the horizontal axis and half of the child
+     *                       height on the vertical axis (vertical center).
+     */
+    public void adjustAnchorOffsetXY(View child, float[] anchorOffsetXY) {
+        return;
+    }
+
+    /** Set up the initial layout for round screens. */
+    private void maybeSetUpCircularInitialLayout(int width, int height) {
+        // The values in this function are custom to the curve we use.
+        if (mCurvePathHeight != height) {
+            mCurvePathHeight = height;
+            mCurveBottom = -0.048f * height;
+            mCurveTop = 1.048f * height;
+            mLineGradient = 0.5f / 0.048f;
+            mCurvePath.reset();
+            mCurvePath.moveTo(0.5f * width, mCurveBottom);
+            mCurvePath.lineTo(0.34f * width, 0.075f * height);
+            mCurvePath.cubicTo(
+                    0.22f * width, 0.17f * height, 0.13f * width, 0.32f * height, 0.13f * width,
+                    height / 2);
+            mCurvePath.cubicTo(
+                    0.13f * width,
+                    0.68f * height,
+                    0.22f * width,
+                    0.83f * height,
+                    0.34f * width,
+                    0.925f * height);
+            mCurvePath.lineTo(width / 2, mCurveTop);
+            mPathMeasure.setPath(mCurvePath, false);
+            mPathLength = mPathMeasure.getLength();
+        }
+    }
+}
diff --git a/wearable/src/android/support/wearable/view/ScrollManager.java b/wearable/src/android/support/wearable/view/ScrollManager.java
new file mode 100644
index 0000000..4699d4b
--- /dev/null
+++ b/wearable/src/android/support/wearable/view/ScrollManager.java
@@ -0,0 +1,236 @@
+/*
+ * 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 android.support.wearable.view;
+
+import static android.support.annotation.RestrictTo.Scope.LIBRARY_GROUP;
+
+import android.annotation.TargetApi;
+import android.os.Build;
+import android.support.annotation.RestrictTo;
+import android.support.v7.widget.RecyclerView;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+
+/**
+ * Class adding circular scrolling support to {@link WearableRecyclerView}.
+ *
+ * @hide
+ */
+@TargetApi(Build.VERSION_CODES.M)
+@RestrictTo(LIBRARY_GROUP)
+class ScrollManager {
+    // One second in milliseconds.
+    private static final int ONE_SEC_IN_MS = 1000;
+    private static final float VELOCITY_MULTIPLIER = 1.5f;
+    private static final float FLING_EDGE_RATIO = 1.5f;
+
+    /**
+     * Taps beyond this radius fraction are considered close enough to the bezel to be candidates
+     * for circular scrolling.
+     */
+    private float mMinRadiusFraction = 0.0f;
+
+    private float mMinRadiusFractionSquared = mMinRadiusFraction * mMinRadiusFraction;
+
+    /** How many degrees you have to drag along the bezel to scroll one screen height. */
+    private float mScrollDegreesPerScreen = 180;
+
+    private float mScrollRadiansPerScreen = (float) Math.toRadians(mScrollDegreesPerScreen);
+
+    /** Radius of screen in pixels, ignoring insets, if any. */
+    private float mScreenRadiusPx;
+
+    private float mScreenRadiusPxSquared;
+
+    /** How many pixels to scroll for each radian of bezel scrolling. */
+    private float mScrollPixelsPerRadian;
+
+    /** Whether an {@link MotionEvent#ACTION_DOWN} was received near the bezel. */
+    private boolean mDown;
+
+    /**
+     * Whether the user tapped near the bezel and dragged approximately tangentially to initiate
+     * bezel scrolling.
+     */
+    private boolean mScrolling;
+    /**
+     * The angle of the user's finger relative to the center of the screen for the last {@link
+     * MotionEvent} during bezel scrolling.
+     */
+    private float mLastAngleRadians;
+
+    private RecyclerView mRecyclerView;
+    VelocityTracker mVelocityTracker;
+
+    /** Should be called after the window is attached to the view. */
+    void setRecyclerView(RecyclerView recyclerView, int width, int height) {
+        mRecyclerView = recyclerView;
+        mScreenRadiusPx = Math.max(width, height) / 2f;
+        mScreenRadiusPxSquared = mScreenRadiusPx * mScreenRadiusPx;
+        mScrollPixelsPerRadian = height / mScrollRadiansPerScreen;
+        mVelocityTracker = VelocityTracker.obtain();
+    }
+
+    /** Remove the binding with a {@link RecyclerView} */
+    void clearRecyclerView() {
+        mRecyclerView = null;
+    }
+
+    /**
+     * Method dealing with touch events intercepted from the attached {@link RecyclerView}.
+     *
+     * @param event the intercepted touch event.
+     * @return true if the even was handled, false otherwise.
+     */
+    boolean onTouchEvent(MotionEvent event) {
+        float deltaX = event.getRawX() - mScreenRadiusPx;
+        float deltaY = event.getRawY() - mScreenRadiusPx;
+        float radiusSquared = deltaX * deltaX + deltaY * deltaY;
+        final MotionEvent vtev = MotionEvent.obtain(event);
+        mVelocityTracker.addMovement(vtev);
+        vtev.recycle();
+
+        switch (event.getActionMasked()) {
+            case MotionEvent.ACTION_DOWN:
+                if (radiusSquared / mScreenRadiusPxSquared > mMinRadiusFractionSquared) {
+                    mDown = true;
+                    return true; // Consume the event.
+                }
+                break;
+
+            case MotionEvent.ACTION_MOVE:
+                if (mScrolling) {
+                    float angleRadians = (float) Math.atan2(deltaY, deltaX);
+                    float deltaRadians = angleRadians - mLastAngleRadians;
+                    deltaRadians = normalizeAngleRadians(deltaRadians);
+                    int scrollPixels = Math.round(deltaRadians * mScrollPixelsPerRadian);
+                    if (scrollPixels != 0) {
+                        mRecyclerView.scrollBy(0 /* x */, scrollPixels /* y */);
+                        // Recompute deltaRadians in terms of rounded scrollPixels.
+                        deltaRadians = scrollPixels / mScrollPixelsPerRadian;
+                        mLastAngleRadians += deltaRadians;
+                        mLastAngleRadians = normalizeAngleRadians(mLastAngleRadians);
+                    }
+                    // Always consume the event so that we never break the circular scrolling
+                    // gesture.
+                    return true;
+                }
+
+                if (mDown) {
+                    float deltaXFromCenter = event.getRawX() - mScreenRadiusPx;
+                    float deltaYFromCenter = event.getRawY() - mScreenRadiusPx;
+                    float distFromCenter = (float) Math.hypot(deltaXFromCenter, deltaYFromCenter);
+                    if (distFromCenter != 0) {
+                        deltaXFromCenter /= distFromCenter;
+                        deltaYFromCenter /= distFromCenter;
+
+                        mScrolling = true;
+                        mRecyclerView.invalidate();
+                        mLastAngleRadians = (float) Math.atan2(deltaYFromCenter, deltaXFromCenter);
+                        return true; // Consume the event.
+                    }
+                } else {
+                    // Double check we're not missing an event we should really be handling.
+                    if (radiusSquared / mScreenRadiusPxSquared > mMinRadiusFractionSquared) {
+                        mDown = true;
+                        return true; // Consume the event.
+                    }
+                }
+                break;
+
+            case MotionEvent.ACTION_UP:
+                mDown = false;
+                mScrolling = false;
+                mVelocityTracker.computeCurrentVelocity(ONE_SEC_IN_MS,
+                        mRecyclerView.getMaxFlingVelocity());
+                int velocityY = (int) mVelocityTracker.getYVelocity();
+                if (event.getX() < FLING_EDGE_RATIO * mScreenRadiusPx) {
+                    velocityY = -velocityY;
+                }
+                mVelocityTracker.clear();
+                if (Math.abs(velocityY) > mRecyclerView.getMinFlingVelocity()) {
+                    return mRecyclerView.fling(0, (int) (VELOCITY_MULTIPLIER * velocityY));
+                }
+                break;
+
+            case MotionEvent.ACTION_CANCEL:
+                if (mDown) {
+                    mDown = false;
+                    mScrolling = false;
+                    mRecyclerView.invalidate();
+                    return true; // Consume the event.
+                }
+                break;
+        }
+
+        return false;
+    }
+
+    /**
+     * Normalizes an angle to be in the range [-pi, pi] by adding or subtracting 2*pi if necessary.
+     *
+     * @param angleRadians an angle in radians. Must be no more than 2*pi out of normal range.
+     * @return an angle in radians in the range [-pi, pi]
+     */
+    private static float normalizeAngleRadians(float angleRadians) {
+        if (angleRadians < -Math.PI) {
+            angleRadians = (float) (angleRadians + Math.PI * 2);
+        }
+        if (angleRadians > Math.PI) {
+            angleRadians = (float) (angleRadians - Math.PI * 2);
+        }
+        return angleRadians;
+    }
+
+    /**
+     * Set how many degrees you have to drag along the bezel to scroll one screen height.
+     *
+     * @param degreesPerScreen desired degrees per screen scroll.
+     */
+    public void setScrollDegreesPerScreen(float degreesPerScreen) {
+        mScrollDegreesPerScreen = degreesPerScreen;
+        mScrollRadiansPerScreen = (float) Math.toRadians(mScrollDegreesPerScreen);
+    }
+
+    /**
+     * Sets the width of a virtual 'bezel' close to the edge of the screen within which taps can be
+     * recognized as belonging to a rotary scrolling gesture.
+     *
+     * @param fraction desired fraction of the width of the screen to be treated as a valid rotary
+     *                 scrolling target.
+     */
+    public void setBezelWidth(float fraction) {
+        mMinRadiusFraction = 1 - fraction;
+        mMinRadiusFractionSquared = mMinRadiusFraction * mMinRadiusFraction;
+    }
+
+    /**
+     * Returns how many degrees you have to drag along the bezel to scroll one screen height. See
+     * {@link #setScrollDegreesPerScreen(float)} for details.
+     */
+    public float getScrollDegreesPerScreen() {
+        return mScrollDegreesPerScreen;
+    }
+
+    /**
+     * Returns the current bezel width for circular scrolling. See {@link #setBezelWidth(float)}
+     * for details.
+     */
+    public float getBezelWidth() {
+        return 1 - mMinRadiusFraction;
+    }
+}
diff --git a/wearable/src/android/support/wearable/view/WearableRecyclerView.java b/wearable/src/android/support/wearable/view/WearableRecyclerView.java
new file mode 100644
index 0000000..5531417
--- /dev/null
+++ b/wearable/src/android/support/wearable/view/WearableRecyclerView.java
@@ -0,0 +1,334 @@
+/*
+ * 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 android.support.wearable.view;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Point;
+import android.os.Build;
+import android.support.annotation.Nullable;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.support.wearable.R;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+
+/**
+ * Wearable specific implementation of the {@link RecyclerView} enabling {@link
+ * #setCircularScrollingGestureEnabled(boolean)} circular scrolling} and semi-circular layouts.
+ *
+ * @see #setCircularScrollingGestureEnabled(boolean)
+ * @see #setOffsettingHelper(OffsettingHelper)
+ */
+@TargetApi(Build.VERSION_CODES.M)
+public class WearableRecyclerView extends RecyclerView {
+    private static final String TAG = "WearableRecyclerView";
+
+    private static final int NO_VALUE = Integer.MIN_VALUE;
+
+    /**
+     * This class defines the offsetting logic for updating layout of children in a
+     * WearableRecyclerView.
+     */
+    public abstract static class OffsettingHelper {
+
+        /**
+         * Override this method if you wish to implement custom child layout behavior on scroll.
+         *
+         * @param child  the current child to be affected.
+         * @param parent the {@link WearableRecyclerView} parent that this helper is attached to.
+         */
+        public abstract void updateChild(View child, WearableRecyclerView parent);
+    }
+
+    private final ScrollManager mScrollManager = new ScrollManager();
+    private OffsettingHelper mOffsettingHelper;
+    private boolean mCircularScrollingEnabled;
+    private boolean mEdgeItemsCenteringEnabled;
+
+    private int mOriginalPaddingTop = NO_VALUE;
+    private int mOriginalPaddingBottom = NO_VALUE;
+
+    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);
+
+            setCircularScrollingGestureEnabled(
+                    a.getBoolean(
+                            R.styleable.WearableRecyclerView_circularScrollingGestureEnabled,
+                            mCircularScrollingEnabled));
+            setBezelWidthFraction(
+                    a.getFloat(R.styleable.WearableRecyclerView_bezelWidth,
+                            mScrollManager.getBezelWidth()));
+            setScrollDegreesPerScreen(
+                    a.getFloat(
+                            R.styleable.WearableRecyclerView_scrollDegreesPerScreen,
+                            mScrollManager.getScrollDegreesPerScreen()));
+            a.recycle();
+        }
+
+        setLayoutManager(new OffsettingLinearLayoutManager(getContext()));
+    }
+
+    private 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
+    protected void onAttachedToWindow() {
+        super.onAttachedToWindow();
+        Point screenSize = new Point();
+        getDisplay().getSize(screenSize);
+        mScrollManager.setRecyclerView(this, screenSize.x, screenSize.y);
+    }
+
+    @Override
+    protected void onDetachedFromWindow() {
+        super.onDetachedFromWindow();
+        mScrollManager.clearRecyclerView();
+    }
+
+    @Override
+    public void setLayoutManager(LayoutManager layoutManager) {
+        if (!(layoutManager instanceof OffsettingLinearLayoutManager)) {
+            throw new UnsupportedOperationException(
+                    "This class implements its own layout manager and does not take custom ones");
+        } else {
+            super.setLayoutManager(layoutManager);
+        }
+    }
+
+    /**
+     * 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 #setBezelWidthFraction(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 setBezelWidthFraction(float fraction) {
+        mScrollManager.setBezelWidth(fraction);
+    }
+
+    /**
+     * Returns the current bezel width for circular scrolling as a fraction of the screen's
+     * radius.
+     *
+     * @see #setBezelWidthFraction(float)
+     */
+    public float getBezelWidthFraction() {
+        return mScrollManager.getBezelWidth();
+    }
+
+    /**
+     * Sets the {@link WearableRecyclerView.OffsettingHelper} that this {@link
+     * WearableRecyclerView} will use.
+     *
+     * @param offsettingHelper the instance of {@link OffsettingHelper} to use. Pass null if you
+     *                         wish to clear the instance used.
+     */
+    public void setOffsettingHelper(@Nullable OffsettingHelper offsettingHelper) {
+        mOffsettingHelper = offsettingHelper;
+    }
+
+    /**
+     * Return the {@link WearableRecyclerView.OffsettingHelper} currently responsible for
+     * offsetting logic for this {@link WearableRecyclerView}.
+     *
+     * @return the currently used {@link OffsettingHelper} instance.
+     */
+    @Nullable
+    public OffsettingHelper getOffsettingHelper() {
+        return mOffsettingHelper;
+    }
+
+    /**
+     * Use this method to configure the {@link WearableRecyclerView} 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.
+     *
+     * @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) {
+        mEdgeItemsCenteringEnabled = isEnabled;
+        if (mEdgeItemsCenteringEnabled) {
+            setupCenteredPadding();
+        } else {
+            setupOriginalPadding();
+        }
+    }
+
+    /**
+     * Returns whether the view is currently configured to center the edge children. See {@link
+     * #setEdgeItemsCenteringEnabled} for details.
+     */
+    public boolean getEdgeItemsCenteringEnabled() {
+        return mEdgeItemsCenteringEnabled;
+    }
+
+    /**
+     * Helper class which implements a vertical linear layout manager that encapsulates the logic
+     * for updating layout of children of a WearableRecyclerView.
+     */
+    private final class OffsettingLinearLayoutManager extends LinearLayoutManager {
+        /**
+         * Creates a vertical OffsettingLinearLayoutManager
+         *
+         * @param context Current context, will be used to access resources.
+         */
+        OffsettingLinearLayoutManager(Context context) {
+            super(context, VERTICAL, false);
+        }
+
+        @Override
+        public int scrollVerticallyBy(
+                int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
+            int scrolled = super.scrollVerticallyBy(dy, recycler, state);
+
+            updateLayout();
+            return scrolled;
+        }
+
+        @Override
+        public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
+            super.onLayoutChildren(recycler, state);
+            if (getChildCount() == 0) {
+                return;
+            }
+
+            updateLayout();
+        }
+
+        private void updateLayout() {
+            if (mOffsettingHelper != null) {
+                for (int count = 0; count < getChildCount(); count++) {
+                    View child = getChildAt(count);
+                    mOffsettingHelper.updateChild(child, WearableRecyclerView.this);
+                }
+            }
+        }
+    }
+}
diff --git a/wearable/tests/Android.mk b/wearable/tests/Android.mk
deleted file mode 100644
index 41e00fb..0000000
--- a/wearable/tests/Android.mk
+++ /dev/null
@@ -1,38 +0,0 @@
-# Copyright (C) 2015 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.
-
-LOCAL_PATH := $(call my-dir)
-
-include $(CLEAR_VARS)
-
-LOCAL_MODULE_TAGS := tests
-
-LOCAL_SDK_VERSION := $(SUPPORT_CURRENT_SDK_VERSION)
-
-LOCAL_SRC_FILES := $(call all-java-files-under, src)
-
-LOCAL_RESOURCE_DIR := \
-    $(LOCAL_PATH)/res \
-    $(LOCAL_PATH)/../res
-
-LOCAL_STATIC_JAVA_LIBRARIES  := \
-        android-support-wearable \
-        android-support-annotations
-
-LOCAL_PACKAGE_NAME := WearableViewTests
-LOCAL_AAPT_FLAGS := \
-        --auto-add-overlay \
-        --extra-packages android.support.wearable.view
-
-include $(BUILD_PACKAGE)
diff --git a/wearable/tests/AndroidManifest.xml b/wearable/tests/AndroidManifest.xml
index a0ab292..638532d 100644
--- a/wearable/tests/AndroidManifest.xml
+++ b/wearable/tests/AndroidManifest.xml
@@ -19,13 +19,12 @@
           package="android.support.wearable.test">
     <uses-sdk android:minSdkVersion="20"
               android:targetSdkVersion="23"
-              tools:overrideLibrary="android.support.test,
-                      android.app, android.support.test.rule, android.support.test.espresso,
-                      android.support.test.espresso.idling"/>
+              tools:overrideLibrary="android.support.test, android.app, android.support.test.rule,
+                      android.support.test.espresso, android.support.test.espresso.idling"/>
 
     <uses-permission android:name="android.permission.WAKE_LOCK" />
 
-    <application android:supportsRtl="true" >
+    <application android:supportsRtl="true">
         <activity android:name="android.support.wearable.view.LayoutTestActivity">
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
@@ -38,5 +37,12 @@
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity>
+
+        <activity android:name="android.support.wearable.view.WearableRecyclerViewTestActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
     </application>
 </manifest>
diff --git a/wearable/tests/res/layout/curved_offsetting_helper_child.xml b/wearable/tests/res/layout/curved_offsetting_helper_child.xml
new file mode 100644
index 0000000..781d6b3
--- /dev/null
+++ b/wearable/tests/res/layout/curved_offsetting_helper_child.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2017 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.
+  -->
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/content1"
+    android:layout_width="100dp"
+    android:layout_height="50dp"
+    android:text="Test Case 1"/>
\ No newline at end of file
diff --git a/wearable/tests/res/layout/wearable_recycler_view_basic.xml b/wearable/tests/res/layout/wearable_recycler_view_basic.xml
new file mode 100644
index 0000000..3f2c255
--- /dev/null
+++ b/wearable/tests/res/layout/wearable_recycler_view_basic.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2017 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.
+  -->
+
+<android.support.wearable.view.WearableRecyclerView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:id="@+id/wrv">
+
+</android.support.wearable.view.WearableRecyclerView>
diff --git a/wearable/tests/src/android/support/wearable/view/CurvedOffsettingHelperTest.java b/wearable/tests/src/android/support/wearable/view/CurvedOffsettingHelperTest.java
new file mode 100644
index 0000000..69d86ce
--- /dev/null
+++ b/wearable/tests/src/android/support/wearable/view/CurvedOffsettingHelperTest.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2017 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 android.support.wearable.view;
+
+import static junit.framework.Assert.assertEquals;
+
+import android.support.test.InstrumentationRegistry;
+import android.support.test.filters.MediumTest;
+import android.support.test.rule.ActivityTestRule;
+import android.support.test.runner.AndroidJUnit4;
+import android.support.wearable.test.R;
+import android.support.wearable.view.util.WakeLockRule;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.MockitoAnnotations;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@MediumTest
+@RunWith(AndroidJUnit4.class)
+public class CurvedOffsettingHelperTest {
+
+    @Rule
+    public final WakeLockRule wakeLock = new WakeLockRule();
+
+    @Rule
+    public final ActivityTestRule<WearableRecyclerViewTestActivity> mActivityRule =
+            new ActivityTestRule<>(WearableRecyclerViewTestActivity.class, true, true);
+
+    CurvedOffsettingHelper mCurvedOffsettingHelperUnderTest;
+
+    @Before
+    public void setUp() throws Throwable {
+        MockitoAnnotations.initMocks(this);
+        mCurvedOffsettingHelperUnderTest = new CurvedOffsettingHelper();
+    }
+
+    @Test
+    public void testOffsetting() throws Throwable {
+        ViewFetchingRunnable customRunnable = new ViewFetchingRunnable(){
+            @Override
+            public void run() {
+                WearableRecyclerView wrv =
+                        (WearableRecyclerView) mActivityRule.getActivity().findViewById(R.id.wrv);
+                wrv.setLayoutParams(new FrameLayout.LayoutParams(390, 390));
+                wrv.invalidate();
+                mIdViewMap.put(R.id.wrv, wrv);
+            }
+        };
+        mActivityRule.runOnUiThread(customRunnable);
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+        WearableRecyclerView wrv = (WearableRecyclerView) customRunnable.mIdViewMap.get(R.id.wrv);
+        int offset = wrv.getResources().getDimensionPixelSize(R.dimen.wrv_curve_default_x_offset);
+        View child1 = wrv.getChildAt(0);
+        View child2 = wrv.getChildAt(1);
+        View child3 = wrv.getChildAt(2);
+        View child4 = wrv.getChildAt(3);
+        View child5 = wrv.getChildAt(4);
+
+        // When the child is updated by the curved offsetting helper
+        if (child1 != null) {
+            mCurvedOffsettingHelperUnderTest.updateChild(child1, wrv);
+        }
+        if (child2 != null) {
+            mCurvedOffsettingHelperUnderTest.updateChild(child2, wrv);
+        }
+        if (child3 != null) {
+            mCurvedOffsettingHelperUnderTest.updateChild(child3, wrv);
+        }
+        if (child4 != null) {
+            mCurvedOffsettingHelperUnderTest.updateChild(child4, wrv);
+        }
+        if (child5 != null) {
+            mCurvedOffsettingHelperUnderTest.updateChild(child5, wrv);
+        }
+        if (wrv.getResources().getConfiguration().isScreenRound()) {
+            // Then the left position and the translation of the child is modified if the screen is
+            // round
+            if (child1 != null) {
+                assertEquals(162 - offset, child1.getLeft(), 1);
+                assertEquals(-9.5, child1.getTranslationY(), 1);
+            }
+            if (child2 != null) {
+                assertEquals(129 - offset, child2.getLeft(), 1);
+                assertEquals(-16.7, child2.getTranslationY(), 1);
+            }
+            if (child3 != null) {
+                assertEquals(99 - offset, child3.getLeft(), 1);
+                assertEquals(-19.9, child3.getTranslationY(), 1);
+            }
+            if (child4 != null) {
+                assertEquals(76 - offset, child4.getLeft(), 1);
+                assertEquals(-17.9, child4.getTranslationY(), 1);
+            }
+            if (child5 != null) {
+                assertEquals(59 - offset, child5.getLeft(), 1);
+                assertEquals(-13, child5.getTranslationY(), 1);
+            }
+        } else {
+            // Then the child is not modified if the screen is not round.
+            if (child1 != null) {
+                assertEquals(0, child1.getLeft());
+                assertEquals(0.0f, child1.getTranslationY());
+            }
+            if (child2 != null) {
+                assertEquals(0, child2.getLeft());
+                assertEquals(0.0f, child2.getTranslationY());
+            }
+            if (child3 != null) {
+                assertEquals(0, child3.getLeft());
+                assertEquals(0.0f, child3.getTranslationY());
+            }
+            if (child4 != null) {
+                assertEquals(0, child4.getLeft());
+                assertEquals(0.0f, child4.getTranslationY());
+            }
+            if (child5 != null) {
+                assertEquals(0, child5.getLeft());
+                assertEquals(0.0f, child5.getTranslationY());
+            }
+        }
+    }
+
+    private abstract class ViewFetchingRunnable implements Runnable {
+        Map<Integer, View> mIdViewMap = new HashMap();
+    }
+}
diff --git a/wearable/tests/src/android/support/wearable/view/ScrollManagerTest.java b/wearable/tests/src/android/support/wearable/view/ScrollManagerTest.java
new file mode 100644
index 0000000..73b2bd0
--- /dev/null
+++ b/wearable/tests/src/android/support/wearable/view/ScrollManagerTest.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2017 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 android.support.wearable.view;
+
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import static java.lang.Math.cos;
+import static java.lang.Math.sin;
+
+import android.os.SystemClock;
+import android.support.test.filters.MediumTest;
+import android.support.test.rule.ActivityTestRule;
+import android.support.test.runner.AndroidJUnit4;
+import android.support.wearable.view.util.WakeLockRule;
+import android.view.MotionEvent;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@MediumTest
+@RunWith(AndroidJUnit4.class)
+public class ScrollManagerTest {
+    private static final int TEST_WIDTH = 400;
+    private static final int TEST_HEIGHT = 400;
+    private static final int STEP_COUNT = 300;
+
+    private static final int EXPECTED_SCROLLS_FOR_STRAIGHT_GESTURE = 36;
+    private static final int EXPECTED_SCROLLS_FOR_CIRCULAR_GESTURE = 199;
+
+    @Rule
+    public final WakeLockRule wakeLock = new WakeLockRule();
+
+    @Rule
+    public final ActivityTestRule<WearableRecyclerViewTestActivity> mActivityRule =
+            new ActivityTestRule<>(WearableRecyclerViewTestActivity.class, true, true);
+
+    @Mock
+    WearableRecyclerView mMockWearableRecyclerView;
+
+    ScrollManager mScrollManagerUnderTest;
+
+    @Before
+    public void setUp() throws Throwable {
+        MockitoAnnotations.initMocks(this);
+        mScrollManagerUnderTest = new ScrollManager();
+        mScrollManagerUnderTest.setRecyclerView(mMockWearableRecyclerView, TEST_WIDTH, TEST_HEIGHT);
+    }
+
+    @Test
+    public void testStraightUpScrollingGestureLeft() throws Throwable {
+        // Pretend to scroll in a straight line from center left to upper left
+        scroll(mScrollManagerUnderTest, 30, 30, 200, 150);
+        // The scroll manager should require the recycler view to scroll up and only up
+        verify(mMockWearableRecyclerView, times(EXPECTED_SCROLLS_FOR_STRAIGHT_GESTURE))
+                .scrollBy(0, 1);
+    }
+
+    @Test
+    public void testStraightDownScrollingGestureLeft() throws Throwable {
+        // Pretend to scroll in a straight line upper left to center left
+        scroll(mScrollManagerUnderTest, 30, 30, 150, 200);
+        // The scroll manager should require the recycler view to scroll down and only down
+        verify(mMockWearableRecyclerView, times(EXPECTED_SCROLLS_FOR_STRAIGHT_GESTURE))
+                .scrollBy(0, -1);
+    }
+
+    @Test
+    public void testStraightUpScrollingGestureRight() throws Throwable {
+        // Pretend to scroll in a straight line from center right to upper right
+        scroll(mScrollManagerUnderTest, 370, 370, 200, 150);
+        // The scroll manager should require the recycler view to scroll down and only down
+        verify(mMockWearableRecyclerView, times(EXPECTED_SCROLLS_FOR_STRAIGHT_GESTURE))
+                .scrollBy(0, -1);
+    }
+
+    @Test
+    public void testStraightDownScrollingGestureRight() throws Throwable {
+        // Pretend to scroll in a straight line upper right to center right
+        scroll(mScrollManagerUnderTest, 370, 370, 150, 200);
+        // The scroll manager should require the recycler view to scroll up and only up
+        verify(mMockWearableRecyclerView, times(EXPECTED_SCROLLS_FOR_STRAIGHT_GESTURE))
+                .scrollBy(0, 1);
+    }
+
+    @Test
+    public void testCircularScrollingGestureLeft() throws Throwable {
+        // Pretend to scroll in an arch from center left to center right
+        scrollOnArch(mScrollManagerUnderTest, 30, 200, 180.0f);
+        // The scroll manager should never reverse the scroll direction and scroll up
+        verify(mMockWearableRecyclerView, times(EXPECTED_SCROLLS_FOR_CIRCULAR_GESTURE))
+                .scrollBy(0, 1);
+    }
+
+    @Test
+    public void testCircularScrollingGestureRight() throws Throwable {
+        // Pretend to scroll in an arch from center left to center right
+        scrollOnArch(mScrollManagerUnderTest, 370, 200, -180.0f);
+        // The scroll manager should never reverse the scroll direction and scroll down.
+        verify(mMockWearableRecyclerView, times(EXPECTED_SCROLLS_FOR_CIRCULAR_GESTURE))
+                .scrollBy(0, -1);
+    }
+
+    private static void scroll(ScrollManager scrollManager, float fromX, float toX, float fromY,
+            float toY) {
+        long downTime = SystemClock.uptimeMillis();
+        long eventTime = SystemClock.uptimeMillis();
+
+        float y = fromY;
+        float x = fromX;
+
+        float yStep = (toY - fromY) / STEP_COUNT;
+        float xStep = (toX - fromX) / STEP_COUNT;
+
+        MotionEvent event = MotionEvent.obtain(downTime, eventTime,
+                MotionEvent.ACTION_DOWN, x, y, 0);
+        scrollManager.onTouchEvent(event);
+        for (int i = 0; i < STEP_COUNT; ++i) {
+            y += yStep;
+            x += xStep;
+            eventTime = SystemClock.uptimeMillis();
+            event = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_MOVE, x, y, 0);
+            scrollManager.onTouchEvent(event);
+        }
+
+        eventTime = SystemClock.uptimeMillis();
+        event = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, x, y, 0);
+        scrollManager.onTouchEvent(event);
+    }
+
+    private static void scrollOnArch(ScrollManager scrollManager, float fromX, float fromY,
+            float deltaAngle) {
+        long downTime = SystemClock.uptimeMillis();
+        long eventTime = SystemClock.uptimeMillis();
+
+        float stepAngle = deltaAngle / STEP_COUNT;
+        double relativeX = fromX - (TEST_WIDTH / 2);
+        double relativeY = fromY - (TEST_HEIGHT / 2);
+        float radius = (float) Math.sqrt(relativeX * relativeX + relativeY * relativeY);
+        float angle = getAngle(fromX, fromY, TEST_WIDTH, TEST_HEIGHT);
+
+        float y = fromY;
+        float x = fromX;
+
+        MotionEvent event = MotionEvent.obtain(downTime, eventTime,
+                MotionEvent.ACTION_DOWN, x, y, 0);
+        scrollManager.onTouchEvent(event);
+        for (int i = 0; i < STEP_COUNT; ++i) {
+            angle += stepAngle;
+            x = getX(angle, radius, TEST_WIDTH);
+            y = getY(angle, radius, TEST_HEIGHT);
+            eventTime = SystemClock.uptimeMillis();
+            event = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_MOVE, x, y, 0);
+            scrollManager.onTouchEvent(event);
+        }
+
+        eventTime = SystemClock.uptimeMillis();
+        event = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, x, y, 0);
+        scrollManager.onTouchEvent(event);
+    }
+
+    private static float getX(double angle, double radius, double viewWidth) {
+        double radianAngle = Math.toRadians(angle - 90);
+        double relativeX = cos(radianAngle) * radius;
+        return (float) (relativeX + (viewWidth / 2));
+    }
+
+    private static float getY(double angle, double radius, double viewHeight) {
+        double radianAngle = Math.toRadians(angle - 90);
+        double relativeY = sin(radianAngle) * radius;
+        return (float) (relativeY + (viewHeight / 2));
+    }
+
+    private static float getAngle(double x, double y, double viewWidth, double viewHeight) {
+        double relativeX = x - (viewWidth / 2);
+        double relativeY = y - (viewHeight / 2);
+        double rowAngle = Math.atan2(relativeX, relativeY);
+        double angle = -Math.toDegrees(rowAngle) - 180;
+        if (angle < 0) {
+            angle += 360;
+        }
+        return (float) angle;
+    }
+}
diff --git a/wearable/tests/src/android/support/wearable/view/SwipeDismissFrameLayoutTest.java b/wearable/tests/src/android/support/wearable/view/SwipeDismissFrameLayoutTest.java
index 3b0ae41..b595138 100644
--- a/wearable/tests/src/android/support/wearable/view/SwipeDismissFrameLayoutTest.java
+++ b/wearable/tests/src/android/support/wearable/view/SwipeDismissFrameLayoutTest.java
@@ -218,8 +218,7 @@
     @SmallTest
     public void testSwipeDoesNotDismissViewIfStartsInWrongPosition() {
         // GIVEN a freshly setup SwipeDismissFrameLayout with dismiss turned on, but only for an
-      // inner
-        // circle.
+        // inner circle.
         setUpSwipeableRegion();
         // WHEN we perform a swipe to dismiss from the left edge of the screen.
         onView(withId(R.id.swipe_dismiss_root)).perform(swipeRightFromLeftEdge());
@@ -231,8 +230,7 @@
     @SmallTest
     public void testSwipeDoesDismissViewIfStartsInRightPosition() {
         // GIVEN a freshly setup SwipeDismissFrameLayout with dismiss turned on, but only for an
-      // inner
-        // circle.
+        // inner circle.
         setUpSwipeableRegion();
         // WHEN we perform a swipe to dismiss from the center of the screen.
         onView(withId(R.id.swipe_dismiss_root)).perform(swipeRightFromCenter());
diff --git a/wearable/tests/src/android/support/wearable/view/WearableRecyclerViewTest.java b/wearable/tests/src/android/support/wearable/view/WearableRecyclerViewTest.java
new file mode 100644
index 0000000..82a731d
--- /dev/null
+++ b/wearable/tests/src/android/support/wearable/view/WearableRecyclerViewTest.java
@@ -0,0 +1,219 @@
+/*
+ * Copyright (C) 2017 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 android.support.wearable.view;
+
+import static android.support.test.espresso.Espresso.onView;
+import static android.support.test.espresso.matcher.ViewMatchers.withId;
+import static android.support.wearable.view.util.AsyncViewActions.waitForMatchingView;
+import static android.support.wearable.view.util.MoreViewAssertions.withNoVerticalScrollOffset;
+import static android.support.wearable.view.util.MoreViewAssertions.withPositiveVerticalScrollOffset;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertNotNull;
+import static junit.framework.Assert.assertNull;
+import static junit.framework.Assert.assertTrue;
+
+import static org.hamcrest.Matchers.allOf;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.verify;
+
+import android.app.Activity;
+import android.support.annotation.IdRes;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.espresso.ViewAction;
+import android.support.test.espresso.action.GeneralLocation;
+import android.support.test.espresso.action.GeneralSwipeAction;
+import android.support.test.espresso.action.Press;
+import android.support.test.espresso.action.Swipe;
+import android.support.test.filters.MediumTest;
+import android.support.test.rule.ActivityTestRule;
+import android.support.test.runner.AndroidJUnit4;
+import android.support.wearable.test.R;
+import android.support.wearable.view.util.WakeLockRule;
+import android.view.View;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@MediumTest
+@RunWith(AndroidJUnit4.class)
+public class WearableRecyclerViewTest {
+
+    private static final long MAX_WAIT_TIME = 10000;
+    @Mock
+    WearableRecyclerView.OffsettingHelper mMockOffsettingHelper;
+
+    @Rule
+    public final WakeLockRule wakeLock = new WakeLockRule();
+
+    @Rule
+    public final ActivityTestRule<WearableRecyclerViewTestActivity> mActivityRule =
+            new ActivityTestRule<>(WearableRecyclerViewTestActivity.class, true, true);
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+    }
+
+    @Test
+    public void testCaseInitState() {
+        WearableRecyclerView wrv = new WearableRecyclerView(mActivityRule.getActivity());
+
+        assertFalse(wrv.getEdgeItemsCenteringEnabled());
+        assertFalse(wrv.isCircularScrollingGestureEnabled());
+        assertNull(wrv.getOffsettingHelper());
+        assertEquals(1.0f, wrv.getBezelWidthFraction());
+        assertEquals(180.0f, wrv.getScrollDegreesPerScreen());
+    }
+
+    @Test
+    public void testEdgeItemsCenteringOnAndOff() throws Throwable {
+        mActivityRule.runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                WearableRecyclerView wrv =
+                        (WearableRecyclerView) mActivityRule.getActivity().findViewById(R.id.wrv);
+                wrv.setEdgeItemsCenteringEnabled(true);
+            }
+        });
+
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        mActivityRule.runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                WearableRecyclerView wrv =
+                        (WearableRecyclerView) mActivityRule.getActivity().findViewById(R.id.wrv);
+                View child = wrv.getChildAt(0);
+                assertNotNull("child", child);
+                assertEquals((wrv.getHeight() - child.getHeight()) / 2, child.getTop());
+            }
+        });
+
+        mActivityRule.runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                WearableRecyclerView wrv =
+                        (WearableRecyclerView) mActivityRule.getActivity().findViewById(R.id.wrv);
+                wrv.setEdgeItemsCenteringEnabled(false);
+            }
+        });
+
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        mActivityRule.runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                WearableRecyclerView wrv =
+                        (WearableRecyclerView) mActivityRule.getActivity().findViewById(R.id.wrv);
+                View child = wrv.getChildAt(0);
+                assertNotNull("child", child);
+                assertEquals(0, child.getTop());
+
+            }
+        });
+    }
+
+    @Test
+    public void testCircularScrollingGesture() throws Throwable {
+        onView(withId(R.id.wrv)).perform(swipeDownFromTopRight());
+        assertNotScrolledY(R.id.wrv);
+
+        mActivityRule.runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                WearableRecyclerView wrv =
+                        (WearableRecyclerView) mActivityRule.getActivity().findViewById(R.id.wrv);
+                wrv.setCircularScrollingGestureEnabled(true);
+            }
+        });
+
+        onView(withId(R.id.wrv)).perform(swipeDownFromTopRight());
+        assertScrolledY(R.id.wrv);
+    }
+
+    @Test
+    public void testOffsettingHelper() throws Throwable {
+        mActivityRule.runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                WearableRecyclerView wrv =
+                        (WearableRecyclerView) mActivityRule.getActivity().findViewById(R.id.wrv);
+                wrv.setOffsettingHelper(mMockOffsettingHelper);
+            }
+        });
+
+        onView(withId(R.id.wrv)).perform(swipeDownFromTopRight());
+        verify(mMockOffsettingHelper, atLeast(1)).updateChild(any(View.class),
+                any(WearableRecyclerView.class));
+
+    }
+
+    @Test
+    public void testCurvedOffsettingHelper() throws Throwable {
+        mActivityRule.runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                WearableRecyclerView wrv =
+                        (WearableRecyclerView) mActivityRule.getActivity().findViewById(R.id.wrv);
+                wrv.setOffsettingHelper(new CurvedOffsettingHelper());
+            }
+        });
+
+        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+        onView(withId(R.id.wrv)).perform(swipeDownFromTopRight());
+
+        mActivityRule.runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                Activity activity = mActivityRule.getActivity();
+                WearableRecyclerView wrv = (WearableRecyclerView) activity.findViewById(R.id.wrv);
+                if (activity.getResources().getConfiguration().isScreenRound()) {
+                    View child = wrv.getChildAt(0);
+                    assertTrue(child.getLeft() > 0);
+                } else {
+                    for (int i = 0; i < wrv.getChildCount(); i++) {
+                        assertEquals(0, wrv.getChildAt(i).getLeft());
+                    }
+                }
+            }
+        });
+    }
+
+    private static ViewAction swipeDownFromTopRight() {
+        return new GeneralSwipeAction(
+                Swipe.FAST, GeneralLocation.TOP_RIGHT, GeneralLocation.BOTTOM_RIGHT,
+                Press.FINGER);
+    }
+
+    private void assertScrolledY(@IdRes int layoutId) {
+        onView(withId(layoutId)).perform(waitForMatchingView(
+                allOf(withId(layoutId), withPositiveVerticalScrollOffset()), MAX_WAIT_TIME));
+    }
+
+    private void assertNotScrolledY(@IdRes int layoutId) {
+        onView(withId(layoutId)).perform(waitForMatchingView(
+                allOf(withId(layoutId), withNoVerticalScrollOffset()), MAX_WAIT_TIME));
+    }
+}
diff --git a/wearable/tests/src/android/support/wearable/view/WearableRecyclerViewTestActivity.java b/wearable/tests/src/android/support/wearable/view/WearableRecyclerViewTestActivity.java
new file mode 100644
index 0000000..1c4c8be
--- /dev/null
+++ b/wearable/tests/src/android/support/wearable/view/WearableRecyclerViewTestActivity.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2017 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 android.support.wearable.view;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.support.v7.widget.RecyclerView;
+import android.support.wearable.test.R;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+public class WearableRecyclerViewTestActivity extends Activity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.wearable_recycler_view_basic);
+        WearableRecyclerView wrv =
+                (WearableRecyclerView) findViewById(android.support.wearable.test.R.id.wrv);
+        wrv.setAdapter(new TestAdapter());
+    }
+
+    private class ViewHolder extends RecyclerView.ViewHolder {
+        TextView mView;
+        ViewHolder(TextView itemView) {
+            super(itemView);
+            mView = itemView;
+        }
+    }
+
+    private class TestAdapter extends WearableRecyclerView.Adapter<ViewHolder> {
+
+        @Override
+        public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+            TextView view = new TextView(parent.getContext());
+            return new ViewHolder(view);
+        }
+
+        @Override
+        public void onBindViewHolder(ViewHolder holder, int position) {
+            holder.mView.setText("holder at position " + position);
+            holder.mView.setTag(position);
+        }
+
+        @Override
+        public int getItemCount() {
+            return 100;
+        }
+    }
+}
diff --git a/wearable/tests/src/android/support/wearable/view/util/MoreViewAssertions.java b/wearable/tests/src/android/support/wearable/view/util/MoreViewAssertions.java
index 929593a..79586ea 100644
--- a/wearable/tests/src/android/support/wearable/view/util/MoreViewAssertions.java
+++ b/wearable/tests/src/android/support/wearable/view/util/MoreViewAssertions.java
@@ -21,7 +21,7 @@
 import android.support.test.espresso.NoMatchingViewException;
 import android.support.test.espresso.ViewAssertion;
 import android.support.test.espresso.util.HumanReadables;
-import android.util.Log;
+import android.support.wearable.view.WearableRecyclerView;
 import android.view.View;
 
 import org.hamcrest.Description;
@@ -30,13 +30,10 @@
 
 public class MoreViewAssertions {
 
-    public static final String TAG = "bilt";
-
     public static ViewAssertion left(final Matcher<Integer> matcher) {
         return new ViewAssertion() {
             @Override
             public void check(View view, NoMatchingViewException noViewException) {
-                Log.d(TAG, "l: " + view.getLeft());
                 assertThat("View left: " + HumanReadables.describe(view), view.getLeft(), matcher);
             }
         };
@@ -46,7 +43,6 @@
         return new ViewAssertion() {
             @Override
             public void check(View view, NoMatchingViewException noViewException) {
-                Log.d(TAG, "t: " + view.getPaddingTop());
                 assertThat("View top: " + HumanReadables.describe(view), ((double) view.getTop()),
                         matcher);
             }
@@ -57,7 +53,6 @@
         return new ViewAssertion() {
             @Override
             public void check(View view, NoMatchingViewException noViewException) {
-                Log.d(TAG, "t: " + view.getPaddingTop());
                 assertThat("View top: " + HumanReadables.describe(view), view.getTop(), matcher);
             }
         };
@@ -67,7 +62,6 @@
         return new ViewAssertion() {
             @Override
             public void check(View view, NoMatchingViewException noViewException) {
-                Log.d(TAG, "r: " + view.getPaddingRight());
                 assertThat("View right: " + HumanReadables.describe(view), view.getRight(),
                         matcher);
             }
@@ -78,7 +72,6 @@
         return new ViewAssertion() {
             @Override
             public void check(View view, NoMatchingViewException noViewException) {
-                Log.d(TAG, "b: " + view.getBottom());
                 assertThat("View bottom: " + HumanReadables.describe(view), view.getBottom(),
                         matcher);
             }
@@ -89,7 +82,6 @@
         return new ViewAssertion() {
             @Override
             public void check(View view, NoMatchingViewException noViewException) {
-                Log.d(TAG, "b: " + view.getBottom());
                 assertThat("View bottom: " + HumanReadables.describe(view), ((double) view
                         .getBottom()), matcher);
             }
@@ -184,4 +176,32 @@
             }
         };
     }
+
+    public static Matcher<WearableRecyclerView> withPositiveVerticalScrollOffset() {
+        return new TypeSafeMatcher<WearableRecyclerView>() {
+            @Override
+            public void describeTo(Description description) {
+                description.appendText("with positive y scroll offset");
+            }
+
+            @Override
+            public boolean matchesSafely(WearableRecyclerView view) {
+                return view.computeVerticalScrollOffset() > 0;
+            }
+        };
+    }
+
+    public static Matcher<WearableRecyclerView> withNoVerticalScrollOffset() {
+        return new TypeSafeMatcher<WearableRecyclerView>() {
+            @Override
+            public void describeTo(Description description) {
+                description.appendText("with no y scroll offset");
+            }
+
+            @Override
+            public boolean matchesSafely(WearableRecyclerView view) {
+                return view.computeVerticalScrollOffset() == 0;
+            }
+        };
+    }
 }