/* Copyright (C) 2010 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.widget;
import java.lang.ref.WeakReference;
import android.animation.ObjectAnimator;
import android.animation.PropertyValuesHolder;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.BlurMaskFilter;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Region;
import android.graphics.TableMaskFilter;
import android.os.Bundle;
import android.util.AttributeSet;
import android.util.Log;
import android.view.InputDevice;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.animation.LinearInterpolator;
import android.widget.RemoteViews.RemoteView;
@RemoteView
/**
* A view that displays its children in a stack and allows users to discretely swipe
* through the children.
*/
public class StackView extends AdapterViewAnimator {
private final String TAG = "StackView";
/**
* Default animation parameters
*/
private static final int DEFAULT_ANIMATION_DURATION = 400;
private static final int MINIMUM_ANIMATION_DURATION = 50;
private static final int STACK_RELAYOUT_DURATION = 100;
/**
* Parameters effecting the perspective visuals
*/
private static final float PERSPECTIVE_SHIFT_FACTOR_Y = 0.1f;
private static final float PERSPECTIVE_SHIFT_FACTOR_X = 0.1f;
private float mPerspectiveShiftX;
private float mPerspectiveShiftY;
private float mNewPerspectiveShiftX;
private float mNewPerspectiveShiftY;
@SuppressWarnings({"FieldCanBeLocal"})
private static final float PERSPECTIVE_SCALE_FACTOR = 0f;
/**
* Represent the two possible stack modes, one where items slide up, and the other
* where items slide down. The perspective is also inverted between these two modes.
*/
private static final int ITEMS_SLIDE_UP = 0;
private static final int ITEMS_SLIDE_DOWN = 1;
/**
* These specify the different gesture states
*/
private static final int GESTURE_NONE = 0;
private static final int GESTURE_SLIDE_UP = 1;
private static final int GESTURE_SLIDE_DOWN = 2;
/**
* Specifies how far you need to swipe (up or down) before it
* will be consider a completed gesture when you lift your finger
*/
private static final float SWIPE_THRESHOLD_RATIO = 0.2f;
/**
* Specifies the total distance, relative to the size of the stack,
* that views will be slid, either up or down
*/
private static final float SLIDE_UP_RATIO = 0.7f;
/**
* Sentinel value for no current active pointer.
* Used by {@link #mActivePointerId}.
*/
private static final int INVALID_POINTER = -1;
/**
* Number of active views in the stack. One fewer view is actually visible, as one is hidden.
*/
private static final int NUM_ACTIVE_VIEWS = 5;
private static final int FRAME_PADDING = 4;
private final Rect mTouchRect = new Rect();
private static final int MIN_TIME_BETWEEN_INTERACTION_AND_AUTOADVANCE = 5000;
private static final long MIN_TIME_BETWEEN_SCROLLS = 100;
/**
* These variables are all related to the current state of touch interaction
* with the stack
*/
private float mInitialY;
private float mInitialX;
private int mActivePointerId;
private int mYVelocity = 0;
private int mSwipeGestureType = GESTURE_NONE;
private int mSlideAmount;
private int mSwipeThreshold;
private int mTouchSlop;
private int mMaximumVelocity;
private VelocityTracker mVelocityTracker;
private boolean mTransitionIsSetup = false;
private int mResOutColor;
private int mClickColor;
private static HolographicHelper sHolographicHelper;
private ImageView mHighlight;
private ImageView mClickFeedback;
private boolean mClickFeedbackIsValid = false;
private StackSlider mStackSlider;
private boolean mFirstLayoutHappened = false;
private long mLastInteractionTime = 0;
private long mLastScrollTime;
private int mStackMode;
private int mFramePadding;
private final Rect stackInvalidateRect = new Rect();
/**
* {@inheritDoc}
*/
public StackView(Context context) {
this(context, null);
}
/**
* {@inheritDoc}
*/
public StackView(Context context, AttributeSet attrs) {
this(context, attrs, com.android.internal.R.attr.stackViewStyle);
}
/**
* {@inheritDoc}
*/
public StackView(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}
/**
* {@inheritDoc}
*/
public StackView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
final TypedArray a = context.obtainStyledAttributes(
attrs, com.android.internal.R.styleable.StackView, defStyleAttr, defStyleRes);
mResOutColor = a.getColor(
com.android.internal.R.styleable.StackView_resOutColor, 0);
mClickColor = a.getColor(
com.android.internal.R.styleable.StackView_clickColor, 0);
a.recycle();
initStackView();
}
private void initStackView() {
configureViewAnimator(NUM_ACTIVE_VIEWS, 1);
setStaticTransformationsEnabled(true);
final ViewConfiguration configuration = ViewConfiguration.get(getContext());
mTouchSlop = configuration.getScaledTouchSlop();
mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
mActivePointerId = INVALID_POINTER;
mHighlight = new ImageView(getContext());
mHighlight.setLayoutParams(new LayoutParams(mHighlight));
addViewInLayout(mHighlight, -1, new LayoutParams(mHighlight));
mClickFeedback = new ImageView(getContext());
mClickFeedback.setLayoutParams(new LayoutParams(mClickFeedback));
addViewInLayout(mClickFeedback, -1, new LayoutParams(mClickFeedback));
mClickFeedback.setVisibility(INVISIBLE);
mStackSlider = new StackSlider();
if (sHolographicHelper == null) {
sHolographicHelper = new HolographicHelper(mContext);
}
setClipChildren(false);
setClipToPadding(false);
// This sets the form of the StackView, which is currently to have the perspective-shifted
// views above the active view, and have items slide down when sliding out. The opposite is
// available by using ITEMS_SLIDE_UP.
mStackMode = ITEMS_SLIDE_DOWN;
// This is a flag to indicate the the stack is loading for the first time
mWhichChild = -1;
// Adjust the frame padding based on the density, since the highlight changes based
// on the density
final float density = mContext.getResources().getDisplayMetrics().density;
mFramePadding = (int) Math.ceil(density * FRAME_PADDING);
}
/**
* Animate the views between different relative indexes within the {@link AdapterViewAnimator}
*/
void transformViewForTransition(int fromIndex, int toIndex, final View view, boolean animate) {
if (!animate) {
((StackFrame) view).cancelSliderAnimator();
view.setRotationX(0f);
LayoutParams lp = (LayoutParams) view.getLayoutParams();
lp.setVerticalOffset(0);
lp.setHorizontalOffset(0);
}
if (fromIndex == -1 && toIndex == getNumActiveViews() -1) {
transformViewAtIndex(toIndex, view, false);
view.setVisibility(VISIBLE);
view.setAlpha(1.0f);
} else if (fromIndex == 0 && toIndex == 1) {
// Slide item in
((StackFrame) view).cancelSliderAnimator();
view.setVisibility(VISIBLE);
int duration = Math.round(mStackSlider.getDurationForNeutralPosition(mYVelocity));
StackSlider animationSlider = new StackSlider(mStackSlider);
animationSlider.setView(view);
if (animate) {
PropertyValuesHolder slideInY = PropertyValuesHolder.ofFloat("YProgress", 0.0f);
PropertyValuesHolder slideInX = PropertyValuesHolder.ofFloat("XProgress", 0.0f);
ObjectAnimator slideIn = ObjectAnimator.ofPropertyValuesHolder(animationSlider,
slideInX, slideInY);
slideIn.setDuration(duration);
slideIn.setInterpolator(new LinearInterpolator());
((StackFrame) view).setSliderAnimator(slideIn);
slideIn.start();
} else {
animationSlider.setYProgress(0f);
animationSlider.setXProgress(0f);
}
} else if (fromIndex == 1 && toIndex == 0) {
// Slide item out
((StackFrame) view).cancelSliderAnimator();
int duration = Math.round(mStackSlider.getDurationForOffscreenPosition(mYVelocity));
StackSlider animationSlider = new StackSlider(mStackSlider);
animationSlider.setView(view);
if (animate) {
PropertyValuesHolder slideOutY = PropertyValuesHolder.ofFloat("YProgress", 1.0f);
PropertyValuesHolder slideOutX = PropertyValuesHolder.ofFloat("XProgress", 0.0f);
ObjectAnimator slideOut = ObjectAnimator.ofPropertyValuesHolder(animationSlider,
slideOutX, slideOutY);
slideOut.setDuration(duration);
slideOut.setInterpolator(new LinearInterpolator());
((StackFrame) view).setSliderAnimator(slideOut);
slideOut.start();
} else {
animationSlider.setYProgress(1.0f);
animationSlider.setXProgress(0f);
}
} else if (toIndex == 0) {
// Make sure this view that is "waiting in the wings" is invisible
view.setAlpha(0.0f);
view.setVisibility(INVISIBLE);
} else if ((fromIndex == 0 || fromIndex == 1) && toIndex > 1) {
view.setVisibility(VISIBLE);
view.setAlpha(1.0f);
view.setRotationX(0f);
LayoutParams lp = (LayoutParams) view.getLayoutParams();
lp.setVerticalOffset(0);
lp.setHorizontalOffset(0);
} else if (fromIndex == -1) {
view.setAlpha(1.0f);
view.setVisibility(VISIBLE);
} else if (toIndex == -1) {
if (animate) {
postDelayed(new Runnable() {
public void run() {
view.setAlpha(0);
}
}, STACK_RELAYOUT_DURATION);
} else {
view.setAlpha(0f);
}
}
// Implement the faked perspective
if (toIndex != -1) {
transformViewAtIndex(toIndex, view, animate);
}
}
private void transformViewAtIndex(int index, final View view, boolean animate) {
final float maxPerspectiveShiftY = mPerspectiveShiftY;
final float maxPerspectiveShiftX = mPerspectiveShiftX;
if (mStackMode == ITEMS_SLIDE_DOWN) {
index = mMaxNumActiveViews - index - 1;
if (index == mMaxNumActiveViews - 1) index--;
} else {
index--;
if (index < 0) index++;
}
float r = (index * 1.0f) / (mMaxNumActiveViews - 2);
final float scale = 1 - PERSPECTIVE_SCALE_FACTOR * (1 - r);
float perspectiveTranslationY = r * maxPerspectiveShiftY;
float scaleShiftCorrectionY = (scale - 1) *
(getMeasuredHeight() * (1 - PERSPECTIVE_SHIFT_FACTOR_Y) / 2.0f);
final float transY = perspectiveTranslationY + scaleShiftCorrectionY;
float perspectiveTranslationX = (1 - r) * maxPerspectiveShiftX;
float scaleShiftCorrectionX = (1 - scale) *
(getMeasuredWidth() * (1 - PERSPECTIVE_SHIFT_FACTOR_X) / 2.0f);
final float transX = perspectiveTranslationX + scaleShiftCorrectionX;
// If this view is currently being animated for a certain position, we need to cancel
// this animation so as not to interfere with the new transformation.
if (view instanceof StackFrame) {
((StackFrame) view).cancelTransformAnimator();
}
if (animate) {
PropertyValuesHolder translationX = PropertyValuesHolder.ofFloat("translationX", transX);
PropertyValuesHolder translationY = PropertyValuesHolder.ofFloat("translationY", transY);
PropertyValuesHolder scalePropX = PropertyValuesHolder.ofFloat("scaleX", scale);
PropertyValuesHolder scalePropY = PropertyValuesHolder.ofFloat("scaleY", scale);
ObjectAnimator oa = ObjectAnimator.ofPropertyValuesHolder(view, scalePropX, scalePropY,
translationY, translationX);
oa.setDuration(STACK_RELAYOUT_DURATION);
if (view instanceof StackFrame) {
((StackFrame) view).setTransformAnimator(oa);
}
oa.start();
} else {
view.setTranslationX(transX);
view.setTranslationY(transY);
view.setScaleX(scale);
view.setScaleY(scale);
}
}
private void setupStackSlider(View v, int mode) {
mStackSlider.setMode(mode);
if (v != null) {
mHighlight.setImageBitmap(sHolographicHelper.createResOutline(v, mResOutColor));
mHighlight.setRotation(v.getRotation());
mHighlight.setTranslationY(v.getTranslationY());
mHighlight.setTranslationX(v.getTranslationX());
mHighlight.bringToFront();
v.bringToFront();
mStackSlider.setView(v);
v.setVisibility(VISIBLE);
}
}
/**
* {@inheritDoc}
*/
@Override
@android.view.RemotableViewMethod
public void showNext() {
if (mSwipeGestureType != GESTURE_NONE) return;
if (!mTransitionIsSetup) {
View v = getViewAtRelativeIndex(1);
if (v != null) {
setupStackSlider(v, StackSlider.NORMAL_MODE);
mStackSlider.setYProgress(0);
mStackSlider.setXProgress(0);
}
}
super.showNext();
}
/**
* {@inheritDoc}
*/
@Override
@android.view.RemotableViewMethod
public void showPrevious() {
if (mSwipeGestureType != GESTURE_NONE) return;
if (!mTransitionIsSetup) {
View v = getViewAtRelativeIndex(0);
if (v != null) {
setupStackSlider(v, StackSlider.NORMAL_MODE);
mStackSlider.setYProgress(1);
mStackSlider.setXProgress(0);
}
}
super.showPrevious();
}
@Override
void showOnly(int childIndex, boolean animate) {
super.showOnly(childIndex, animate);
// Here we need to make sure that the z-order of the children is correct
for (int i = mCurrentWindowEnd; i >= mCurrentWindowStart; i--) {
int index = modulo(i, getWindowSize());
ViewAndMetaData vm = mViewsMap.get(index);
if (vm != null) {
View v = mViewsMap.get(index).view;
if (v != null) v.bringToFront();
}
}
if (mHighlight != null) {
mHighlight.bringToFront();
}
mTransitionIsSetup = false;
mClickFeedbackIsValid = false;
}
void updateClickFeedback() {
if (!mClickFeedbackIsValid) {
View v = getViewAtRelativeIndex(1);
if (v != null) {
mClickFeedback.setImageBitmap(
sHolographicHelper.createClickOutline(v, mClickColor));
mClickFeedback.setTranslationX(v.getTranslationX());
mClickFeedback.setTranslationY(v.getTranslationY());
}
mClickFeedbackIsValid = true;
}
}
@Override
void showTapFeedback(View v) {
updateClickFeedback();
mClickFeedback.setVisibility(VISIBLE);
mClickFeedback.bringToFront();
invalidate();
}
@Override
void hideTapFeedback(View v) {
mClickFeedback.setVisibility(INVISIBLE);
invalidate();
}
private void updateChildTransforms() {
for (int i = 0; i < getNumActiveViews(); i++) {
View v = getViewAtRelativeIndex(i);
if (v != null) {
transformViewAtIndex(i, v, false);
}
}
}
private static class StackFrame extends FrameLayout {
WeakReference<ObjectAnimator> transformAnimator;
WeakReference<ObjectAnimator> sliderAnimator;
public StackFrame(Context context) {
super(context);
}
void setTransformAnimator(ObjectAnimator oa) {
transformAnimator = new WeakReference<ObjectAnimator>(oa);
}
void setSliderAnimator(ObjectAnimator oa) {
sliderAnimator = new WeakReference<ObjectAnimator>(oa);
}
boolean cancelTransformAnimator() {
if (transformAnimator != null) {
ObjectAnimator oa = transformAnimator.get();
if (oa != null) {
oa.cancel();
return true;
}
}
return false;
}
boolean cancelSliderAnimator() {
if (sliderAnimator != null) {
ObjectAnimator oa = sliderAnimator.get();
if (oa != null) {
oa.cancel();
return true;
}
}
return false;
}
}
@Override
FrameLayout getFrameForChild() {
StackFrame fl = new StackFrame(mContext);
fl.setPadding(mFramePadding, mFramePadding, mFramePadding, mFramePadding);
return fl;
}
/**
* Apply any necessary tranforms for the child that is being added.
*/
void applyTransformForChildAtIndex(View child, int relativeIndex) {
}
@Override
protected void dispatchDraw(Canvas canvas) {
boolean expandClipRegion = false;
canvas.getClipBounds(stackInvalidateRect);
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
LayoutParams lp = (LayoutParams) child.getLayoutParams();
if ((lp.horizontalOffset == 0 && lp.verticalOffset == 0) ||
child.getAlpha() == 0f || child.getVisibility() != VISIBLE) {
lp.resetInvalidateRect();
}
Rect childInvalidateRect = lp.getInvalidateRect();
if (!childInvalidateRect.isEmpty()) {
expandClipRegion = true;
stackInvalidateRect.union(childInvalidateRect);
}
}
// We only expand the clip bounds if necessary.
if (expandClipRegion) {
canvas.save(Canvas.CLIP_SAVE_FLAG);
canvas.clipRect(stackInvalidateRect, Region.Op.UNION);
super.dispatchDraw(canvas);
canvas.restore();
} else {
super.dispatchDraw(canvas);
}
}
private void onLayout() {
if (!mFirstLayoutHappened) {
mFirstLayoutHappened = true;
updateChildTransforms();
}
final int newSlideAmount = Math.round(SLIDE_UP_RATIO * getMeasuredHeight());
if (mSlideAmount != newSlideAmount) {
mSlideAmount = newSlideAmount;
mSwipeThreshold = Math.round(SWIPE_THRESHOLD_RATIO * newSlideAmount);
}
if (Float.compare(mPerspectiveShiftY, mNewPerspectiveShiftY) != 0 ||
Float.compare(mPerspectiveShiftX, mNewPerspectiveShiftX) != 0) {
mPerspectiveShiftY = mNewPerspectiveShiftY;
mPerspectiveShiftX = mNewPerspectiveShiftX;
updateChildTransforms();
}
}
@Override
public boolean onGenericMotionEvent(MotionEvent event) {
if ((event.getSource() & InputDevice.SOURCE_CLASS_POINTER) != 0) {
switch (event.getAction()) {
case MotionEvent.ACTION_SCROLL: {
final float vscroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
if (vscroll < 0) {
pacedScroll(false);
return true;
} else if (vscroll > 0) {
pacedScroll(true);
return true;
}
}
}
}
return super.onGenericMotionEvent(event);
}
// This ensures that the frequency of stack flips caused by scrolls is capped
private void pacedScroll(boolean up) {
long timeSinceLastScroll = System.currentTimeMillis() - mLastScrollTime;
if (timeSinceLastScroll > MIN_TIME_BETWEEN_SCROLLS) {
if (up) {
showPrevious();
} else {
showNext();
}
mLastScrollTime = System.currentTimeMillis();
}
}
/**
* {@inheritDoc}
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction();
switch(action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN: {
if (mActivePointerId == INVALID_POINTER) {
mInitialX = ev.getX();
mInitialY = ev.getY();
mActivePointerId = ev.getPointerId(0);
}
break;
}
case MotionEvent.ACTION_MOVE: {
int pointerIndex = ev.findPointerIndex(mActivePointerId);
if (pointerIndex == INVALID_POINTER) {
// no data for our primary pointer, this shouldn't happen, log it
Log.d(TAG, "Error: No data for our primary pointer.");
return false;
}
float newY = ev.getY(pointerIndex);
float deltaY = newY - mInitialY;
beginGestureIfNeeded(deltaY);
break;
}
case MotionEvent.ACTION_POINTER_UP: {
onSecondaryPointerUp(ev);
break;
}
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
mActivePointerId = INVALID_POINTER;
mSwipeGestureType = GESTURE_NONE;
}
}
return mSwipeGestureType != GESTURE_NONE;
}
private void beginGestureIfNeeded(float deltaY) {
if ((int) Math.abs(deltaY) > mTouchSlop && mSwipeGestureType == GESTURE_NONE) {
final int swipeGestureType = deltaY < 0 ? GESTURE_SLIDE_UP : GESTURE_SLIDE_DOWN;
cancelLongPress();
requestDisallowInterceptTouchEvent(true);
if (mAdapter == null) return;
final int adapterCount = getCount();
int activeIndex;
if (mStackMode == ITEMS_SLIDE_UP) {
activeIndex = (swipeGestureType == GESTURE_SLIDE_DOWN) ? 0 : 1;
} else {
activeIndex = (swipeGestureType == GESTURE_SLIDE_DOWN) ? 1 : 0;
}
boolean endOfStack = mLoopViews && adapterCount == 1 &&
((mStackMode == ITEMS_SLIDE_UP && swipeGestureType == GESTURE_SLIDE_UP) ||
(mStackMode == ITEMS_SLIDE_DOWN && swipeGestureType == GESTURE_SLIDE_DOWN));
boolean beginningOfStack = mLoopViews && adapterCount == 1 &&
((mStackMode == ITEMS_SLIDE_DOWN && swipeGestureType == GESTURE_SLIDE_UP) ||
(mStackMode == ITEMS_SLIDE_UP && swipeGestureType == GESTURE_SLIDE_DOWN));
int stackMode;
if (mLoopViews && !beginningOfStack && !endOfStack) {
stackMode = StackSlider.NORMAL_MODE;
} else if (mCurrentWindowStartUnbounded + activeIndex == -1 || beginningOfStack) {
activeIndex++;
stackMode = StackSlider.BEGINNING_OF_STACK_MODE;
} else if (mCurrentWindowStartUnbounded + activeIndex == adapterCount - 1 || endOfStack) {
stackMode = StackSlider.END_OF_STACK_MODE;
} else {
stackMode = StackSlider.NORMAL_MODE;
}
mTransitionIsSetup = stackMode == StackSlider.NORMAL_MODE;
View v = getViewAtRelativeIndex(activeIndex);
if (v == null) return;
setupStackSlider(v, stackMode);
// We only register this gesture if we've made it this far without a problem
mSwipeGestureType = swipeGestureType;
cancelHandleClick();
}
}
/**
* {@inheritDoc}
*/
@Override
public boolean onTouchEvent(MotionEvent ev) {
super.onTouchEvent(ev);
int action = ev.getAction();
int pointerIndex = ev.findPointerIndex(mActivePointerId);
if (pointerIndex == INVALID_POINTER) {
// no data for our primary pointer, this shouldn't happen, log it
Log.d(TAG, "Error: No data for our primary pointer.");
return false;
}
float newY = ev.getY(pointerIndex);
float newX = ev.getX(pointerIndex);
float deltaY = newY - mInitialY;
float deltaX = newX - mInitialX;
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(ev);
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_MOVE: {
beginGestureIfNeeded(deltaY);
float rx = deltaX / (mSlideAmount * 1.0f);
if (mSwipeGestureType == GESTURE_SLIDE_DOWN) {
float r = (deltaY - mTouchSlop * 1.0f) / mSlideAmount * 1.0f;
if (mStackMode == ITEMS_SLIDE_DOWN) r = 1 - r;
mStackSlider.setYProgress(1 - r);
mStackSlider.setXProgress(rx);
return true;
} else if (mSwipeGestureType == GESTURE_SLIDE_UP) {
float r = -(deltaY + mTouchSlop * 1.0f) / mSlideAmount * 1.0f;
if (mStackMode == ITEMS_SLIDE_DOWN) r = 1 - r;
mStackSlider.setYProgress(r);
mStackSlider.setXProgress(rx);
return true;
}
break;
}
case MotionEvent.ACTION_UP: {
handlePointerUp(ev);
break;
}
case MotionEvent.ACTION_POINTER_UP: {
onSecondaryPointerUp(ev);
break;
}
case MotionEvent.ACTION_CANCEL: {
mActivePointerId = INVALID_POINTER;
mSwipeGestureType = GESTURE_NONE;
break;
}
}
return true;
}
private void onSecondaryPointerUp(MotionEvent ev) {
final int activePointerIndex = ev.getActionIndex();
final int pointerId = ev.getPointerId(activePointerIndex);
if (pointerId == mActivePointerId) {
int activeViewIndex = (mSwipeGestureType == GESTURE_SLIDE_DOWN) ? 0 : 1;
View v = getViewAtRelativeIndex(activeViewIndex);
if (v == null) return;
// Our primary pointer has gone up -- let's see if we can find
// another pointer on the view. If so, then we should replace
// our primary pointer with this new pointer and adjust things
// so that the view doesn't jump
for (int index = 0; index < ev.getPointerCount(); index++) {
if (index != activePointerIndex) {
float x = ev.getX(index);
float y = ev.getY(index);
mTouchRect.set(v.getLeft(), v.getTop(), v.getRight(), v.getBottom());
if (mTouchRect.contains(Math.round(x), Math.round(y))) {
float oldX = ev.getX(activePointerIndex);
float oldY = ev.getY(activePointerIndex);
// adjust our frame of reference to avoid a jump
mInitialY += (y - oldY);
mInitialX += (x - oldX);
mActivePointerId = ev.getPointerId(index);
if (mVelocityTracker != null) {
mVelocityTracker.clear();
}
// ok, we're good, we found a new pointer which is touching the active view
return;
}
}
}
// if we made it this far, it means we didn't find a satisfactory new pointer :(,
// so end the gesture
handlePointerUp(ev);
}
}
private void handlePointerUp(MotionEvent ev) {
int pointerIndex = ev.findPointerIndex(mActivePointerId);
float newY = ev.getY(pointerIndex);
int deltaY = (int) (newY - mInitialY);
mLastInteractionTime = System.currentTimeMillis();
if (mVelocityTracker != null) {
mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
mYVelocity = (int) mVelocityTracker.getYVelocity(mActivePointerId);
}
if (mVelocityTracker != null) {
mVelocityTracker.recycle();
mVelocityTracker = null;
}
if (deltaY > mSwipeThreshold && mSwipeGestureType == GESTURE_SLIDE_DOWN
&& mStackSlider.mMode == StackSlider.NORMAL_MODE) {
// We reset the gesture variable, because otherwise we will ignore showPrevious() /
// showNext();
mSwipeGestureType = GESTURE_NONE;
// Swipe threshold exceeded, swipe down
if (mStackMode == ITEMS_SLIDE_UP) {
showPrevious();
} else {
showNext();
}
mHighlight.bringToFront();
} else if (deltaY < -mSwipeThreshold && mSwipeGestureType == GESTURE_SLIDE_UP
&& mStackSlider.mMode == StackSlider.NORMAL_MODE) {
// We reset the gesture variable, because otherwise we will ignore showPrevious() /
// showNext();
mSwipeGestureType = GESTURE_NONE;
// Swipe threshold exceeded, swipe up
if (mStackMode == ITEMS_SLIDE_UP) {
showNext();
} else {
showPrevious();
}
mHighlight.bringToFront();
} else if (mSwipeGestureType == GESTURE_SLIDE_UP ) {
// Didn't swipe up far enough, snap back down
int duration;
float finalYProgress = (mStackMode == ITEMS_SLIDE_DOWN) ? 1 : 0;
if (mStackMode == ITEMS_SLIDE_UP || mStackSlider.mMode != StackSlider.NORMAL_MODE) {
duration = Math.round(mStackSlider.getDurationForNeutralPosition());
} else {
duration = Math.round(mStackSlider.getDurationForOffscreenPosition());
}
StackSlider animationSlider = new StackSlider(mStackSlider);
PropertyValuesHolder snapBackY = PropertyValuesHolder.ofFloat("YProgress", finalYProgress);
PropertyValuesHolder snapBackX = PropertyValuesHolder.ofFloat("XProgress", 0.0f);
ObjectAnimator pa = ObjectAnimator.ofPropertyValuesHolder(animationSlider,
snapBackX, snapBackY);
pa.setDuration(duration);
pa.setInterpolator(new LinearInterpolator());
pa.start();
} else if (mSwipeGestureType == GESTURE_SLIDE_DOWN) {
// Didn't swipe down far enough, snap back up
float finalYProgress = (mStackMode == ITEMS_SLIDE_DOWN) ? 0 : 1;
int duration;
if (mStackMode == ITEMS_SLIDE_DOWN || mStackSlider.mMode != StackSlider.NORMAL_MODE) {
duration = Math.round(mStackSlider.getDurationForNeutralPosition());
} else {
duration = Math.round(mStackSlider.getDurationForOffscreenPosition());
}
StackSlider animationSlider = new StackSlider(mStackSlider);
PropertyValuesHolder snapBackY =
PropertyValuesHolder.ofFloat("YProgress",finalYProgress);
PropertyValuesHolder snapBackX = PropertyValuesHolder.ofFloat("XProgress", 0.0f);
ObjectAnimator pa = ObjectAnimator.ofPropertyValuesHolder(animationSlider,
snapBackX, snapBackY);
pa.setDuration(duration);
pa.start();
}
mActivePointerId = INVALID_POINTER;
mSwipeGestureType = GESTURE_NONE;
}
private class StackSlider {
View mView;
float mYProgress;
float mXProgress;
static final int NORMAL_MODE = 0;
static final int BEGINNING_OF_STACK_MODE = 1;
static final int END_OF_STACK_MODE = 2;
int mMode = NORMAL_MODE;
public StackSlider() {
}
public StackSlider(StackSlider copy) {
mView = copy.mView;
mYProgress = copy.mYProgress;
mXProgress = copy.mXProgress;
mMode = copy.mMode;
}
private float cubic(float r) {
return (float) (Math.pow(2 * r - 1, 3) + 1) / 2.0f;
}
private float highlightAlphaInterpolator(float r) {
float pivot = 0.4f;
if (r < pivot) {
return 0.85f * cubic(r / pivot);
} else {
return 0.85f * cubic(1 - (r - pivot) / (1 - pivot));
}
}
private float viewAlphaInterpolator(float r) {
float pivot = 0.3f;
if (r > pivot) {
return (r - pivot) / (1 - pivot);
} else {
return 0;
}
}
private float rotationInterpolator(float r) {
float pivot = 0.2f;
if (r < pivot) {
return 0;
} else {
return (r - pivot) / (1 - pivot);
}
}
void setView(View v) {
mView = v;
}
public void setYProgress(float r) {
// enforce r between 0 and 1
r = Math.min(1.0f, r);
r = Math.max(0, r);
mYProgress = r;
if (mView == null) return;
final LayoutParams viewLp = (LayoutParams) mView.getLayoutParams();
final LayoutParams highlightLp = (LayoutParams) mHighlight.getLayoutParams();
int stackDirection = (mStackMode == ITEMS_SLIDE_UP) ? 1 : -1;
// We need to prevent any clipping issues which may arise by setting a layer type.
// This doesn't come for free however, so we only want to enable it when required.
if (Float.compare(0f, mYProgress) != 0 && Float.compare(1.0f, mYProgress) != 0) {
if (mView.getLayerType() == LAYER_TYPE_NONE) {
mView.setLayerType(LAYER_TYPE_HARDWARE, null);
}
} else {
if (mView.getLayerType() != LAYER_TYPE_NONE) {
mView.setLayerType(LAYER_TYPE_NONE, null);
}
}
switch (mMode) {
case NORMAL_MODE:
viewLp.setVerticalOffset(Math.round(-r * stackDirection * mSlideAmount));
highlightLp.setVerticalOffset(Math.round(-r * stackDirection * mSlideAmount));
mHighlight.setAlpha(highlightAlphaInterpolator(r));
float alpha = viewAlphaInterpolator(1 - r);
// We make sure that views which can't be seen (have 0 alpha) are also invisible
// so that they don't interfere with click events.
if (mView.getAlpha() == 0 && alpha != 0 && mView.getVisibility() != VISIBLE) {
mView.setVisibility(VISIBLE);
} else if (alpha == 0 && mView.getAlpha() != 0
&& mView.getVisibility() == VISIBLE) {
mView.setVisibility(INVISIBLE);
}
mView.setAlpha(alpha);
mView.setRotationX(stackDirection * 90.0f * rotationInterpolator(r));
mHighlight.setRotationX(stackDirection * 90.0f * rotationInterpolator(r));
break;
case END_OF_STACK_MODE:
r = r * 0.2f;
viewLp.setVerticalOffset(Math.round(-stackDirection * r * mSlideAmount));
highlightLp.setVerticalOffset(Math.round(-stackDirection * r * mSlideAmount));
mHighlight.setAlpha(highlightAlphaInterpolator(r));
break;
case BEGINNING_OF_STACK_MODE:
r = (1-r) * 0.2f;
viewLp.setVerticalOffset(Math.round(stackDirection * r * mSlideAmount));
highlightLp.setVerticalOffset(Math.round(stackDirection * r * mSlideAmount));
mHighlight.setAlpha(highlightAlphaInterpolator(r));
break;
}
}
public void setXProgress(float r) {
// enforce r between 0 and 1
r = Math.min(2.0f, r);
r = Math.max(-2.0f, r);
mXProgress = r;
if (mView == null) return;
final LayoutParams viewLp = (LayoutParams) mView.getLayoutParams();
final LayoutParams highlightLp = (LayoutParams) mHighlight.getLayoutParams();
r *= 0.2f;
viewLp.setHorizontalOffset(Math.round(r * mSlideAmount));
highlightLp.setHorizontalOffset(Math.round(r * mSlideAmount));
}
void setMode(int mode) {
mMode = mode;
}
float getDurationForNeutralPosition() {
return getDuration(false, 0);
}
float getDurationForOffscreenPosition() {
return getDuration(true, 0);
}
float getDurationForNeutralPosition(float velocity) {
return getDuration(false, velocity);
}
float getDurationForOffscreenPosition(float velocity) {
return getDuration(true, velocity);
}
private float getDuration(boolean invert, float velocity) {
if (mView != null) {
final LayoutParams viewLp = (LayoutParams) mView.getLayoutParams();
float d = (float) Math.hypot(viewLp.horizontalOffset, viewLp.verticalOffset);
float maxd = (float) Math.hypot(mSlideAmount, 0.4f * mSlideAmount);
if (velocity == 0) {
return (invert ? (1 - d / maxd) : d / maxd) * DEFAULT_ANIMATION_DURATION;
} else {
float duration = invert ? d / Math.abs(velocity) :
(maxd - d) / Math.abs(velocity);
if (duration < MINIMUM_ANIMATION_DURATION ||
duration > DEFAULT_ANIMATION_DURATION) {
return getDuration(invert, 0);
} else {
return duration;
}
}
}
return 0;
}
// Used for animations
@SuppressWarnings({"UnusedDeclaration"})
public float getYProgress() {
return mYProgress;
}
// Used for animations
@SuppressWarnings({"UnusedDeclaration"})
public float getXProgress() {
return mXProgress;
}
}
LayoutParams createOrReuseLayoutParams(View v) {
final ViewGroup.LayoutParams currentLp = v.getLayoutParams();
if (currentLp instanceof LayoutParams) {
LayoutParams lp = (LayoutParams) currentLp;
lp.setHorizontalOffset(0);
lp.setVerticalOffset(0);
lp.width = 0;
lp.width = 0;
return lp;
}
return new LayoutParams(v);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
checkForAndHandleDataChanged();
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
int childRight = mPaddingLeft + child.getMeasuredWidth();
int childBottom = mPaddingTop + child.getMeasuredHeight();
LayoutParams lp = (LayoutParams) child.getLayoutParams();
child.layout(mPaddingLeft + lp.horizontalOffset, mPaddingTop + lp.verticalOffset,
childRight + lp.horizontalOffset, childBottom + lp.verticalOffset);
}
onLayout();
}
@Override
public void advance() {
long timeSinceLastInteraction = System.currentTimeMillis() - mLastInteractionTime;
if (mAdapter == null) return;
final int adapterCount = getCount();
if (adapterCount == 1 && mLoopViews) return;
if (mSwipeGestureType == GESTURE_NONE &&
timeSinceLastInteraction > MIN_TIME_BETWEEN_INTERACTION_AND_AUTOADVANCE) {
showNext();
}
}
private void measureChildren() {
final int count = getChildCount();
final int measuredWidth = getMeasuredWidth();
final int measuredHeight = getMeasuredHeight();
final int childWidth = Math.round(measuredWidth*(1-PERSPECTIVE_SHIFT_FACTOR_X))
- mPaddingLeft - mPaddingRight;
final int childHeight = Math.round(measuredHeight*(1-PERSPECTIVE_SHIFT_FACTOR_Y))
- mPaddingTop - mPaddingBottom;
int maxWidth = 0;
int maxHeight = 0;
for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
child.measure(MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.AT_MOST),
MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.AT_MOST));
if (child != mHighlight && child != mClickFeedback) {
final int childMeasuredWidth = child.getMeasuredWidth();
final int childMeasuredHeight = child.getMeasuredHeight();
if (childMeasuredWidth > maxWidth) {
maxWidth = childMeasuredWidth;
}
if (childMeasuredHeight > maxHeight) {
maxHeight = childMeasuredHeight;
}
}
}
mNewPerspectiveShiftX = PERSPECTIVE_SHIFT_FACTOR_X * measuredWidth;
mNewPerspectiveShiftY = PERSPECTIVE_SHIFT_FACTOR_Y * measuredHeight;
// If we have extra space, we try and spread the items out
if (maxWidth > 0 && count > 0 && maxWidth < childWidth) {
mNewPerspectiveShiftX = measuredWidth - maxWidth;
}
if (maxHeight > 0 && count > 0 && maxHeight < childHeight) {
mNewPerspectiveShiftY = measuredHeight - maxHeight;
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
boolean haveChildRefSize = (mReferenceChildWidth != -1 && mReferenceChildHeight != -1);
// We need to deal with the case where our parent hasn't told us how
// big we should be. In this case we should
float factorY = 1/(1 - PERSPECTIVE_SHIFT_FACTOR_Y);
if (heightSpecMode == MeasureSpec.UNSPECIFIED) {
heightSpecSize = haveChildRefSize ?
Math.round(mReferenceChildHeight * (1 + factorY)) +
mPaddingTop + mPaddingBottom : 0;
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
if (haveChildRefSize) {
int height = Math.round(mReferenceChildHeight * (1 + factorY))
+ mPaddingTop + mPaddingBottom;
if (height <= heightSpecSize) {
heightSpecSize = height;
} else {
heightSpecSize |= MEASURED_STATE_TOO_SMALL;
}
} else {
heightSpecSize = 0;
}
}
float factorX = 1/(1 - PERSPECTIVE_SHIFT_FACTOR_X);
if (widthSpecMode == MeasureSpec.UNSPECIFIED) {
widthSpecSize = haveChildRefSize ?
Math.round(mReferenceChildWidth * (1 + factorX)) +
mPaddingLeft + mPaddingRight : 0;
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
if (haveChildRefSize) {
int width = mReferenceChildWidth + mPaddingLeft + mPaddingRight;
if (width <= widthSpecSize) {
widthSpecSize = width;
} else {
widthSpecSize |= MEASURED_STATE_TOO_SMALL;
}
} else {
widthSpecSize = 0;
}
}
setMeasuredDimension(widthSpecSize, heightSpecSize);
measureChildren();
}
@Override
public CharSequence getAccessibilityClassName() {
return StackView.class.getName();
}
/** @hide */
@Override
public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfoInternal(info);
info.setScrollable(getChildCount() > 1);
if (isEnabled()) {
if (getDisplayedChild() < getChildCount() - 1) {
info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
}
if (getDisplayedChild() > 0) {
info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
}
}
}
/** @hide */
@Override
public boolean performAccessibilityActionInternal(int action, Bundle arguments) {
if (super.performAccessibilityActionInternal(action, arguments)) {
return true;
}
if (!isEnabled()) {
return false;
}
switch (action) {
case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: {
if (getDisplayedChild() < getChildCount() - 1) {
showNext();
return true;
}
} return false;
case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: {
if (getDisplayedChild() > 0) {
showPrevious();
return true;
}
} return false;
}
return false;
}
class LayoutParams extends ViewGroup.LayoutParams {
int horizontalOffset;
int verticalOffset;
View mView;
private final Rect parentRect = new Rect();
private final Rect invalidateRect = new Rect();
private final RectF invalidateRectf = new RectF();
private final Rect globalInvalidateRect = new Rect();
LayoutParams(View view) {
super(0, 0);
width = 0;
height = 0;
horizontalOffset = 0;
verticalOffset = 0;
mView = view;
}
LayoutParams(Context c, AttributeSet attrs) {
super(c, attrs);
horizontalOffset = 0;
verticalOffset = 0;
width = 0;
height = 0;
}
void invalidateGlobalRegion(View v, Rect r) {
// We need to make a new rect here, so as not to modify the one passed
globalInvalidateRect.set(r);
globalInvalidateRect.union(0, 0, getWidth(), getHeight());
View p = v;
if (!(v.getParent() != null && v.getParent() instanceof View)) return;
boolean firstPass = true;
parentRect.set(0, 0, 0, 0);
while (p.getParent() != null && p.getParent() instanceof View
&& !parentRect.contains(globalInvalidateRect)) {
if (!firstPass) {
globalInvalidateRect.offset(p.getLeft() - p.getScrollX(), p.getTop()
- p.getScrollY());
}
firstPass = false;
p = (View) p.getParent();
parentRect.set(p.getScrollX(), p.getScrollY(),
p.getWidth() + p.getScrollX(), p.getHeight() + p.getScrollY());
p.invalidate(globalInvalidateRect.left, globalInvalidateRect.top,
globalInvalidateRect.right, globalInvalidateRect.bottom);
}
p.invalidate(globalInvalidateRect.left, globalInvalidateRect.top,
globalInvalidateRect.right, globalInvalidateRect.bottom);
}
Rect getInvalidateRect() {
return invalidateRect;
}
void resetInvalidateRect() {
invalidateRect.set(0, 0, 0, 0);
}
// This is public so that ObjectAnimator can access it
public void setVerticalOffset(int newVerticalOffset) {
setOffsets(horizontalOffset, newVerticalOffset);
}
public void setHorizontalOffset(int newHorizontalOffset) {
setOffsets(newHorizontalOffset, verticalOffset);
}
public void setOffsets(int newHorizontalOffset, int newVerticalOffset) {
int horizontalOffsetDelta = newHorizontalOffset - horizontalOffset;
horizontalOffset = newHorizontalOffset;
int verticalOffsetDelta = newVerticalOffset - verticalOffset;
verticalOffset = newVerticalOffset;
if (mView != null) {
mView.requestLayout();
int left = Math.min(mView.getLeft() + horizontalOffsetDelta, mView.getLeft());
int right = Math.max(mView.getRight() + horizontalOffsetDelta, mView.getRight());
int top = Math.min(mView.getTop() + verticalOffsetDelta, mView.getTop());
int bottom = Math.max(mView.getBottom() + verticalOffsetDelta, mView.getBottom());
invalidateRectf.set(left, top, right, bottom);
float xoffset = -invalidateRectf.left;
float yoffset = -invalidateRectf.top;
invalidateRectf.offset(xoffset, yoffset);
mView.getMatrix().mapRect(invalidateRectf);
invalidateRectf.offset(-xoffset, -yoffset);
invalidateRect.set((int) Math.floor(invalidateRectf.left),
(int) Math.floor(invalidateRectf.top),
(int) Math.ceil(invalidateRectf.right),
(int) Math.ceil(invalidateRectf.bottom));
invalidateGlobalRegion(mView, invalidateRect);
}
}
}
private static class HolographicHelper {
private final Paint mHolographicPaint = new Paint();
private final Paint mErasePaint = new Paint();
private final Paint mBlurPaint = new Paint();
private static final int RES_OUT = 0;
private static final int CLICK_FEEDBACK = 1;
private float mDensity;
private BlurMaskFilter mSmallBlurMaskFilter;
private BlurMaskFilter mLargeBlurMaskFilter;
private final Canvas mCanvas = new Canvas();
private final Canvas mMaskCanvas = new Canvas();
private final int[] mTmpXY = new int[2];
private final Matrix mIdentityMatrix = new Matrix();
HolographicHelper(Context context) {
mDensity = context.getResources().getDisplayMetrics().density;
mHolographicPaint.setFilterBitmap(true);
mHolographicPaint.setMaskFilter(TableMaskFilter.CreateClipTable(0, 30));
mErasePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));
mErasePaint.setFilterBitmap(true);
mSmallBlurMaskFilter = new BlurMaskFilter(2 * mDensity, BlurMaskFilter.Blur.NORMAL);
mLargeBlurMaskFilter = new BlurMaskFilter(4 * mDensity, BlurMaskFilter.Blur.NORMAL);
}
Bitmap createClickOutline(View v, int color) {
return createOutline(v, CLICK_FEEDBACK, color);
}
Bitmap createResOutline(View v, int color) {
return createOutline(v, RES_OUT, color);
}
Bitmap createOutline(View v, int type, int color) {
mHolographicPaint.setColor(color);
if (type == RES_OUT) {
mBlurPaint.setMaskFilter(mSmallBlurMaskFilter);
} else if (type == CLICK_FEEDBACK) {
mBlurPaint.setMaskFilter(mLargeBlurMaskFilter);
}
if (v.getMeasuredWidth() == 0 || v.getMeasuredHeight() == 0) {
return null;
}
Bitmap bitmap = Bitmap.createBitmap(v.getResources().getDisplayMetrics(),
v.getMeasuredWidth(), v.getMeasuredHeight(), Bitmap.Config.ARGB_8888);
mCanvas.setBitmap(bitmap);
float rotationX = v.getRotationX();
float rotation = v.getRotation();
float translationY = v.getTranslationY();
float translationX = v.getTranslationX();
v.setRotationX(0);
v.setRotation(0);
v.setTranslationY(0);
v.setTranslationX(0);
v.draw(mCanvas);
v.setRotationX(rotationX);
v.setRotation(rotation);
v.setTranslationY(translationY);
v.setTranslationX(translationX);
drawOutline(mCanvas, bitmap);
mCanvas.setBitmap(null);
return bitmap;
}
void drawOutline(Canvas dest, Bitmap src) {
final int[] xy = mTmpXY;
Bitmap mask = src.extractAlpha(mBlurPaint, xy);
mMaskCanvas.setBitmap(mask);
mMaskCanvas.drawBitmap(src, -xy[0], -xy[1], mErasePaint);
dest.drawColor(0, PorterDuff.Mode.CLEAR);
dest.setMatrix(mIdentityMatrix);
dest.drawBitmap(mask, xy[0], xy[1], mHolographicPaint);
mMaskCanvas.setBitmap(null);
mask.recycle();
}
}
}