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;
+ }
+ };
+ }
}