diff options
Diffstat (limited to 'src')
9 files changed, 2772 insertions, 0 deletions
diff --git a/src/android/support/wearable/view/CircledImageView.java b/src/android/support/wearable/view/CircledImageView.java new file mode 100644 index 00000000..132f6b5c --- /dev/null +++ b/src/android/support/wearable/view/CircledImageView.java @@ -0,0 +1,601 @@ +/* + * 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. + */ + +package android.support.wearable.view; + +import android.animation.ArgbEvaluator; +import android.animation.ValueAnimator; +import android.animation.ValueAnimator.AnimatorUpdateListener; +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.graphics.RadialGradient; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.support.wearable.R; +import android.util.AttributeSet; +import android.view.View; + +import java.util.Objects; + +/** + * An image view surrounded by a circle. + */ +@TargetApi(Build.VERSION_CODES.LOLLIPOP) +public class CircledImageView extends View { + + private static final ArgbEvaluator ARGB_EVALUATOR = new ArgbEvaluator(); + + private Drawable mDrawable; + + private final RectF mOval; + private final Paint mPaint; + + private ColorStateList mCircleColor; + + private float mCircleRadius; + private float mCircleRadiusPercent; + + private float mCircleRadiusPressed; + private float mCircleRadiusPressedPercent; + + private float mRadiusInset; + + private int mCircleBorderColor; + + private float mCircleBorderWidth; + private float mProgress = 1f; + private final float mShadowWidth; + + private float mShadowVisibility; + private boolean mCircleHidden = false; + + private float mInitialCircleRadius; + + private boolean mPressed = false; + + private boolean mProgressIndeterminate; + private ProgressDrawable mIndeterminateDrawable; + private Rect mIndeterminateBounds = new Rect(); + private long mColorChangeAnimationDurationMs = 0; + + private float mImageCirclePercentage = 1f; + private float mImageHorizontalOffcenterPercentage = 0f; + private Integer mImageTint; + + private final Drawable.Callback mDrawableCallback = new Drawable.Callback() { + @Override + public void invalidateDrawable(Drawable drawable) { + invalidate(); + } + + @Override + public void scheduleDrawable(Drawable drawable, Runnable runnable, long l) { + // Not needed. + } + + @Override + public void unscheduleDrawable(Drawable drawable, Runnable runnable) { + // Not needed. + } + }; + + private int mCurrentColor; + + private final AnimatorUpdateListener mAnimationListener = new AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + int color = (int) animation.getAnimatedValue(); + if (color != CircledImageView.this.mCurrentColor) { + CircledImageView.this.mCurrentColor = color; + CircledImageView.this.invalidate(); + } + } + }; + + private ValueAnimator mColorAnimator; + + public CircledImageView(Context context) { + this(context, null); + } + + public CircledImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public CircledImageView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.CircledImageView); + mDrawable = a.getDrawable(R.styleable.CircledImageView_android_src); + + mCircleColor = a.getColorStateList(R.styleable.CircledImageView_circle_color); + if (mCircleColor == null) { + mCircleColor = ColorStateList.valueOf(android.R.color.darker_gray); + } + + mCircleRadius = a.getDimension( + R.styleable.CircledImageView_circle_radius, 0); + mInitialCircleRadius = mCircleRadius; + mCircleRadiusPressed = a.getDimension( + R.styleable.CircledImageView_circle_radius_pressed, mCircleRadius); + mCircleBorderColor = a.getColor( + R.styleable.CircledImageView_circle_border_color, Color.BLACK); + mCircleBorderWidth = a.getDimension(R.styleable.CircledImageView_circle_border_width, 0); + + if (mCircleBorderWidth > 0) { + mRadiusInset += mCircleBorderWidth; + } + + float circlePadding = a.getDimension(R.styleable.CircledImageView_circle_padding, 0); + if (circlePadding > 0) { + mRadiusInset += circlePadding; + } + mShadowWidth = a.getDimension(R.styleable.CircledImageView_shadow_width, 0); + + mImageCirclePercentage = a.getFloat( + R.styleable.CircledImageView_image_circle_percentage, 0f); + + mImageHorizontalOffcenterPercentage = a.getFloat( + R.styleable.CircledImageView_image_horizontal_offcenter_percentage, 0f); + + if (a.hasValue(R.styleable.CircledImageView_image_tint)) { + mImageTint = a.getColor(R.styleable.CircledImageView_image_tint, 0); + } + + mCircleRadiusPercent = a.getFraction(R.styleable.CircledImageView_circle_radius_percent, + 1, 1, 0f); + + mCircleRadiusPressedPercent = a.getFraction( + R.styleable.CircledImageView_circle_radius_pressed_percent, 1, 1, + mCircleRadiusPercent); + + a.recycle(); + + mOval = new RectF(); + mPaint = new Paint(); + mPaint.setAntiAlias(true); + + mIndeterminateDrawable = new ProgressDrawable(); + // {@link #mDrawableCallback} must be retained as a member, as Drawable callback + // is held by weak reference, we must retain it for it to continue to be called. + mIndeterminateDrawable.setCallback(mDrawableCallback); + + setWillNotDraw(false); + + setColorForCurrentState(); + } + + public void setCircleHidden(boolean circleHidden) { + if (circleHidden != mCircleHidden) { + mCircleHidden = circleHidden; + invalidate(); + } + } + + + @Override + protected boolean onSetAlpha(int alpha) { + return true; + } + + @Override + protected void onDraw(Canvas canvas) { + int paddingLeft = getPaddingLeft(); + int paddingTop = getPaddingTop(); + + + float circleRadius = mPressed ? getCircleRadiusPressed() : getCircleRadius(); + if (mShadowWidth > 0 && mShadowVisibility > 0) { + // First let's find the center of the view. + mOval.set(paddingLeft, paddingTop, getWidth() - getPaddingRight(), + getHeight() - getPaddingBottom()); + // Having the center, lets make the shadow start beyond the circled and possibly the + // border. + final float radius = circleRadius + mCircleBorderWidth + + mShadowWidth * mShadowVisibility; + mPaint.setColor(Color.BLACK); + mPaint.setAlpha(Math.round(mPaint.getAlpha() * getAlpha())); + mPaint.setStyle(Style.FILL); + // TODO: precalc and pre-allocate this + mPaint.setShader(new RadialGradient(mOval.centerX(), mOval.centerY(), radius, + new int[]{Color.BLACK, Color.TRANSPARENT}, new float[]{0.6f, 1f}, + Shader.TileMode.MIRROR)); + canvas.drawCircle(mOval.centerX(), mOval.centerY(), radius, mPaint); + mPaint.setShader(null); + } + if (mCircleBorderWidth > 0) { + // First let's find the center of the view. + mOval.set(paddingLeft, paddingTop, getWidth() - getPaddingRight(), + getHeight() - getPaddingBottom()); + // Having the center, lets make the border meet the circle. + mOval.set(mOval.centerX() - circleRadius, mOval.centerY() - circleRadius, + mOval.centerX() + circleRadius, mOval.centerY() + circleRadius); + mPaint.setColor(mCircleBorderColor); + // {@link #Paint.setAlpha} is a helper method that just sets the alpha portion of the + // color. {@link #Paint.setPaint} will clear any previously set alpha value. + mPaint.setAlpha(Math.round(mPaint.getAlpha() * getAlpha())); + mPaint.setStyle(Style.STROKE); + mPaint.setStrokeWidth(mCircleBorderWidth); + + if (mProgressIndeterminate) { + mOval.roundOut(mIndeterminateBounds); + mIndeterminateDrawable.setBounds(mIndeterminateBounds); + mIndeterminateDrawable.setRingColor(mCircleBorderColor); + mIndeterminateDrawable.setRingWidth(mCircleBorderWidth); + mIndeterminateDrawable.draw(canvas); + } else { + canvas.drawArc(mOval, -90, 360 * mProgress, false, mPaint); + } + } + if (!mCircleHidden) { + mOval.set(paddingLeft, paddingTop, getWidth() - getPaddingRight(), + getHeight() - getPaddingBottom()); + // {@link #Paint.setAlpha} is a helper method that just sets the alpha portion of the + // color. {@link #Paint.setPaint} will clear any previously set alpha value. + mPaint.setColor(mCurrentColor); + mPaint.setAlpha(Math.round(mPaint.getAlpha() * getAlpha())); + + mPaint.setStyle(Style.FILL); + float centerX = mOval.centerX(); + float centerY = mOval.centerY(); + + canvas.drawCircle(centerX, centerY, circleRadius, mPaint); + } + + if (mDrawable != null) { + mDrawable.setAlpha(Math.round(getAlpha() * 255)); + + if (mImageTint != null) { + mDrawable.setTint(mImageTint); + } + mDrawable.draw(canvas); + } + + super.onDraw(canvas); + } + + private void setColorForCurrentState() { + int newColor = mCircleColor.getColorForState(getDrawableState(), + mCircleColor.getDefaultColor()); + if (mColorChangeAnimationDurationMs > 0) { + if (mColorAnimator != null) { + mColorAnimator.cancel(); + } else { + mColorAnimator = new ValueAnimator(); + } + mColorAnimator.setIntValues(new int[] { + mCurrentColor, newColor }); + mColorAnimator.setEvaluator(ARGB_EVALUATOR); + mColorAnimator.setDuration(mColorChangeAnimationDurationMs); + mColorAnimator.addUpdateListener(this.mAnimationListener); + mColorAnimator.start(); + } else { + if (newColor != mCurrentColor) { + mCurrentColor = newColor; + invalidate(); + } + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + + final float radius = getCircleRadius() + mCircleBorderWidth + + mShadowWidth * mShadowVisibility; + float desiredWidth = radius * 2; + float desiredHeight = radius * 2; + + int widthMode = MeasureSpec.getMode(widthMeasureSpec); + int widthSize = MeasureSpec.getSize(widthMeasureSpec); + int heightMode = MeasureSpec.getMode(heightMeasureSpec); + int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + int width; + int height; + + if (widthMode == MeasureSpec.EXACTLY) { + width = widthSize; + } else if (widthMode == MeasureSpec.AT_MOST) { + width = (int) Math.min(desiredWidth, widthSize); + } else { + width = (int) desiredWidth; + } + + if (heightMode == MeasureSpec.EXACTLY) { + height = heightSize; + } else if (heightMode == MeasureSpec.AT_MOST) { + height = (int) Math.min(desiredHeight, heightSize); + } else { + height = (int) desiredHeight; + } + + super.onMeasure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + if (mDrawable != null) { + // Retrieve the sizes of the drawable and the view. + final int nativeDrawableWidth = mDrawable.getIntrinsicWidth(); + final int nativeDrawableHeight = mDrawable.getIntrinsicHeight(); + final int viewWidth = getMeasuredWidth(); + final int viewHeight = getMeasuredHeight(); + final float imageCirclePercentage = mImageCirclePercentage > 0 + ? mImageCirclePercentage : 1; + + final float scaleFactor = Math.min(1f, + Math.min( + (float) nativeDrawableWidth != 0 + ? imageCirclePercentage * viewWidth / nativeDrawableWidth : 1, + (float) nativeDrawableHeight != 0 + ? imageCirclePercentage + * viewHeight / nativeDrawableHeight : 1)); + + // Scale the drawable down to fit the view, if needed. + final int drawableWidth = Math.round(scaleFactor * nativeDrawableWidth); + final int drawableHeight = Math.round(scaleFactor * nativeDrawableHeight); + + // Center the drawable within the view. + final int drawableLeft = (viewWidth - drawableWidth) / 2 + + Math.round(mImageHorizontalOffcenterPercentage * drawableWidth); + final int drawableTop = (viewHeight - drawableHeight) / 2; + + mDrawable.setBounds(drawableLeft, drawableTop, drawableLeft + drawableWidth, + drawableTop + drawableHeight); + } + + super.onLayout(changed, left, top, right, bottom); + } + + public void setImageDrawable(Drawable drawable) { + if (drawable != mDrawable) { + final Drawable existingDrawable = mDrawable; + mDrawable = drawable; + + final boolean skipLayout = drawable != null + && existingDrawable != null + && existingDrawable.getIntrinsicHeight() == drawable.getIntrinsicHeight() + && existingDrawable.getIntrinsicWidth() == drawable.getIntrinsicWidth(); + + if (skipLayout) { + mDrawable.setBounds(existingDrawable.getBounds()); + } else { + requestLayout(); + } + + invalidate(); + } + } + + public void setImageResource(int resId) { + setImageDrawable(resId == 0 ? null : getContext().getDrawable(resId)); + } + + public void setImageCirclePercentage(float percentage) { + float clamped = Math.max(0, Math.min(1, percentage)); + if (clamped != mImageCirclePercentage) { + mImageCirclePercentage = clamped; + invalidate(); + } + } + + public void setImageHorizontalOffcenterPercentage(float percentage) { + if (percentage != mImageHorizontalOffcenterPercentage) { + mImageHorizontalOffcenterPercentage = percentage; + invalidate(); + } + } + + public void setImageTint(int tint) { + if (tint != mImageTint) { + mImageTint = tint; + invalidate(); + } + } + + public float getCircleRadius() { + float radius = mCircleRadius; + if (mCircleRadius <= 0 && mCircleRadiusPercent > 0) { + radius = Math.max(getMeasuredHeight(), getMeasuredWidth()) * mCircleRadiusPercent; + } + + return radius - mRadiusInset; + } + + public float getCircleRadiusPercent() { + return mCircleRadiusPercent; + } + + public float getCircleRadiusPressed() { + float radius = mCircleRadiusPressed; + + if (mCircleRadiusPressed <= 0 && mCircleRadiusPressedPercent > 0) { + radius = Math.max(getMeasuredHeight(), getMeasuredWidth()) + * mCircleRadiusPressedPercent; + } + + return radius - mRadiusInset; + } + + public float getCircleRadiusPressedPercent() { + return mCircleRadiusPressedPercent; + } + + public void setCircleRadius(float circleRadius) { + if (circleRadius != mCircleRadius) { + mCircleRadius = circleRadius; + invalidate(); + } + } + + /** + * Sets the radius of the circle to be a percentage of the largest dimension of the view. + * @param circleRadiusPercent A {@code float} from 0 to 1 representing the radius percentage. + */ + public void setCircleRadiusPercent(float circleRadiusPercent) { + if (circleRadiusPercent != mCircleRadiusPercent) { + mCircleRadiusPercent = circleRadiusPercent; + invalidate(); + } + } + + public void setCircleRadiusPressed(float circleRadiusPressed) { + if (circleRadiusPressed != mCircleRadiusPressed) { + mCircleRadiusPressed = circleRadiusPressed; + invalidate(); + } + } + + /** + * Sets the radius of the circle to be a percentage of the largest dimension of the view when + * pressed. + * @param circleRadiusPressedPercent A {@code float} from 0 to 1 representing the radius + * percentage. + */ + public void setCircleRadiusPressedPercent(float circleRadiusPressedPercent) { + if (circleRadiusPressedPercent != mCircleRadiusPressedPercent) { + mCircleRadiusPressedPercent = circleRadiusPressedPercent; + invalidate(); + } + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + setColorForCurrentState(); + } + + public void setCircleColor(int circleColor) { + setCircleColorStateList(ColorStateList.valueOf(circleColor)); + } + + public void setCircleColorStateList(ColorStateList circleColor) { + if (!Objects.equals(circleColor, mCircleColor)) { + mCircleColor = circleColor; + setColorForCurrentState(); + invalidate(); + } + } + + public ColorStateList getCircleColorStateList() { + return mCircleColor; + } + + public int getDefaultCircleColor() { + return mCircleColor.getDefaultColor(); + } + + /** + * Show the circle border as an indeterminate progress spinner. + * The views circle border width and color must be set for this to have an effect. + * + * @param show true if the progress spinner is shown, false to hide it. + */ + public void showIndeterminateProgress(boolean show) { + mProgressIndeterminate = show; + if (show) { + mIndeterminateDrawable.startAnimation(); + } else { + mIndeterminateDrawable.stopAnimation(); + } + } + + @Override + protected void onVisibilityChanged(View changedView, int visibility) { + super.onVisibilityChanged(changedView, visibility); + if (visibility != View.VISIBLE) { + showIndeterminateProgress(false); + } else if (mProgressIndeterminate) { + showIndeterminateProgress(true); + } + } + + public void setProgress(float progress) { + if (progress != mProgress) { + mProgress = progress; + invalidate(); + } + } + + /** + * Set how much of the shadow should be shown. + * @param shadowVisibility Value between 0 and 1. + */ + public void setShadowVisibility(float shadowVisibility) { + if (shadowVisibility != mShadowVisibility) { + mShadowVisibility = shadowVisibility; + invalidate(); + } + } + + public float getInitialCircleRadius() { + return mInitialCircleRadius; + } + + public void setCircleBorderColor(int circleBorderColor) { + mCircleBorderColor = circleBorderColor; + } + + /** + * Set the border around the circle. + * @param circleBorderWidth Width of the border around the circle. + */ + public void setCircleBorderWidth(float circleBorderWidth) { + if (circleBorderWidth != mCircleBorderWidth) { + mCircleBorderWidth = circleBorderWidth; + invalidate(); + } + } + + @Override + public void setPressed(boolean pressed) { + super.setPressed(pressed); + if (pressed != mPressed) { + mPressed = pressed; + invalidate(); + } + } + + public Drawable getImageDrawable() { + return mDrawable; + } + + /** + * @return the milliseconds duration of the transition animation when the color changes. + */ + public long getColorChangeAnimationDuration() { + return mColorChangeAnimationDurationMs; + } + + /** + * @param mColorChangeAnimationDurationMs the milliseconds duration of the color change + * animation. The color change animation will run if the color changes with {@link #setCircleColor} + * or as a result of the active state changing. + */ + public void setColorChangeAnimationDuration(long mColorChangeAnimationDurationMs) { + this.mColorChangeAnimationDurationMs = mColorChangeAnimationDurationMs; + } +} diff --git a/src/android/support/wearable/view/Gusterpolator.java b/src/android/support/wearable/view/Gusterpolator.java new file mode 100644 index 00000000..dc85bcb5 --- /dev/null +++ b/src/android/support/wearable/view/Gusterpolator.java @@ -0,0 +1,84 @@ +/* + * 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 + */ + +package android.support.wearable.view; + +import android.animation.TimeInterpolator; +import android.annotation.TargetApi; +import android.os.Build; + +/** + * Interpolator that uses a Bezier derived S shaped curve. + * @hide + */ +@TargetApi(Build.VERSION_CODES.KITKAT_WATCH) +class Gusterpolator implements TimeInterpolator { + + /** An instance of {@link android.support.wearable.view.Gusterpolator}. */ + public static final Gusterpolator INSTANCE = new Gusterpolator(); + + /** + * To avoid users of this class creating multiple copies needlessly, the constructor is + * private. + */ + private Gusterpolator() {} + + /** + * Lookup table values. + * Generated using a Bezier curve from (0,0) to (1,1) with control points: + * P0 (0,0) + * P1 (0.4, 0) + * P2 (0.2, 1.0) + * P3 (1.0, 1.0) + * + * Values sampled with x at regular intervals between 0 and 1. + */ + private static final float[] VALUES = new float[] { + 0.0f, 0.0002f, 0.0009f, 0.0019f, 0.0036f, 0.0059f, 0.0086f, 0.0119f, 0.0157f, 0.0209f, + 0.0257f, 0.0321f, 0.0392f, 0.0469f, 0.0566f, 0.0656f, 0.0768f, 0.0887f, 0.1033f, 0.1186f, + 0.1349f, 0.1519f, 0.1696f, 0.1928f, 0.2121f, 0.237f, 0.2627f, 0.2892f, 0.3109f, 0.3386f, + 0.3667f, 0.3952f, 0.4241f, 0.4474f, 0.4766f, 0.5f, 0.5234f, 0.5468f, 0.5701f, 0.5933f, + 0.6134f, 0.6333f, 0.6531f, 0.6698f, 0.6891f, 0.7054f, 0.7214f, 0.7346f, 0.7502f, 0.763f, + 0.7756f, 0.7879f, 0.8f, 0.8107f, 0.8212f, 0.8326f, 0.8415f, 0.8503f, 0.8588f, 0.8672f, + 0.8754f, 0.8833f, 0.8911f, 0.8977f, 0.9041f, 0.9113f, 0.9165f, 0.9232f, 0.9281f, 0.9328f, + 0.9382f, 0.9434f, 0.9476f, 0.9518f, 0.9557f, 0.9596f, 0.9632f, 0.9662f, 0.9695f, 0.9722f, + 0.9753f, 0.9777f, 0.9805f, 0.9826f, 0.9847f, 0.9866f, 0.9884f, 0.9901f, 0.9917f, 0.9931f, + 0.9944f, 0.9955f, 0.9964f, 0.9973f, 0.9981f, 0.9986f, 0.9992f, 0.9995f, 0.9998f, 1.0f, 1.0f + }; + + private static final float STEP_SIZE = 1.0f / (VALUES.length - 1); + + @Override + public float getInterpolation(float input) { + if (input >= 1.0f) { + return 1.0f; + } + + if (input <= 0f) { + return 0f; + } + + int position = Math.min( + (int)(input * (VALUES.length - 1)), + VALUES.length - 2); + + float quantized = position * STEP_SIZE; + float difference = input - quantized; + float weight = difference / STEP_SIZE; + + return VALUES[position] + weight * (VALUES[position + 1] - VALUES[position]); + } +} diff --git a/src/android/support/wearable/view/ProgressDrawable.java b/src/android/support/wearable/view/ProgressDrawable.java new file mode 100644 index 00000000..63e6a039 --- /dev/null +++ b/src/android/support/wearable/view/ProgressDrawable.java @@ -0,0 +1,176 @@ +/* + * 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 + */ + +package android.support.wearable.view; + +import android.animation.ObjectAnimator; +import android.animation.TimeInterpolator; +import android.animation.ValueAnimator; +import android.annotation.TargetApi; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.util.Property; +import android.view.animation.LinearInterpolator; + +/** + * Drawable for showing an indeterminate progress indicator. + * + * TODO: When Material progress drawable is available in the support library stop using this. + * + * @hide + */ +@TargetApi(Build.VERSION_CODES.KITKAT_WATCH) +class ProgressDrawable extends Drawable { + + private static Property<ProgressDrawable, Integer> LEVEL = + new Property<ProgressDrawable, Integer>(Integer.class, "level") { + @Override + public Integer get(ProgressDrawable drawable) { + return drawable.getLevel(); + } + + @Override + public void set(ProgressDrawable drawable, Integer value) { + drawable.setLevel(value); + drawable.invalidateSelf(); + } + }; + /** Max level for a level drawable, as specified in developer docs for {@link Drawable}. */ + private static final int MAX_LEVEL = 10000; + + /** How many different sections are there, five gives us the material style star. **/ + private static final int NUMBER_OF_SEGMENTS = 5; + + private static final int LEVELS_PER_SEGMENT = MAX_LEVEL / NUMBER_OF_SEGMENTS; + private static final float STARTING_ANGLE = -90f; + private static final long ANIMATION_DURATION = 6000; + private static final int FULL_CIRCLE = 360; + private static final int MAX_SWEEP = 306; + private static final int CORRECTION_ANGLE = FULL_CIRCLE - MAX_SWEEP; + /** How far through each cycle does the bar stop growing and start shrinking, half way. **/ + private static final float GROW_SHRINK_RATIO = 0.5f; + // TODO: replace this with BakedBezierInterpolator when its available in support library. + private static final TimeInterpolator mInterpolator = Gusterpolator.INSTANCE; + + private final RectF mInnerCircleBounds = new RectF(); + private final Paint mPaint = new Paint(); + private final ObjectAnimator mAnimator; + private float mCircleBorderWidth; + private int mCircleBorderColor; + + public ProgressDrawable() { + mPaint.setAntiAlias(true); + mPaint.setStyle(Paint.Style.STROKE); + mAnimator = ObjectAnimator.ofInt(this, LEVEL, 0, MAX_LEVEL); + mAnimator.setRepeatCount(ValueAnimator.INFINITE); + mAnimator.setRepeatMode(ValueAnimator.RESTART); + mAnimator.setDuration(ANIMATION_DURATION); + mAnimator.setInterpolator(new LinearInterpolator()); + } + + public void setRingColor(int color) { + mCircleBorderColor = color; + } + + public void setRingWidth(float width) { + mCircleBorderWidth = width; + } + + public void startAnimation() { + mAnimator.start(); + } + + public void stopAnimation() { + mAnimator.cancel(); + } + + @Override + public void draw(Canvas canvas) { + canvas.save(); + mInnerCircleBounds.set(getBounds()); + mInnerCircleBounds.inset(mCircleBorderWidth / 2.0f, mCircleBorderWidth / 2.0f); + mPaint.setStrokeWidth(mCircleBorderWidth); + mPaint.setColor(mCircleBorderColor); + + float sweepAngle = FULL_CIRCLE; + boolean growing = false; + float correctionAngle = 0; + int level = getLevel(); + + int currentSegment = level / LEVELS_PER_SEGMENT; + int offset = currentSegment * LEVELS_PER_SEGMENT; + float progress = (level - offset) / (float) LEVELS_PER_SEGMENT; + + growing = progress < GROW_SHRINK_RATIO; + correctionAngle = CORRECTION_ANGLE * progress; + + if (growing) { + sweepAngle = MAX_SWEEP * mInterpolator.getInterpolation( + lerpInv(0f, GROW_SHRINK_RATIO, progress)); + } else { + sweepAngle = MAX_SWEEP * (1.0f - mInterpolator.getInterpolation( + lerpInv(GROW_SHRINK_RATIO, 1.0f, progress))); + } + + sweepAngle = Math.max(1, sweepAngle); + + canvas.rotate( + level * (1.0f / MAX_LEVEL) * 2 * FULL_CIRCLE + STARTING_ANGLE + correctionAngle, + mInnerCircleBounds.centerX(), + mInnerCircleBounds.centerY()); + canvas.drawArc(mInnerCircleBounds, + growing ? 0 : MAX_SWEEP - sweepAngle, + sweepAngle, + false, + mPaint); + canvas.restore(); + } + + @Override + public void setAlpha(int i) { + // Not supported. + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + // Not supported. + } + + @Override + public int getOpacity() { + return PixelFormat.OPAQUE; + } + + @Override + protected boolean onLevelChange(int level) { + return true; // Changing the level of this drawable does change its appearance. + } + + /** + * Returns the interpolation scalar (s) that satisfies the equation: + * {@code value = }lerp(a, b, s) + * + * <p>If {@code a == b}, then this function will return 0. + */ + private static float lerpInv(float a, float b, float value) { + return a != b ? ((value - a) / (b - a)) : 0.0f; + } +} diff --git a/src/android/support/wearable/view/SimpleAnimatorListener.java b/src/android/support/wearable/view/SimpleAnimatorListener.java new file mode 100644 index 00000000..13631a2a --- /dev/null +++ b/src/android/support/wearable/view/SimpleAnimatorListener.java @@ -0,0 +1,67 @@ +/* + * 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 + */ + +package android.support.wearable.view; + +import android.animation.Animator; +import android.annotation.TargetApi; +import android.os.Build; + +/** + * Convenience class for listening for Animator events that implements the AnimatorListener + * interface and allows extending only methods that are necessary. + */ +@TargetApi(Build.VERSION_CODES.KITKAT_WATCH) +public class SimpleAnimatorListener implements Animator.AnimatorListener { + + private boolean mWasCanceled; + + @Override + public void onAnimationCancel(Animator animator) { + mWasCanceled = true; + } + + @Override + public void onAnimationEnd(Animator animator) { + if (!mWasCanceled) { + onAnimationComplete(animator); + } + } + + @Override + public void onAnimationRepeat(Animator animator) { + } + + @Override + public void onAnimationStart(Animator animator) { + mWasCanceled = false; + } + + /** + * Called when the animation finishes. Not called if the animation was canceled. + */ + public void onAnimationComplete(Animator animator) { + } + + /** + * Provides information if the animation was cancelled. + * @return True if animation was cancelled. + */ + public boolean wasCanceled() { + return mWasCanceled; + } + +} diff --git a/src/android/support/wearable/view/WearableListView.java b/src/android/support/wearable/view/WearableListView.java new file mode 100644 index 00000000..01baa98b --- /dev/null +++ b/src/android/support/wearable/view/WearableListView.java @@ -0,0 +1,1387 @@ +/* + * 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 + */ + +package android.support.wearable.view; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.PointF; +import android.os.Build; +import android.os.Handler; +import android.support.v7.widget.LinearSmoothScroller; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.Property; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.widget.Scroller; + +import java.util.ArrayList; +import java.util.List; + +/** + * An alternative version of ListView that is optimized for ease of use on small screen wearable + * devices. It displays a vertically scrollable list of items, and automatically snaps to the + * nearest item when the user stops scrolling. + * + * <p> + * For a quick start, you will need to implement a subclass of {@link .Adapter}, + * which will create and bind your views to the {@link .ViewHolder} objects. If you want to add + * more visual treatment to your views when they become the central items of the + * WearableListView, have them implement the {@link .OnCenterProximityListener} interface. + * </p> + */ +@TargetApi(Build.VERSION_CODES.KITKAT_WATCH) +public class WearableListView extends RecyclerView { + @SuppressWarnings("unused") + private static final String TAG = "WearableListView"; + + private static final long FLIP_ANIMATION_DURATION_MS = 150; + private static final long CENTERING_ANIMATION_DURATION_MS = 150; + + private static final float TOP_TAP_REGION_PERCENTAGE = .33f; + private static final float BOTTOM_TAP_REGION_PERCENTAGE = .33f; + + // Each item will occupy one third of the height. + private static final int THIRD = 3; + + private final int mMinFlingVelocity; + private final int mMaxFlingVelocity; + + private boolean mMaximizeSingleItem; + private boolean mCanClick = true; + // WristGesture navigation signals are delivered as KeyEvents. Allow developer to disable them + // for this specific View. It might be cleaner to simply have users re-implement onKeyDown(). + // TOOD: Finalize the disabling mechanism here. + private boolean mGestureNavigationEnabled = true; + private int mTapPositionX; + private int mTapPositionY; + private ClickListener mClickListener; + + private Animator mScrollAnimator; + // This is a little hacky due to the fact that animator provides incremental values instead of + // deltas and scrolling code requires deltas. We animate WearableListView directly and use this + // field to calculate deltas. Obviously this means that only one scrolling algorithm can run at + // a time, but I don't think it would be wise to have more than one running. + private int mLastScrollChange; + + private SetScrollVerticallyProperty mSetScrollVerticallyProperty = + new SetScrollVerticallyProperty(); + + private final List<OnScrollListener> mOnScrollListeners = new ArrayList<OnScrollListener>(); + + private final List<OnCentralPositionChangedListener> mOnCentralPositionChangedListeners = + new ArrayList<OnCentralPositionChangedListener>(); + + private OnOverScrollListener mOverScrollListener; + + private boolean mGreedyTouchMode; + + private float mStartX; + + private float mStartY; + + private float mStartFirstTop; + + private final int mTouchSlop; + + private boolean mPossibleVerticalSwipe; + + private int mInitialOffset = 0; + + private Scroller mScroller; + + // Top and bottom boundaries for tap checking. Need to recompute by calling computeTapRegions + // before referencing. + private final float[] mTapRegions = new float[2]; + + private boolean mGestureDirectionLocked; + private int mPreviousCentral = 0; + + // Temp variable for storing locations on screen. + private final int[] mLocation = new int[2]; + + // TODO: Consider clearing this when underlying data set changes. If the data set changes, you + // can't safely assume that this pressed view is in the same place as it was before and it will + // receive setPressed(false) unnecessarily. In theory it should be fine, but in practice we + // have places like this: mIconView.setCircleColor(pressed ? mPressedColor : mSelectedColor); + // This might set selected color on non selected item. Our logic should be: if you change + // underlying data set, all best are off and you need to preserve the state; we will clear + // this field. However, I am not willing to introduce this so late in C development. + private View mPressedView = null; + + private final Runnable mPressedRunnable = new Runnable() { + @Override + public void run() { + if (getChildCount() > 0) { + mPressedView = getChildAt(findCenterViewIndex()); + mPressedView.setPressed(true); + } else { + Log.w(TAG, "mPressedRunnable: the children were removed, skipping."); + } + } + }; + + private final Runnable mReleasedRunnable = new Runnable() { + @Override + public void run() { + releasePressedItem(); + } + }; + + private Runnable mNotifyChildrenPostLayoutRunnable = new Runnable() { + @Override + public void run() { + notifyChildrenAboutProximity(false); + } + }; + + private final AdapterDataObserver mObserver = new AdapterDataObserver() { + @Override + public void onChanged() { + WearableListView.this.addOnLayoutChangeListener(new OnLayoutChangeListener() { + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + WearableListView.this.removeOnLayoutChangeListener(this); + if (WearableListView.this.getChildCount() > 0) { + WearableListView.this.animateToCenter(); + } + } + }); + } + }; + + public WearableListView(Context context) { + this(context, null); + } + + public WearableListView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public WearableListView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + setHasFixedSize(true); + setOverScrollMode(View.OVER_SCROLL_NEVER); + setLayoutManager(new LayoutManager()); + + final RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + if (newState == RecyclerView.SCROLL_STATE_IDLE && getChildCount() > 0) { + handleTouchUp(null, newState); + } + for (OnScrollListener listener : mOnScrollListeners) { + listener.onScrollStateChanged(newState); + } + } + + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + onScroll(dy); + } + }; + setOnScrollListener(onScrollListener); + + final ViewConfiguration vc = ViewConfiguration.get(context); + mTouchSlop = vc.getScaledTouchSlop(); + + mMinFlingVelocity = vc.getScaledMinimumFlingVelocity(); + mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity(); + } + + @Override + public void setAdapter(RecyclerView.Adapter adapter) { + RecyclerView.Adapter currentAdapter = getAdapter(); + if (currentAdapter != null) { + currentAdapter.unregisterAdapterDataObserver(mObserver); + } + + super.setAdapter(adapter); + + if (adapter != null) { + adapter.registerAdapterDataObserver(mObserver); + } + } + + /** + * @return the position of the center child's baseline; -1 if no center child exists or if + * the center child does not return a valid baseline. + */ + @Override + public int getBaseline() { + // No children implies there is no center child for which a baseline can be computed. + if (getChildCount() == 0) { + return super.getBaseline(); + } + + // Compute the baseline of the center child. + final int centerChildIndex = findCenterViewIndex(); + final int centerChildBaseline = getChildAt(centerChildIndex).getBaseline(); + + // If the center child has no baseline, neither does this list view. + if (centerChildBaseline == -1) { + return super.getBaseline(); + } + + return getCentralViewTop() + centerChildBaseline; + } + + /** + * @return true if the list is scrolled all the way to the top. + */ + public boolean isAtTop() { + if (getChildCount() == 0) { + return true; + } + + int centerChildIndex = findCenterViewIndex(); + View centerView = getChildAt(centerChildIndex); + return getChildAdapterPosition(centerView) == 0 && + getScrollState() == RecyclerView.SCROLL_STATE_IDLE; + } + + /** + * Clears the state of the layout manager that positions list items. + */ + public void resetLayoutManager() { + setLayoutManager(new LayoutManager()); + } + + /** + * Controls whether WearableListView should intercept all touch events and also prevent the + * parent from receiving them. + * @param greedy If true it will intercept all touch events. + */ + public void setGreedyTouchMode(boolean greedy) { + mGreedyTouchMode = greedy; + } + + /** + * By default the first element of the list is initially positioned in the center of the screen. + * This method allows the developer to specify a different offset, e.g. to hide the + * WearableListView before the user is allowed to use it. + * + * @param top How far the elements should be pushed down. + */ + public void setInitialOffset(int top) { + mInitialOffset = top; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent event) { + if (!isEnabled()) { + return false; + } + + if (mGreedyTouchMode && getChildCount() > 0) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + mStartX = event.getX(); + mStartY = event.getY(); + mStartFirstTop = getChildCount() > 0 ? getChildAt(0).getTop() : 0; + mPossibleVerticalSwipe = true; + mGestureDirectionLocked = false; + } else if (action == MotionEvent.ACTION_MOVE && mPossibleVerticalSwipe) { + handlePossibleVerticalSwipe(event); + } + getParent().requestDisallowInterceptTouchEvent(mPossibleVerticalSwipe); + } + return super.onInterceptTouchEvent(event); + } + + private boolean handlePossibleVerticalSwipe(MotionEvent event) { + if (mGestureDirectionLocked) { + return mPossibleVerticalSwipe; + } + float deltaX = Math.abs(mStartX - event.getX()); + float deltaY = Math.abs(mStartY - event.getY()); + float distance = (deltaX * deltaX) + (deltaY * deltaY); + // Verify that the distance moved in the combined x/y direction is at + // least touch slop before determining the gesture direction. + if (distance > (mTouchSlop * mTouchSlop)) { + if (deltaX > deltaY) { + mPossibleVerticalSwipe = false; + } + mGestureDirectionLocked = true; + } + return mPossibleVerticalSwipe; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!isEnabled()) { + return false; + } + + // super.onTouchEvent can change the state of the scroll, keep a copy so that handleTouchUp + // can exit early if scrollState != IDLE when the touch event started. + int scrollState = getScrollState(); + boolean result = super.onTouchEvent(event); + if (getChildCount() > 0) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + handleTouchDown(event); + } else if (action == MotionEvent.ACTION_UP) { + handleTouchUp(event, scrollState); + getParent().requestDisallowInterceptTouchEvent(false); + } else if (action == MotionEvent.ACTION_MOVE) { + if (Math.abs(mTapPositionX - (int) event.getX()) >= mTouchSlop || + Math.abs(mTapPositionY - (int) event.getY()) >= mTouchSlop) { + releasePressedItem(); + mCanClick = false; + } + result |= handlePossibleVerticalSwipe(event); + getParent().requestDisallowInterceptTouchEvent(mPossibleVerticalSwipe); + } else if (action == MotionEvent.ACTION_CANCEL) { + getParent().requestDisallowInterceptTouchEvent(false); + mCanClick = true; + } + } + return result; + } + + private void releasePressedItem() { + if (mPressedView != null) { + mPressedView.setPressed(false); + mPressedView = null; + } + Handler handler = getHandler(); + if (handler != null) { + handler.removeCallbacks(mPressedRunnable); + } + } + + private void onScroll(int dy) { + for (OnScrollListener listener : mOnScrollListeners) { + listener.onScroll(dy); + } + notifyChildrenAboutProximity(true); + } + + /** + * Adds a listener that will be called when the content of the list view is scrolled. + */ + public void addOnScrollListener(OnScrollListener listener) { + mOnScrollListeners.add(listener); + } + + /** + * Removes listener for scroll events. + */ + public void removeOnScrollListener(OnScrollListener listener) { + mOnScrollListeners.remove(listener); + } + + /** + * Adds a listener that will be called when the central item of the list changes. + */ + public void addOnCentralPositionChangedListener(OnCentralPositionChangedListener listener) { + mOnCentralPositionChangedListeners.add(listener); + } + + /** + * Removes a listener that would be called when the central item of the list changes. + */ + public void removeOnCentralPositionChangedListener(OnCentralPositionChangedListener listener) { + mOnCentralPositionChangedListeners.remove(listener); + } + + /** + * Determines if navigation of list with wrist gestures is enabled. + */ + public boolean isGestureNavigationEnabled() { + return mGestureNavigationEnabled; + } + + /** + * Sets whether navigation of list with wrist gestures is enabled. + */ + public void setEnableGestureNavigation(boolean enabled) { + mGestureNavigationEnabled = enabled; + } + + @Override /* KeyEvent.Callback */ + public boolean onKeyDown(int keyCode, KeyEvent event) { + // Respond to keycodes (at least originally generated and injected by wrist gestures). + if (mGestureNavigationEnabled) { + switch (keyCode) { + case KeyEvent.KEYCODE_NAVIGATE_PREVIOUS: + fling(0, -mMinFlingVelocity); + return true; + case KeyEvent.KEYCODE_NAVIGATE_NEXT: + fling(0, mMinFlingVelocity); + return true; + case KeyEvent.KEYCODE_NAVIGATE_IN: + return tapCenterView(); + case KeyEvent.KEYCODE_NAVIGATE_OUT: + // Returing false leaves the action to the container of this WearableListView + // (e.g. finishing the activity containing this WearableListView). + return false; + } + } + return super.onKeyDown(keyCode, event); + } + + /** + * Simulate tapping the child view at the center of this list. + */ + private boolean tapCenterView() { + if (!isEnabled() || getVisibility() != View.VISIBLE) { + return false; + } + int index = findCenterViewIndex(); + View view = getChildAt(index); + ViewHolder holder = getChildViewHolder(view); + if (mClickListener != null) { + mClickListener.onClick(holder); + return true; + } + return false; + } + + private boolean checkForTap(MotionEvent event) { + // No taps are accepted if this view is disabled. + if (!isEnabled()) { + return false; + } + + float rawY = event.getRawY(); + int index = findCenterViewIndex(); + View view = getChildAt(index); + ViewHolder holder = getChildViewHolder(view); + computeTapRegions(mTapRegions); + if (rawY > mTapRegions[0] && rawY < mTapRegions[1]) { + if (mClickListener != null) { + mClickListener.onClick(holder); + } + return true; + } + if (index > 0 && rawY <= mTapRegions[0]) { + animateToMiddle(index - 1, index); + return true; + } + if (index < getChildCount() - 1 && rawY >= mTapRegions[1]) { + animateToMiddle(index + 1, index); + return true; + } + if (index == 0 && rawY <= mTapRegions[0] && mClickListener != null) { + // Special case: if the top third of the screen is empty and the touch event happens + // there, we don't want to immediately disallow the parent from using it. We tell + // parent to disallow intercept only after we locked a gesture. Before that he + // might do something with the action. + mClickListener.onTopEmptyRegionClick(); + return true; + } + return false; + } + + private void animateToMiddle(int newCenterIndex, int oldCenterIndex) { + if (newCenterIndex == oldCenterIndex) { + throw new IllegalArgumentException( + "newCenterIndex must be different from oldCenterIndex"); + } + List<Animator> animators = new ArrayList<Animator>(); + View child = getChildAt(newCenterIndex); + int scrollToMiddle = getCentralViewTop() - child.getTop(); + startScrollAnimation(animators, scrollToMiddle, FLIP_ANIMATION_DURATION_MS); + } + + private void startScrollAnimation(List<Animator> animators, int scroll, long duration) { + startScrollAnimation(animators, scroll, duration, 0); + } + + private void startScrollAnimation(List<Animator> animators, int scroll, long duration, + long delay) { + startScrollAnimation(animators, scroll, duration, delay, null); + } + + private void startScrollAnimation( + int scroll, long duration, long delay, Animator.AnimatorListener listener) { + startScrollAnimation(null, scroll, duration, delay, listener); + } + + private void startScrollAnimation(List<Animator> animators, int scroll, long duration, + long delay, Animator.AnimatorListener listener) { + if (mScrollAnimator != null) { + mScrollAnimator.cancel(); + } + + mLastScrollChange = 0; + ObjectAnimator scrollAnimator = ObjectAnimator.ofInt(this, mSetScrollVerticallyProperty, + 0, -scroll); + + if (animators != null) { + animators.add(scrollAnimator); + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(animators); + mScrollAnimator = animatorSet; + } else { + mScrollAnimator = scrollAnimator; + } + mScrollAnimator.setDuration(duration); + if (listener != null) { + mScrollAnimator.addListener(listener); + } + if (delay > 0) { + mScrollAnimator.setStartDelay(delay); + } + mScrollAnimator.start(); + } + + @Override + public boolean fling(int velocityX, int velocityY) { + if (getChildCount() == 0) { + return false; + } + // If we are flinging towards empty space (before first element or after last), we reuse + // original flinging mechanism. + final int index = findCenterViewIndex(); + final View child = getChildAt(index); + int currentPosition = getChildPosition(child); + if ((currentPosition == 0 && velocityY < 0) || + (currentPosition == getAdapter().getItemCount() - 1 && velocityY > 0)) { + return super.fling(velocityX, velocityY); + } + + if (Math.abs(velocityY) < mMinFlingVelocity) { + return false; + } + velocityY = Math.max(Math.min(velocityY, mMaxFlingVelocity), -mMaxFlingVelocity); + + if (mScroller == null) { + mScroller = new Scroller(getContext(), null, true); + } + mScroller.fling(0, 0, 0, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, + Integer.MIN_VALUE, Integer.MAX_VALUE); + int finalY = mScroller.getFinalY(); + int delta = finalY / (getPaddingTop() + getAdjustedHeight() / 2); + if (delta == 0) { + // If the fling would not be enough to change position, we increase it to satisfy user's + // intent of switching current position. + delta = velocityY > 0 ? 1 : -1; + } + int finalPosition = Math.max( + 0, Math.min(getAdapter().getItemCount() - 1, currentPosition + delta)); + smoothScrollToPosition(finalPosition); + return true; + } + + public void smoothScrollToPosition(int position, RecyclerView.SmoothScroller smoothScroller) { + LayoutManager layoutManager = (LayoutManager) getLayoutManager(); + layoutManager.setCustomSmoothScroller(smoothScroller); + smoothScrollToPosition(position); + layoutManager.clearCustomSmoothScroller(); + } + + @Override + public ViewHolder getChildViewHolder(View child) { + return (ViewHolder) super.getChildViewHolder(child); + } + + /** + * Adds a listener that will be called when the user taps on the WearableListView or its items. + */ + public void setClickListener(ClickListener clickListener) { + mClickListener = clickListener; + } + + /** + * Adds a listener that will be called when the user drags the top element below its allowed + * bottom position. + * + * @hide + */ + public void setOverScrollListener(OnOverScrollListener listener) { + mOverScrollListener = listener; + } + + private int findCenterViewIndex() { + // TODO(gruszczy): This could be easily optimized, so that we stop looking when we the + // distance starts growing again, instead of finding the closest. It would safe half of + // the loop. + int count = getChildCount(); + int index = -1; + int closest = Integer.MAX_VALUE; + int centerY = getCenterYPos(this); + for (int i = 0; i < count; ++i) { + final View child = getChildAt(i); + int childCenterY = getTop() + getCenterYPos(child); + final int distance = Math.abs(centerY - childCenterY); + if (distance < closest) { + closest = distance; + index = i; + } + } + if (index == -1) { + throw new IllegalStateException("Can't find central view."); + } + return index; + } + + private static int getCenterYPos(View v) { + return v.getTop() + v.getPaddingTop() + getAdjustedHeight(v) / 2; + } + + private void handleTouchUp(MotionEvent event, int scrollState) { + if (mCanClick && event != null && checkForTap(event)) { + Handler handler = getHandler(); + if (handler != null) { + handler.postDelayed(mReleasedRunnable, ViewConfiguration.getTapTimeout()); + } + return; + } + + if (scrollState != RecyclerView.SCROLL_STATE_IDLE) { + // We are flinging, so let's not start animations just yet. Instead we will start them + // when the fling finishes. + return; + } + + if (isOverScrolling()) { + mOverScrollListener.onOverScroll(); + } else { + animateToCenter(); + } + } + + private boolean isOverScrolling() { + return getChildCount() > 0 + // If first view top was below the central top, it means it was never centered. + // Don't allow overscroll, otherwise a simple touch (instead of a drag) will be + // enough to trigger overscroll. + && mStartFirstTop <= getCentralViewTop() + && getChildAt(0).getTop() >= getTopViewMaxTop() + && mOverScrollListener != null; + } + + private int getTopViewMaxTop() { + return getHeight() / 2; + } + + private int getItemHeight() { + // Round up so that the screen is fully occupied by 3 items. + return getAdjustedHeight() / THIRD + 1; + } + + /** + * Returns top of the central {@code View} in the list when such view is fully centered. + * + * This is a more or a less a static value that you can use to align other views with the + * central one. + */ + public int getCentralViewTop() { + return getPaddingTop() + getItemHeight(); + } + + /** + * Automatically starts an animation that snaps the list to center on the element closest to the + * middle. + */ + public void animateToCenter() { + final int index = findCenterViewIndex(); + final View child = getChildAt(index); + final int scrollToMiddle = getCentralViewTop() - child.getTop(); + startScrollAnimation(scrollToMiddle, CENTERING_ANIMATION_DURATION_MS, 0, + new SimpleAnimatorListener() { + @Override + public void onAnimationEnd(Animator animator) { + if (!wasCanceled()) { + mCanClick = true; + } + } + }); + } + + /** + * Animate the list so that the first view is back to its initial position. + * @param endAction Action to execute when the animation is done. + * @hide + */ + public void animateToInitialPosition(final Runnable endAction) { + final View child = getChildAt(0); + final int scrollToMiddle = getCentralViewTop() + mInitialOffset - child.getTop(); + startScrollAnimation(scrollToMiddle, CENTERING_ANIMATION_DURATION_MS, 0, + new SimpleAnimatorListener() { + @Override + public void onAnimationEnd(Animator animator) { + if (endAction != null) { + endAction.run(); + } + } + }); + } + + private void handleTouchDown(MotionEvent event) { + if (mCanClick) { + mTapPositionX = (int) event.getX(); + mTapPositionY = (int) event.getY(); + float rawY = event.getRawY(); + computeTapRegions(mTapRegions); + if (rawY > mTapRegions[0] && rawY < mTapRegions[1]) { + View view = getChildAt(findCenterViewIndex()); + if (view instanceof OnCenterProximityListener) { + Handler handler = getHandler(); + if (handler != null) { + handler.removeCallbacks(mReleasedRunnable); + handler.postDelayed(mPressedRunnable, ViewConfiguration.getTapTimeout()); + } + } + } + } + } + + private void setScrollVertically(int scroll) { + scrollBy(0, scroll - mLastScrollChange); + mLastScrollChange = scroll; + } + + private int getAdjustedHeight() { + return getAdjustedHeight(this); + } + + private static int getAdjustedHeight(View v) { + return v.getHeight() - v.getPaddingBottom() - v.getPaddingTop(); + } + + private void computeTapRegions(float[] tapRegions) { + mLocation[0] = mLocation[1] = 0; + getLocationOnScreen(mLocation); + int mScreenTop = mLocation[1]; + int height = getHeight(); + tapRegions[0] = mScreenTop + height * TOP_TAP_REGION_PERCENTAGE; + tapRegions[1] = mScreenTop + height * (1 - BOTTOM_TAP_REGION_PERCENTAGE); + } + + /** + * Determines if, when there is only one item in the WearableListView, that the single item + * is laid out so that it's height fills the entire WearableListView. + */ + public boolean getMaximizeSingleItem() { + return mMaximizeSingleItem; + } + + /** + * When set to true, if there is only one item in the WearableListView, it will fill the entire + * WearableListView. When set to false, the default behavior will be used and the single item + * will fill only a third of the screen. + */ + public void setMaximizeSingleItem(boolean maximizeSingleItem) { + mMaximizeSingleItem = maximizeSingleItem; + } + + private void notifyChildrenAboutProximity(boolean animate) { + LayoutManager layoutManager = (LayoutManager) getLayoutManager(); + int count = layoutManager.getChildCount(); + + if (count == 0) { + return; + } + + int index = layoutManager.findCenterViewIndex(); + for (int i = 0; i < count; ++i) { + final View view = layoutManager.getChildAt(i); + ViewHolder holder = getChildViewHolder(view); + holder.onCenterProximity(i == index, animate); + } + final int position = getChildViewHolder(getChildAt(index)).getPosition(); + if (position != mPreviousCentral) { + for (OnScrollListener listener : mOnScrollListeners) { + listener.onCentralPositionChanged(position); + } + for (OnCentralPositionChangedListener listener : + mOnCentralPositionChangedListeners) { + listener.onCentralPositionChanged(position); + } + mPreviousCentral = position; + } + } + + // TODO: Move this to a separate class, so it can't directly interact with the WearableListView. + private class LayoutManager extends RecyclerView.LayoutManager { + private int mFirstPosition; + + private boolean mPushFirstHigher; + + private int mAbsoluteScroll; + + private boolean mUseOldViewTop = true; + + private boolean mWasZoomedIn = false; + + private RecyclerView.SmoothScroller mSmoothScroller; + + private RecyclerView.SmoothScroller mDefaultSmoothScroller; + + // We need to have another copy of the same method, because this one uses + // LayoutManager.getChildCount/getChildAt instead of View.getChildCount/getChildAt and + // they return different values. + private int findCenterViewIndex() { + // TODO(gruszczy): This could be easily optimized, so that we stop looking when we the + // distance starts growing again, instead of finding the closest. It would safe half of + // the loop. + int count = getChildCount(); + int index = -1; + int closest = Integer.MAX_VALUE; + int centerY = getCenterYPos(WearableListView.this); + for (int i = 0; i < count; ++i) { + final View child = getLayoutManager().getChildAt(i); + int childCenterY = getTop() + getCenterYPos(child); + final int distance = Math.abs(centerY - childCenterY); + if (distance < closest) { + closest = distance; + index = i; + } + } + if (index == -1) { + throw new IllegalStateException("Can't find central view."); + } + return index; + } + + @Override + public void onLayoutChildren(RecyclerView.Recycler recycler, State state) { + final int parentBottom = getHeight() - getPaddingBottom(); + // By default we assume this is the first run and the first element will be centered + // with optional initial offset. + int oldTop = getCentralViewTop() + mInitialOffset; + // Here we handle any other situation where we relayout or we want to achieve a + // specific layout of children. + if (mUseOldViewTop && getChildCount() > 0) { + // We are performing a relayout after we already had some children, because e.g. the + // contents of an adapter has changed. First we want to check, if the central item + // from before the layout is still here, because we want to preserve it. + int index = findCenterViewIndex(); + int position = getPosition(getChildAt(index)); + if (position == NO_POSITION) { + // Central item was removed. Let's find the first surviving item and use it + // as an anchor. + for (int i = 0, N = getChildCount(); index + i < N || index - i >= 0; ++i) { + View child = getChildAt(index + i); + if (child != null) { + position = getPosition(child); + if (position != NO_POSITION) { + index = index + i; + break; + } + } + child = getChildAt(index - i); + if (child != null) { + position = getPosition(child); + if (position != NO_POSITION) { + index = index - i; + break; + } + } + } + } + if (position == NO_POSITION) { + // None of the children survives the relayout, let's just use the top of the + // first one. + oldTop = getChildAt(0).getTop(); + int count = state.getItemCount(); + // Lets first make sure that the first position is not above the last element, + // which can happen if elements were removed. + while (mFirstPosition >= count && mFirstPosition > 0) { + mFirstPosition--; + } + } else { + // Some of the children survived the relayout. We will keep it in its place, + // but go through previous children and maybe add them. + if (!mWasZoomedIn) { + // If we were previously zoomed-in on a single item, ignore this and just + // use the default value set above. Reasoning: if we are still zoomed-in, + // oldTop will be ignored when laying out the single child element. If we + // are no longer zoomed in, then we want to position items using the top + // of the single item as if the single item was not zoomed in, which is + // equal to the default value. + oldTop = getChildAt(index).getTop(); + } + while (oldTop > getPaddingTop() && position > 0) { + position--; + oldTop -= getItemHeight(); + } + if (position == 0 && oldTop > getCentralViewTop()) { + // We need to handle special case where the first, central item was removed + // and now the first element is hanging below, instead of being nicely + // centered. + oldTop = getCentralViewTop(); + } + mFirstPosition = position; + } + } else if (mPushFirstHigher) { + // We are trying to position elements ourselves, so we force position of the first + // one. + oldTop = getCentralViewTop() - getItemHeight(); + } + + performLayoutChildren(recycler, state, parentBottom, oldTop); + + // Since the content might have changed, we need to adjust the absolute scroll in case + // some elements have disappeared or were added. + if (getChildCount() == 0) { + setAbsoluteScroll(0); + } else { + View child = getChildAt(findCenterViewIndex()); + setAbsoluteScroll(child.getTop() - getCentralViewTop() + getPosition(child) * + getItemHeight()); + } + + mUseOldViewTop = true; + mPushFirstHigher = false; + } + + private void performLayoutChildren(Recycler recycler, State state, int parentBottom, + int top) { + detachAndScrapAttachedViews(recycler); + + if (mMaximizeSingleItem && state.getItemCount() == 1) { + performLayoutOneChild(recycler, parentBottom); + mWasZoomedIn = true; + } else { + performLayoutMultipleChildren(recycler, state, parentBottom, top); + mWasZoomedIn = false; + } + + if (getChildCount() > 0) { + post(mNotifyChildrenPostLayoutRunnable); + } + } + + private void performLayoutOneChild(Recycler recycler, int parentBottom) { + final int right = getWidth() - getPaddingRight(); + View v = recycler.getViewForPosition(getFirstPosition()); + addView(v, 0); + measureZoomView(v); + v.layout(getPaddingLeft(), getPaddingTop(), right, parentBottom); + } + + private void performLayoutMultipleChildren(Recycler recycler, State state, int parentBottom, + int top) { + int bottom; + final int left = getPaddingLeft(); + final int right = getWidth() - getPaddingRight(); + final int count = state.getItemCount(); + // If we are laying out children with center element being different than the first, we + // need to start with previous child which appears half visible at the top. + for (int i = 0; getFirstPosition() + i < count; i++, top = bottom) { + if (top >= parentBottom) { + break; + } + View v = recycler.getViewForPosition(getFirstPosition() + i); + addView(v, i); + measureThirdView(v); + bottom = top + getItemHeight(); + v.layout(left, top, right, bottom); + } + } + + private void setAbsoluteScroll(int absoluteScroll) { + mAbsoluteScroll = absoluteScroll; + for (OnScrollListener listener : mOnScrollListeners) { + listener.onAbsoluteScrollChange(mAbsoluteScroll); + } + } + + private void measureView(View v, int height) { + final LayoutParams lp = (LayoutParams) v.getLayoutParams(); + final int widthSpec = getChildMeasureSpec(getWidth(), + getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin, lp.width, + canScrollHorizontally()); + final int heightSpec = getChildMeasureSpec(getHeight(), + getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin, + height, canScrollVertically()); + v.measure(widthSpec, heightSpec); + } + + private void measureThirdView(View v) { + measureView(v, (int) (1 + (float) getHeight() / THIRD)); + } + + private void measureZoomView(View v) { + measureView(v, getHeight()); + } + + @Override + public RecyclerView.LayoutParams generateDefaultLayoutParams() { + return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + } + + @Override + public boolean canScrollVertically() { + // Disable vertical scrolling when zoomed. + return getItemCount() != 1 || !mWasZoomedIn; + } + + @Override + public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, State state) { + // TODO(gruszczy): This code is shit, needs to be rewritten. + if (getChildCount() == 0) { + return 0; + } + int scrolled = 0; + final int left = getPaddingLeft(); + final int right = getWidth() - getPaddingRight(); + if (dy < 0) { + while (scrolled > dy) { + final View topView = getChildAt(0); + if (getFirstPosition() > 0) { + final int hangingTop = Math.max(-topView.getTop(), 0); + final int scrollBy = Math.min(scrolled - dy, hangingTop); + scrolled -= scrollBy; + offsetChildrenVertical(scrollBy); + if (getFirstPosition() > 0 && scrolled > dy) { + mFirstPosition--; + View v = recycler.getViewForPosition(getFirstPosition()); + addView(v, 0); + measureThirdView(v); + final int bottom = topView.getTop(); + final int top = bottom - getItemHeight(); + v.layout(left, top, right, bottom); + } else { + break; + } + } else { + mPushFirstHigher = false; + int maxScroll = mOverScrollListener!= null ? + getHeight() : getTopViewMaxTop(); + final int scrollBy = Math.min(-dy + scrolled, maxScroll - topView.getTop()); + scrolled -= scrollBy; + offsetChildrenVertical(scrollBy); + break; + } + } + } else if (dy > 0) { + final int parentHeight = getHeight(); + while (scrolled < dy) { + final View bottomView = getChildAt(getChildCount() - 1); + if (state.getItemCount() > mFirstPosition + getChildCount()) { + final int hangingBottom = + Math.max(bottomView.getBottom() - parentHeight, 0); + final int scrollBy = -Math.min(dy - scrolled, hangingBottom); + scrolled -= scrollBy; + offsetChildrenVertical(scrollBy); + if (scrolled < dy) { + View v = recycler.getViewForPosition(mFirstPosition + getChildCount()); + final int top = getChildAt(getChildCount() - 1).getBottom(); + addView(v); + measureThirdView(v); + final int bottom = top + getItemHeight(); + v.layout(left, top, right, bottom); + } else { + break; + } + } else { + final int scrollBy = + Math.max(-dy + scrolled, getHeight() / 2 - bottomView.getBottom()); + scrolled -= scrollBy; + offsetChildrenVertical(scrollBy); + break; + } + } + } + recycleViewsOutOfBounds(recycler); + setAbsoluteScroll(mAbsoluteScroll + scrolled); + return scrolled; + } + + @Override + public void scrollToPosition(int position) { + mUseOldViewTop = false; + if (position > 0) { + mFirstPosition = position - 1; + mPushFirstHigher = true; + } else { + mFirstPosition = position; + mPushFirstHigher = false; + } + requestLayout(); + } + + public void setCustomSmoothScroller(RecyclerView.SmoothScroller smoothScroller) { + mSmoothScroller = smoothScroller; + } + + public void clearCustomSmoothScroller() { + mSmoothScroller = null; + } + + public RecyclerView.SmoothScroller getDefaultSmoothScroller(RecyclerView recyclerView) { + if (mDefaultSmoothScroller == null) { + mDefaultSmoothScroller = new SmoothScroller( + recyclerView.getContext(), this); + } + return mDefaultSmoothScroller; + } + @Override + public void smoothScrollToPosition(RecyclerView recyclerView, State state, + int position) { + RecyclerView.SmoothScroller scroller = mSmoothScroller; + if (scroller == null) { + scroller = getDefaultSmoothScroller(recyclerView); + } + scroller.setTargetPosition(position); + startSmoothScroll(scroller); + } + + private void recycleViewsOutOfBounds(RecyclerView.Recycler recycler) { + final int childCount = getChildCount(); + final int parentWidth = getWidth(); + // Here we want to use real height, so we don't remove views that are only visible in + // padded section. + final int parentHeight = getHeight(); + boolean foundFirst = false; + int first = 0; + int last = 0; + for (int i = 0; i < childCount; i++) { + final View v = getChildAt(i); + if (v.hasFocus() || (v.getRight() >= 0 && v.getLeft() <= parentWidth && + v.getBottom() >= 0 && v.getTop() <= parentHeight)) { + if (!foundFirst) { + first = i; + foundFirst = true; + } + last = i; + } + } + for (int i = childCount - 1; i > last; i--) { + removeAndRecycleViewAt(i, recycler); + } + for (int i = first - 1; i >= 0; i--) { + removeAndRecycleViewAt(i, recycler); + } + if (getChildCount() == 0) { + mFirstPosition = 0; + } else if (first > 0) { + mPushFirstHigher = true; + mFirstPosition += first; + } + } + + public int getFirstPosition() { + return mFirstPosition; + } + + @Override + public void onAdapterChanged(RecyclerView.Adapter oldAdapter, + RecyclerView.Adapter newAdapter) { + removeAllViews(); + } + } + + /** + * Interface for receiving callbacks when WearableListView children become or cease to be the + * central item. + */ + public interface OnCenterProximityListener { + /** + * Called when this view becomes central item of the WearableListView. + * + * @param animate Whether you should animate your transition of the View to become the + * central item. If false, this is the initial setting and you should + * transition immediately. + */ + void onCenterPosition(boolean animate); + + /** + * Called when this view stops being the central item of the WearableListView. + * @param animate Whether you should animate your transition of the View to being + * non central item. If false, this is the initial setting and you should + * transition immediately. + */ + void onNonCenterPosition(boolean animate); + } + + /** + * Interface for listening for click events on WearableListView. + */ + public interface ClickListener { + /** + * Called when the central child of the WearableListView is tapped. + * @param view View that was clicked. + */ + public void onClick(ViewHolder view); + + /** + * Called when the user taps the top third of the WearableListView and no item is present + * there. This can happen when you are in initial state and the first, top-most item of the + * WearableListView is centered. + */ + public void onTopEmptyRegionClick(); + } + + /** + * @hide + */ + public interface OnOverScrollListener { + public void onOverScroll(); + } + + /** + * Interface for listening to WearableListView content scrolling. + */ + public interface OnScrollListener { + /** + * Called when the content is scrolled, reporting the relative scroll value. + * @param scroll Amount the content was scrolled. This is a delta from the previous + * position to the new position. + */ + public void onScroll(int scroll); + + /** + * Called when the content is scrolled, reporting the absolute scroll value. + * + * @deprecated BE ADVISED DO NOT USE THIS This might provide wrong values when contents + * of a RecyclerView change. + * + * @param scroll Absolute scroll position of the content inside the WearableListView. + */ + @Deprecated + public void onAbsoluteScrollChange(int scroll); + + /** + * Called when WearableListView's scroll state changes. + * + * @param scrollState The updated scroll state. One of {@link #SCROLL_STATE_IDLE}, + * {@link #SCROLL_STATE_DRAGGING} or {@link #SCROLL_STATE_SETTLING}. + */ + public void onScrollStateChanged(int scrollState); + + /** + * Called when the central item of the WearableListView changes. + * + * @param centralPosition Position of the item in the Adapter. + */ + public void onCentralPositionChanged(int centralPosition); + } + + /** + * A listener interface that can be added to the WearableListView to get notified when the + * central item is changed. + */ + public interface OnCentralPositionChangedListener { + /** + * Called when the central item of the WearableListView changes. + * + * @param centralPosition Position of the item in the Adapter. + */ + void onCentralPositionChanged(int centralPosition); + } + + /** + * Base class for adapters providing data for the WearableListView. For details refer to + * RecyclerView.Adapter. + */ + public static abstract class Adapter extends RecyclerView.Adapter<ViewHolder> { + } + + private static class SmoothScroller extends LinearSmoothScroller { + + private static final float MILLISECONDS_PER_INCH = 100f; + + private final LayoutManager mLayoutManager; + + public SmoothScroller(Context context, WearableListView.LayoutManager manager) { + super(context); + mLayoutManager = manager; + } + + @Override + protected void onStart() { + super.onStart(); + } + + // TODO: (mindyp): when flinging, return the dydt that triggered the fling. + @Override + protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) { + return MILLISECONDS_PER_INCH / displayMetrics.densityDpi; + } + + @Override + public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int + snapPreference) { + // Snap to center. + return (boxStart + boxEnd) / 2 - (viewStart + viewEnd) / 2; + } + + @Override + public PointF computeScrollVectorForPosition(int targetPosition) { + if (targetPosition < mLayoutManager.getFirstPosition()) { + return new PointF(0, -1); + } else { + return new PointF(0, 1); + } + } + } + + /** + * Wrapper around items displayed in the list view. {@link .Adapter} must return objects that + * are instances of this class. Consider making the wrapped View implement + * {@link .OnCenterProximityListener} if you want to receive a callback when it becomes or + * ceases to be the central item in the WearableListView. + */ + public static class ViewHolder extends RecyclerView.ViewHolder { + public ViewHolder(View itemView) { + super(itemView); + } + + /** + * Called when the wrapped view is becoming or ceasing to be the central item of the + * WearableListView. + * + * Retained as protected for backwards compatibility. + * + * @hide + */ + protected void onCenterProximity(boolean isCentralItem, boolean animate) { + if (!(itemView instanceof OnCenterProximityListener)) { + return; + } + OnCenterProximityListener item = (OnCenterProximityListener) itemView; + if (isCentralItem) { + item.onCenterPosition(animate); + } else { + item.onNonCenterPosition(animate); + } + } + } + + private class SetScrollVerticallyProperty extends Property<WearableListView, Integer> { + public SetScrollVerticallyProperty() { + super(Integer.class, "scrollVertically"); + } + + @Override + public Integer get(WearableListView wearableListView) { + return wearableListView.mLastScrollChange; + } + + @Override + public void set(WearableListView wearableListView, Integer value) { + wearableListView.setScrollVertically(value); + } + } +} diff --git a/src/com/android/packageinstaller/permission/ui/wear/settings/ExtendedOnCenterProximityListener.java b/src/com/android/packageinstaller/permission/ui/wear/settings/ExtendedOnCenterProximityListener.java new file mode 100644 index 00000000..02c203b3 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/wear/settings/ExtendedOnCenterProximityListener.java @@ -0,0 +1,30 @@ +/* + * 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. + */ + +package com.android.packageinstaller.permission.ui.wear.settings; + +import android.support.wearable.view.WearableListView; + +public interface ExtendedOnCenterProximityListener + extends WearableListView.OnCenterProximityListener { + float getProximityMinValue(); + + float getProximityMaxValue(); + + float getCurrentProximityValue(); + + void setScalingAnimatorValue(float value); +} diff --git a/src/com/android/packageinstaller/permission/ui/wear/settings/ExtendedViewHolder.java b/src/com/android/packageinstaller/permission/ui/wear/settings/ExtendedViewHolder.java new file mode 100644 index 00000000..6b725419 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/wear/settings/ExtendedViewHolder.java @@ -0,0 +1,84 @@ +/* + * 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. + */ + +package com.android.packageinstaller.permission.ui.wear.settings; + +import android.animation.ObjectAnimator; +import android.support.wearable.view.WearableListView; +import android.view.View; + + +public class ExtendedViewHolder extends WearableListView.ViewHolder { + public static final long DEFAULT_ANIMATION_DURATION = 150; + + private ObjectAnimator mScalingUpAnimator; + + private ObjectAnimator mScalingDownAnimator; + + private float mMinValue; + + private float mMaxValue; + + public ExtendedViewHolder(View itemView) { + super(itemView); + if (itemView instanceof ExtendedOnCenterProximityListener) { + ExtendedOnCenterProximityListener item = + (ExtendedOnCenterProximityListener) itemView; + mMinValue = item.getProximityMinValue(); + item.setScalingAnimatorValue(mMinValue); + mMaxValue = item.getProximityMaxValue(); + mScalingUpAnimator = ObjectAnimator.ofFloat(item, "scalingAnimatorValue", mMinValue, + mMaxValue); + mScalingUpAnimator.setDuration(DEFAULT_ANIMATION_DURATION); + mScalingDownAnimator = ObjectAnimator.ofFloat(item, "scalingAnimatorValue", + mMaxValue, mMinValue); + mScalingDownAnimator.setDuration(DEFAULT_ANIMATION_DURATION); + } + } + + public void onCenterProximity(boolean isCentralItem, boolean animate) { + if (!(itemView instanceof ExtendedOnCenterProximityListener)) { + return; + } + ExtendedOnCenterProximityListener item = (ExtendedOnCenterProximityListener) itemView; + if (isCentralItem) { + if (animate) { + mScalingDownAnimator.cancel(); + if (!mScalingUpAnimator.isRunning()) { + mScalingUpAnimator.setFloatValues(item.getCurrentProximityValue(), + mMaxValue); + mScalingUpAnimator.start(); + } + } else { + mScalingUpAnimator.cancel(); + item.setScalingAnimatorValue(item.getProximityMaxValue()); + } + } else { + mScalingUpAnimator.cancel(); + if (animate) { + if (!mScalingDownAnimator.isRunning()) { + mScalingDownAnimator.setFloatValues(item.getCurrentProximityValue(), + mMinValue); + mScalingDownAnimator.start(); + } + } else { + mScalingDownAnimator.cancel(); + item.setScalingAnimatorValue(item.getProximityMinValue()); + } + } + super.onCenterProximity(isCentralItem, animate); + } +} diff --git a/src/com/android/packageinstaller/permission/ui/wear/settings/SettingsAdapter.java b/src/com/android/packageinstaller/permission/ui/wear/settings/SettingsAdapter.java new file mode 100644 index 00000000..9216cbbf --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/wear/settings/SettingsAdapter.java @@ -0,0 +1,295 @@ +/* + * 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. + */ + +package com.android.packageinstaller.permission.ui.wear.settings; + +import android.content.Context; +import android.support.wearable.view.CircledImageView; +import android.support.wearable.view.WearableListView; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.TextAppearanceSpan; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +import com.android.packageinstaller.R; + +import java.util.ArrayList; + +/** + * Common adapter for settings views. Maintains a list of 'Settings', consisting of a name, + * icon and optional activity-specific data. + */ +public class SettingsAdapter<T> extends WearableListView.Adapter { + private static final String TAG = "SettingsAdapter"; + private final Context mContext; + + protected static CharSequence generateLabelWithState( + Context context, int labelId, boolean enabled) { + return generateLabelWithState(context, labelId, R.string.generic_enabled, enabled); + } + + protected static CharSequence generateLabelWithState( + Context context, int labelId, int onId, boolean enabled) { + SpannableStringBuilder ssb = new SpannableStringBuilder(context.getString(labelId)); + ssb.append('\n'); + ssb.append( + context.getString(enabled ? onId : R.string.generic_disabled), + new TextAppearanceSpan(context, R.style.TextAppearance_Settings_Label_Large), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return ssb; + } + + public static final class Setting<S> { + public static final int ID_INVALID = -1; + + public final int id; + public int nameResourceId; + public CharSequence name; + public int iconResource; + public boolean inProgress; + public S data; + + public Setting(CharSequence name, int iconResource, S data) { + this(name, iconResource, data, ID_INVALID); + } + + public Setting(CharSequence name, int iconResource, S data, int id) { + this.name = name; + this.iconResource = iconResource; + this.data = data; + this.inProgress = false; + this.id = id; + } + + public Setting(int nameResource, int iconResource, S data, int id) { + this.nameResourceId = nameResource; + this.iconResource = iconResource; + this.data = data; + this.inProgress = false; + this.id = id; + } + + public Setting(int nameResource, int iconResource, int id) { + this.nameResourceId = nameResource; + this.iconResource = iconResource; + this.data = null; + this.inProgress = false; + this.id = id; + } + + public Setting(CharSequence name, int iconResource, int id) { + this(name, iconResource, null, id); + } + + } + + private final int mItemLayoutId; + private final float mDefaultCircleRadiusPercent; + private final float mSelectedCircleRadiusPercent; + + protected ArrayList<Setting<T>> mSettings = new ArrayList<Setting<T>>(); + + public SettingsAdapter(Context context, int itemLayoutId) { + mContext = context; + mItemLayoutId = itemLayoutId; + mDefaultCircleRadiusPercent = context.getResources().getFraction( + R.dimen.default_settings_circle_radius_percent, 1, 1); + mSelectedCircleRadiusPercent = context.getResources().getFraction( + R.dimen.selected_settings_circle_radius_percent, 1, 1); + } + + @Override + public WearableListView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + return new SettingsItemHolder(new SettingsItem(parent.getContext())); + } + + @Override + public void onBindViewHolder(WearableListView.ViewHolder holder, int position) { + Setting<T> setting = mSettings.get(position); + if (setting.iconResource == -1) { + ((SettingsItemHolder) holder).imageView.setVisibility(View.GONE); + } else { + ((SettingsItemHolder) holder).imageView.setVisibility(View.VISIBLE); + ((SettingsItemHolder) holder).imageView.setImageResource( + mSettings.get(position).iconResource); + } + Log.d(TAG, "onBindViewHolder " + setting.name + " " + setting.id + " " + setting + .nameResourceId); + if (setting.name == null && setting.nameResourceId != 0) { + setting.name = mContext.getString(setting.nameResourceId); + } + ((SettingsItemHolder) holder).textView.setText(setting.name); + } + + @Override + public int getItemCount() { + return mSettings.size(); + } + + public void addSetting(CharSequence name, int iconResource) { + addSetting(name, iconResource, null); + } + + public void addSetting(CharSequence name, int iconResource, T intent) { + addSetting(mSettings.size(), name, iconResource, intent); + } + + public void addSetting(int index, CharSequence name, int iconResource, T intent) { + addSetting(Setting.ID_INVALID, index, name, iconResource, intent); + } + + public void addSetting(int id, int index, CharSequence name, int iconResource, T intent) { + mSettings.add(index, new Setting<T>(name, iconResource, intent, id)); + notifyItemInserted(index); + } + + public void addSettingDontNotify(Setting<T> setting) { + mSettings.add(setting); + } + + public void addSetting(Setting<T> setting) { + mSettings.add(setting); + notifyItemInserted(mSettings.size() - 1); + } + + public void addSetting(int index, Setting<T> setting) { + mSettings.add(index, setting); + notifyItemInserted(index); + } + + /** + * Returns the index of the setting in the adapter based on the ID supplied when it was + * originally added. + * @param id the setting's id + * @return index in the adapter of the setting. -1 if not found. + */ + public int findSetting(int id) { + for (int i = mSettings.size() - 1; i >= 0; --i) { + Setting setting = mSettings.get(i); + + if (setting.id == id) { + return i; + } + } + + return -1; + } + + /** + * Removes a setting at the given index. + * @param index the index of the setting to be removed + */ + public void removeSetting(int index) { + mSettings.remove(index); + notifyDataSetChanged(); + } + + public void clearSettings() { + mSettings.clear(); + notifyDataSetChanged(); + } + + /** + * Updates a setting in place. + * @param index the index of the setting + * @param name the updated setting name + * @param iconResource the update setting icon + * @param intent the updated intent for the setting + */ + public void updateSetting(int index, CharSequence name, int iconResource, T intent) { + Setting<T> setting = mSettings.get(index); + setting.iconResource = iconResource; + setting.name = name; + setting.data = intent; + notifyItemChanged(index); + } + + public Setting<T> get(int position) { + return mSettings.get(position); + } + + protected static class SettingsItemHolder extends ExtendedViewHolder { + public final CircledImageView imageView; + final TextView textView; + + public SettingsItemHolder(View itemView) { + super(itemView); + + imageView = ((CircledImageView) itemView.findViewById(R.id.image)); + textView = ((TextView) itemView.findViewById(R.id.text)); + } + } + + protected class SettingsItem extends FrameLayout implements ExtendedOnCenterProximityListener { + + private final CircledImageView mImage; + private final TextView mText; + + public SettingsItem(Context context) { + super(context); + View view = View.inflate(context, mItemLayoutId, null); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT); + params.gravity = Gravity.CENTER_VERTICAL; + addView(view, params); + mImage = (CircledImageView) findViewById(R.id.image); + mText = (TextView) findViewById(R.id.text); + } + + @Override + public float getProximityMinValue() { + return mDefaultCircleRadiusPercent; + } + + @Override + public float getProximityMaxValue() { + return mSelectedCircleRadiusPercent; + } + + @Override + public float getCurrentProximityValue() { + return mImage.getCircleRadiusPressedPercent(); + } + + @Override + public void setScalingAnimatorValue(float value) { + mImage.setCircleRadiusPercent(value); + mImage.setCircleRadiusPressedPercent(value); + } + + @Override + public void onCenterPosition(boolean animate) { + mImage.setAlpha(1f); + mText.setAlpha(1f); + } + + @Override + public void onNonCenterPosition(boolean animate) { + mImage.setAlpha(0.5f); + mText.setAlpha(0.5f); + } + + TextView getTextView() { + return mText; + } + } +} diff --git a/src/com/android/packageinstaller/permission/ui/wear/settings/ViewUtils.java b/src/com/android/packageinstaller/permission/ui/wear/settings/ViewUtils.java new file mode 100644 index 00000000..cf1c0fd0 --- /dev/null +++ b/src/com/android/packageinstaller/permission/ui/wear/settings/ViewUtils.java @@ -0,0 +1,48 @@ +/* + * 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. + */ + +package com.android.packageinstaller.permission.ui.wear.settings; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; + +/** + * Utility to determine screen shape + */ +public class ViewUtils { + + public static boolean getIsCircular(Context context) { + return context.getResources().getConfiguration().isScreenRound(); + } + + /** + * Set the given {@code view} and all descendants to the given {@code enabled} state. + * + * @param view the parent view of a subtree of components whose enabled state must be set + * @param enabled the new enabled state of the subtree of components + */ + public static void setEnabled(View view, boolean enabled) { + view.setEnabled(enabled); + + if (view instanceof ViewGroup) { + final ViewGroup viewGroup = (ViewGroup) view; + for (int i = 0; i < viewGroup.getChildCount(); i++) { + setEnabled(viewGroup.getChildAt(i), enabled); + } + } + } +} |