StackView.java revision 32a42f1587db77b958d62c3de4f2734eb0a3b965
1/*
2 * Copyright (C) 2010 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.widget;
18
19import java.util.WeakHashMap;
20
21import android.animation.PropertyAnimator;
22import android.content.Context;
23import android.graphics.Bitmap;
24import android.graphics.Canvas;
25import android.graphics.Matrix;
26import android.graphics.Paint;
27import android.graphics.PorterDuff;
28import android.graphics.PorterDuffXfermode;
29import android.graphics.Rect;
30import android.util.AttributeSet;
31import android.util.Log;
32import android.view.MotionEvent;
33import android.view.VelocityTracker;
34import android.view.View;
35import android.view.ViewConfiguration;
36import android.view.ViewGroup;
37import android.widget.RemoteViews.RemoteView;
38
39@RemoteView
40/**
41 * A view that displays its children in a stack and allows users to discretely swipe
42 * through the children.
43 */
44public class StackView extends AdapterViewAnimator {
45    private final String TAG = "StackView";
46
47    /**
48     * Default animation parameters
49     */
50    private final int DEFAULT_ANIMATION_DURATION = 400;
51    private final int MINIMUM_ANIMATION_DURATION = 50;
52
53    /**
54     * These specify the different gesture states
55     */
56    private final int GESTURE_NONE = 0;
57    private final int GESTURE_SLIDE_UP = 1;
58    private final int GESTURE_SLIDE_DOWN = 2;
59
60    /**
61     * Specifies how far you need to swipe (up or down) before it
62     * will be consider a completed gesture when you lift your finger
63     */
64    private final float SWIPE_THRESHOLD_RATIO = 0.35f;
65    private final float SLIDE_UP_RATIO = 0.7f;
66
67    private final WeakHashMap<View, Float> mRotations = new WeakHashMap<View, Float>();
68    private final WeakHashMap<View, Integer>
69            mChildrenToApplyTransformsTo = new WeakHashMap<View, Integer>();
70
71    /**
72     * Sentinel value for no current active pointer.
73     * Used by {@link #mActivePointerId}.
74     */
75    private static final int INVALID_POINTER = -1;
76
77    /**
78     * These variables are all related to the current state of touch interaction
79     * with the stack
80     */
81    private float mInitialY;
82    private float mInitialX;
83    private int mActivePointerId;
84    private int mYVelocity = 0;
85    private int mSwipeGestureType = GESTURE_NONE;
86    private int mViewHeight;
87    private int mSwipeThreshold;
88    private int mTouchSlop;
89    private int mMaximumVelocity;
90    private VelocityTracker mVelocityTracker;
91
92    private ImageView mHighlight;
93    private StackSlider mStackSlider;
94    private boolean mFirstLayoutHappened = false;
95
96    // TODO: temp hack to get this thing started
97    int mIndex = 5;
98
99    public StackView(Context context) {
100        super(context);
101        initStackView();
102    }
103
104    public StackView(Context context, AttributeSet attrs) {
105        super(context, attrs);
106        initStackView();
107    }
108
109    private void initStackView() {
110        configureViewAnimator(4, 2);
111        setStaticTransformationsEnabled(true);
112        final ViewConfiguration configuration = ViewConfiguration.get(getContext());
113        mTouchSlop = configuration.getScaledTouchSlop();// + 5;
114        mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
115        mActivePointerId = INVALID_POINTER;
116
117        mHighlight = new ImageView(getContext());
118        mHighlight.setLayoutParams(new LayoutParams(mHighlight));
119        addViewInLayout(mHighlight, -1, new LayoutParams(mHighlight));
120        mStackSlider = new StackSlider();
121
122        if (!sPaintsInitialized) {
123            initializePaints(getContext());
124        }
125    }
126
127    /**
128     * Animate the views between different relative indexes within the {@link AdapterViewAnimator}
129     */
130    void animateViewForTransition(int fromIndex, int toIndex, View view) {
131        if (fromIndex == -1 && toIndex == 0) {
132            // Fade item in
133            if (view.getAlpha() == 1) {
134                view.setAlpha(0);
135            }
136            PropertyAnimator fadeIn = new PropertyAnimator(DEFAULT_ANIMATION_DURATION,
137                    view, "alpha", view.getAlpha(), 1.0f);
138            fadeIn.start();
139        } else if (fromIndex == mNumActiveViews - 1 && toIndex == mNumActiveViews - 2) {
140            // Slide item in
141            view.setVisibility(VISIBLE);
142
143            LayoutParams lp = (LayoutParams) view.getLayoutParams();
144
145            int largestDuration = (int) Math.round(
146                    (lp.verticalOffset*1.0f/-mViewHeight)*DEFAULT_ANIMATION_DURATION);
147            int duration = largestDuration;
148            if (mYVelocity != 0) {
149                duration = 1000*(0 - lp.verticalOffset)/Math.abs(mYVelocity);
150            }
151
152            duration = Math.min(duration, largestDuration);
153            duration = Math.max(duration, MINIMUM_ANIMATION_DURATION);
154
155            PropertyAnimator slideInY = new PropertyAnimator(duration, mStackSlider,
156                    "YProgress", mStackSlider.getYProgress(), 0);
157            slideInY.start();
158            PropertyAnimator slideInX = new PropertyAnimator(duration, mStackSlider,
159                    "XProgress", mStackSlider.getXProgress(), 0);
160            slideInX.start();
161
162        } else if (fromIndex == mNumActiveViews - 2 && toIndex == mNumActiveViews - 1) {
163            // Slide item out
164            LayoutParams lp = (LayoutParams) view.getLayoutParams();
165
166            int largestDuration = (int) Math.round(mStackSlider.getYProgress()*DEFAULT_ANIMATION_DURATION);
167            int duration = largestDuration;
168            if (mYVelocity != 0) {
169                duration = 1000*(lp.verticalOffset + mViewHeight)/Math.abs(mYVelocity);
170            }
171
172            duration = Math.min(duration, largestDuration);
173            duration = Math.max(duration, MINIMUM_ANIMATION_DURATION);
174
175            PropertyAnimator slideOutY = new PropertyAnimator(duration, mStackSlider,
176                    "YProgress", mStackSlider.getYProgress(), 1);
177            slideOutY.start();
178            PropertyAnimator slideOutX = new PropertyAnimator(duration, mStackSlider,
179                    "XProgress", mStackSlider.getXProgress(), 0);
180            slideOutX.start();
181
182        } else if (fromIndex == -1 && toIndex == mNumActiveViews - 1) {
183            // Make sure this view that is "waiting in the wings" is invisible
184            view.setAlpha(0.0f);
185        } else if (toIndex == -1) {
186            // Fade item out
187            PropertyAnimator fadeOut = new PropertyAnimator(DEFAULT_ANIMATION_DURATION,
188                    view, "alpha", view.getAlpha(), 0);
189            fadeOut.start();
190        }
191    }
192
193    /**
194     * Apply any necessary tranforms for the child that is being added.
195     */
196    void applyTransformForChildAtIndex(View child, int relativeIndex) {
197        float rotation;
198
199        if (!mRotations.containsKey(child)) {
200            rotation = (float) (Math.random()*26 - 13);
201            mRotations.put(child, rotation);
202        } else {
203            rotation = mRotations.get(child);
204        }
205
206        // Child has been removed
207        if (relativeIndex == -1) {
208            if (mRotations.containsKey(child)) {
209                mRotations.remove(child);
210            }
211            if (mChildrenToApplyTransformsTo.containsKey(child)) {
212                mChildrenToApplyTransformsTo.remove(child);
213            }
214        }
215
216        // if this view is already in the layout, we need to
217        // wait until layout has finished in order to set the
218        // pivot point of the rotation (requiring getMeasuredWidth/Height())
219        mChildrenToApplyTransformsTo.put(child, relativeIndex);
220    }
221
222    @Override
223    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
224        super.onLayout(changed, left, top, right, bottom);
225
226        if (!mChildrenToApplyTransformsTo.isEmpty()) {
227            for (View child: mChildrenToApplyTransformsTo.keySet()) {
228                if (mRotations.containsKey(child)) {
229                    child.setPivotX(child.getMeasuredWidth()/2);
230                    child.setPivotY(child.getMeasuredHeight()/2);
231                    child.setRotation(mRotations.get(child));
232                }
233            }
234            mChildrenToApplyTransformsTo.clear();
235        }
236
237        if (!mFirstLayoutHappened) {
238            mViewHeight = (int) Math.round(SLIDE_UP_RATIO*getMeasuredHeight());
239            mSwipeThreshold = (int) Math.round(SWIPE_THRESHOLD_RATIO*mViewHeight);
240
241            // TODO: Right now this walks all the way up the view hierarchy and disables
242            // ClipChildren and ClipToPadding. We're probably going  to want to reset
243            // these flags as well.
244            setClipChildren(false);
245            ViewGroup view = this;
246            while (view.getParent() != null && view.getParent() instanceof ViewGroup) {
247                view = (ViewGroup) view.getParent();
248                view.setClipChildren(false);
249                view.setClipToPadding(false);
250            }
251            mFirstLayoutHappened = true;
252        }
253    }
254
255    @Override
256    public boolean onInterceptTouchEvent(MotionEvent ev) {
257        int action = ev.getAction();
258        switch(action & MotionEvent.ACTION_MASK) {
259
260            case MotionEvent.ACTION_DOWN: {
261                if (mActivePointerId == INVALID_POINTER) {
262                    mInitialX = ev.getX();
263                    mInitialY = ev.getY();
264                    mActivePointerId = ev.getPointerId(0);
265                }
266                break;
267            }
268            case MotionEvent.ACTION_MOVE: {
269                int pointerIndex = ev.findPointerIndex(mActivePointerId);
270                if (pointerIndex == INVALID_POINTER) {
271                    // no data for our primary pointer, this shouldn't happen, log it
272                    Log.d(TAG, "Error: No data for our primary pointer.");
273                    return false;
274                }
275                float newY = ev.getY(pointerIndex);
276                float deltaY = newY - mInitialY;
277
278                beginGestureIfNeeded(deltaY);
279                break;
280            }
281            case MotionEvent.ACTION_POINTER_UP: {
282                onSecondaryPointerUp(ev);
283                break;
284            }
285            case MotionEvent.ACTION_UP:
286            case MotionEvent.ACTION_CANCEL: {
287                mActivePointerId = INVALID_POINTER;
288                mSwipeGestureType = GESTURE_NONE;
289            }
290        }
291
292        return mSwipeGestureType != GESTURE_NONE;
293    }
294
295    private void beginGestureIfNeeded(float deltaY) {
296        if ((int) Math.abs(deltaY) > mTouchSlop && mSwipeGestureType == GESTURE_NONE) {
297            mSwipeGestureType = deltaY < 0 ? GESTURE_SLIDE_UP : GESTURE_SLIDE_DOWN;
298            cancelLongPress();
299            requestDisallowInterceptTouchEvent(true);
300
301            int activeIndex = mSwipeGestureType == GESTURE_SLIDE_DOWN ? mNumActiveViews - 1
302                    : mNumActiveViews - 2;
303
304            View v = getViewAtRelativeIndex(activeIndex);
305            if (v != null) {
306                mHighlight.setImageBitmap(createOutline(v));
307                mHighlight.bringToFront();
308                v.bringToFront();
309                mStackSlider.setView(v);
310                if (mSwipeGestureType == GESTURE_SLIDE_DOWN)
311                    v.setVisibility(VISIBLE);
312            }
313        }
314    }
315
316    @Override
317    public boolean onTouchEvent(MotionEvent ev) {
318        int action = ev.getAction();
319        int pointerIndex = ev.findPointerIndex(mActivePointerId);
320        if (pointerIndex == INVALID_POINTER) {
321            // no data for our primary pointer, this shouldn't happen, log it
322            Log.d(TAG, "Error: No data for our primary pointer.");
323            return false;
324        }
325
326        float newY = ev.getY(pointerIndex);
327        float newX = ev.getX(pointerIndex);
328        float deltaY = newY - mInitialY;
329        float deltaX = newX - mInitialX;
330        if (mVelocityTracker == null) {
331            mVelocityTracker = VelocityTracker.obtain();
332        }
333        mVelocityTracker.addMovement(ev);
334
335        switch (action & MotionEvent.ACTION_MASK) {
336            case MotionEvent.ACTION_MOVE: {
337                beginGestureIfNeeded(deltaY);
338
339                float rx = 0.3f*deltaX/(mViewHeight*1.0f);
340                if (mSwipeGestureType == GESTURE_SLIDE_DOWN) {
341                    float r = (deltaY-mTouchSlop*1.0f)/mViewHeight*1.0f;
342                    mStackSlider.setYProgress(1 - r);
343                    mStackSlider.setXProgress(rx);
344                    return true;
345                } else if (mSwipeGestureType == GESTURE_SLIDE_UP) {
346                    float r = -(deltaY + mTouchSlop*1.0f)/mViewHeight*1.0f;
347                    mStackSlider.setYProgress(r);
348                    mStackSlider.setXProgress(rx);
349                    return true;
350                }
351
352                break;
353            }
354            case MotionEvent.ACTION_UP: {
355                handlePointerUp(ev);
356                break;
357            }
358            case MotionEvent.ACTION_POINTER_UP: {
359                onSecondaryPointerUp(ev);
360                break;
361            }
362            case MotionEvent.ACTION_CANCEL: {
363                mActivePointerId = INVALID_POINTER;
364                mSwipeGestureType = GESTURE_NONE;
365                break;
366            }
367        }
368        return true;
369    }
370
371    private final Rect touchRect = new Rect();
372    private void onSecondaryPointerUp(MotionEvent ev) {
373        final int activePointerIndex = ev.getActionIndex();
374        final int pointerId = ev.getPointerId(activePointerIndex);
375        if (pointerId == mActivePointerId) {
376
377            int activeViewIndex = (mSwipeGestureType == GESTURE_SLIDE_DOWN) ? mNumActiveViews - 1
378                    : mNumActiveViews - 2;
379
380            View v = getViewAtRelativeIndex(activeViewIndex);
381            if (v == null) return;
382
383            // Our primary pointer has gone up -- let's see if we can find
384            // another pointer on the view. If so, then we should replace
385            // our primary pointer with this new pointer and adjust things
386            // so that the view doesn't jump
387            for (int index = 0; index < ev.getPointerCount(); index++) {
388                if (index != activePointerIndex) {
389
390                    float x = ev.getX(index);
391                    float y = ev.getY(index);
392
393                    touchRect.set(v.getLeft(), v.getTop(), v.getRight(), v.getBottom());
394                    if (touchRect.contains((int) Math.round(x), (int) Math.round(y))) {
395                        float oldX = ev.getX(activePointerIndex);
396                        float oldY = ev.getY(activePointerIndex);
397
398                        // adjust our frame of reference to avoid a jump
399                        mInitialY += (y - oldY);
400                        mInitialX += (x - oldX);
401
402                        mActivePointerId = ev.getPointerId(index);
403                        if (mVelocityTracker != null) {
404                            mVelocityTracker.clear();
405                        }
406                        // ok, we're good, we found a new pointer which is touching the active view
407                        return;
408                    }
409                }
410            }
411            // if we made it this far, it means we didn't find a satisfactory new pointer :(,
412            // so end the
413            handlePointerUp(ev);
414        }
415    }
416
417    private void handlePointerUp(MotionEvent ev) {
418        int pointerIndex = ev.findPointerIndex(mActivePointerId);
419        float newY = ev.getY(pointerIndex);
420        int deltaY = (int) (newY - mInitialY);
421
422        mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
423        mYVelocity = (int) mVelocityTracker.getYVelocity(mActivePointerId);
424
425        if (mVelocityTracker != null) {
426            mVelocityTracker.recycle();
427            mVelocityTracker = null;
428        }
429
430        if (deltaY > mSwipeThreshold && mSwipeGestureType == GESTURE_SLIDE_DOWN) {
431            // Swipe threshold exceeded, swipe down
432            showNext();
433            mHighlight.bringToFront();
434        } else if (deltaY < -mSwipeThreshold && mSwipeGestureType == GESTURE_SLIDE_UP) {
435            // Swipe threshold exceeded, swipe up
436            showPrevious();
437            mHighlight.bringToFront();
438        } else if (mSwipeGestureType == GESTURE_SLIDE_UP) {
439            // Didn't swipe up far enough, snap back down
440            int duration = (int) Math.round(mStackSlider.getYProgress()*DEFAULT_ANIMATION_DURATION);
441
442            PropertyAnimator snapBackY = new PropertyAnimator(duration, mStackSlider,
443                    "YProgress", mStackSlider.getYProgress(), 0);
444            snapBackY.start();
445            PropertyAnimator snapBackX = new PropertyAnimator(duration, mStackSlider,
446                    "XProgress", mStackSlider.getXProgress(), 0);
447            snapBackX.start();
448        } else if (mSwipeGestureType == GESTURE_SLIDE_DOWN) {
449            // Didn't swipe down far enough, snap back up
450            int duration = (int) Math.round((1 -
451                    mStackSlider.getYProgress())*DEFAULT_ANIMATION_DURATION);
452            PropertyAnimator snapBackY = new PropertyAnimator(duration, mStackSlider,
453                    "YProgress", mStackSlider.getYProgress(), 1);
454            snapBackY.start();
455            PropertyAnimator snapBackX = new PropertyAnimator(duration, mStackSlider,
456                    "XProgress", mStackSlider.getXProgress(), 0);
457            snapBackX.start();
458        }
459
460        mActivePointerId = INVALID_POINTER;
461        mSwipeGestureType = GESTURE_NONE;
462    }
463
464    private class StackSlider {
465        View mView;
466        float mYProgress;
467        float mXProgress;
468
469        private float cubic(float r) {
470            return (float) (Math.pow(2*r-1, 3) + 1)/2.0f;
471        }
472
473        private float highlightAlphaInterpolator(float r) {
474            float pivot = 0.4f;
475            if (r < pivot) {
476                return 0.85f*cubic(r/pivot);
477            } else {
478                return 0.85f*cubic(1 - (r-pivot)/(1-pivot));
479            }
480        }
481
482        private float viewAlphaInterpolator(float r) {
483            float pivot = 0.3f;
484            if (r > pivot) {
485                return (r - pivot)/(1 - pivot);
486            } else {
487                return 0;
488            }
489        }
490
491        void setView(View v) {
492            mView = v;
493        }
494
495        public void setYProgress(float r) {
496            // enforce r between 0 and 1
497            r = Math.min(1.0f, r);
498            r = Math.max(0, r);
499
500            mYProgress = r;
501
502            final LayoutParams viewLp = (LayoutParams) mView.getLayoutParams();
503            final LayoutParams highlightLp = (LayoutParams) mHighlight.getLayoutParams();
504
505            viewLp.setVerticalOffset((int) Math.round(-r*mViewHeight));
506            highlightLp.setVerticalOffset((int) Math.round(-r*mViewHeight));
507            mHighlight.setAlpha(highlightAlphaInterpolator(r));
508            mView.setAlpha(viewAlphaInterpolator(1-r));
509        }
510
511        public void setXProgress(float r) {
512            // enforce r between 0 and 1
513            r = Math.min(1.0f, r);
514            r = Math.max(-1.0f, r);
515
516            mXProgress = r;
517
518            final LayoutParams viewLp = (LayoutParams) mView.getLayoutParams();
519            final LayoutParams highlightLp = (LayoutParams) mHighlight.getLayoutParams();
520
521            viewLp.setHorizontalOffset((int) Math.round(r*mViewHeight));
522            highlightLp.setHorizontalOffset((int) Math.round(r*mViewHeight));
523        }
524
525        float getYProgress() {
526            return mYProgress;
527        }
528
529        float getXProgress() {
530            return mXProgress;
531        }
532    }
533
534    @Override
535    public void onRemoteAdapterConnected() {
536        super.onRemoteAdapterConnected();
537        setDisplayedChild(mIndex);
538    }
539
540    private static final Paint sHolographicPaint = new Paint();
541    private static final Paint sErasePaint = new Paint();
542    private static boolean sPaintsInitialized = false;
543    private static final float STROKE_WIDTH = 3.0f;
544
545    static void initializePaints(Context context) {
546        sHolographicPaint.setColor(0xff6699ff);
547        sHolographicPaint.setFilterBitmap(true);
548        sErasePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));
549        sErasePaint.setFilterBitmap(true);
550        sPaintsInitialized = true;
551    }
552
553    static Bitmap createOutline(View v) {
554        Bitmap bitmap = Bitmap.createBitmap(v.getMeasuredWidth(), v.getMeasuredHeight(),
555                Bitmap.Config.ARGB_8888);
556        Canvas canvas = new Canvas(bitmap);
557
558        canvas.concat(v.getMatrix());
559        v.draw(canvas);
560
561        Bitmap outlineBitmap = Bitmap.createBitmap(v.getMeasuredWidth(), v.getMeasuredHeight(),
562                Bitmap.Config.ARGB_8888);
563        Canvas outlineCanvas = new Canvas(outlineBitmap);
564        drawOutline(outlineCanvas, v.getMeasuredWidth(), v.getMeasuredHeight(), bitmap);
565        bitmap.recycle();
566        return outlineBitmap;
567    }
568
569    static void drawOutline(Canvas dest, int destWidth, int destHeight, Bitmap src) {
570        dest.drawColor(0, PorterDuff.Mode.CLEAR);
571
572        Bitmap mask = src.extractAlpha();
573        Matrix id = new Matrix();
574
575        Matrix m = new Matrix();
576        float xScale = STROKE_WIDTH*2/(src.getWidth());
577        float yScale = STROKE_WIDTH*2/(src.getHeight());
578        m.preScale(1+xScale, 1+yScale, src.getWidth()/2, src.getHeight()/2);
579        dest.drawBitmap(mask, m, sHolographicPaint);
580
581        dest.drawBitmap(src, id, sErasePaint);
582        mask.recycle();
583    }
584}
585