ExpandHelper.java revision e46647d28467ee9e88aafe2951a5736f494235da
1/*
2 * Copyright (C) 2012 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
17
18package com.android.systemui;
19
20import android.animation.Animator;
21import android.animation.AnimatorListenerAdapter;
22import android.animation.AnimatorSet;
23import android.animation.ObjectAnimator;
24import android.content.Context;
25import android.os.Vibrator;
26import android.util.Slog;
27import android.view.Gravity;
28import android.view.MotionEvent;
29import android.view.ScaleGestureDetector;
30import android.view.View;
31import android.view.ViewConfiguration;
32import android.view.ViewGroup;
33import android.view.View.OnClickListener;
34
35import java.util.Stack;
36
37public class ExpandHelper implements Gefingerpoken, OnClickListener {
38    public interface Callback {
39        View getChildAtRawPosition(float x, float y);
40        View getChildAtPosition(float x, float y);
41        View getPreviousChild(View currentChild);
42        boolean canChildBeExpanded(View v);
43        boolean setUserExpandedChild(View v, boolean userxpanded);
44    }
45
46    private static final String TAG = "ExpandHelper";
47    protected static final boolean DEBUG = false;
48    protected static final boolean DEBUG_SCALE = false;
49    protected static final boolean DEBUG_GLOW = false;
50    private static final long EXPAND_DURATION = 250;
51    private static final long GLOW_DURATION = 150;
52
53    // Set to false to disable focus-based gestures (two-finger pull).
54    private static final boolean USE_DRAG = true;
55    // Set to false to disable scale-based gestures (both horizontal and vertical).
56    private static final boolean USE_SPAN = true;
57    // Both gestures types may be active at the same time.
58    // At least one gesture type should be active.
59    // A variant of the screwdriver gesture will emerge from either gesture type.
60
61    // amount of overstretch for maximum brightness expressed in U
62    // 2f: maximum brightness is stretching a 1U to 3U, or a 4U to 6U
63    private static final float STRETCH_INTERVAL = 2f;
64
65    // level of glow for a touch, without overstretch
66    // overstretch fills the range (GLOW_BASE, 1.0]
67    private static final float GLOW_BASE = 0.5f;
68
69    @SuppressWarnings("unused")
70    private Context mContext;
71
72    private boolean mStretching;
73    private boolean mPullingWithOneFinger;
74    private boolean mWatchingForPull;
75    private boolean mHasPopped;
76    private View mEventSource;
77    private View mCurrView;
78    private View mCurrViewTopGlow;
79    private View mCurrViewBottomGlow;
80    private float mOldHeight;
81    private float mNaturalHeight;
82    private float mInitialTouchFocusY;
83    private float mInitialTouchY;
84    private float mInitialTouchSpan;
85    private int mTouchSlop;
86    private int mLastMotionY;
87    private float mPopLimit;
88    private int mPopDuration;
89    private Callback mCallback;
90    private ScaleGestureDetector mDetector;
91    private ViewScaler mScaler;
92    private ObjectAnimator mScaleAnimation;
93    private AnimatorSet mGlowAnimationSet;
94    private ObjectAnimator mGlowTopAnimation;
95    private ObjectAnimator mGlowBottomAnimation;
96    private Vibrator mVibrator;
97
98    private int mSmallSize;
99    private int mLargeSize;
100    private float mMaximumStretch;
101
102    private int mGravity;
103
104    private View mScrollView;
105
106    private class ViewScaler {
107        View mView;
108
109        public ViewScaler() {}
110        public void setView(View v) {
111            mView = v;
112        }
113        public void setHeight(float h) {
114            if (DEBUG_SCALE) Slog.v(TAG, "SetHeight: setting to " + h);
115            ViewGroup.LayoutParams lp = mView.getLayoutParams();
116            lp.height = (int)h;
117            mView.setLayoutParams(lp);
118            mView.requestLayout();
119        }
120        public float getHeight() {
121            int height = mView.getLayoutParams().height;
122            if (height < 0) {
123                height = mView.getMeasuredHeight();
124            }
125            return (float) height;
126        }
127        public int getNaturalHeight(int maximum) {
128            ViewGroup.LayoutParams lp = mView.getLayoutParams();
129            if (DEBUG_SCALE) Slog.v(TAG, "Inspecting a child of type: " +
130                    mView.getClass().getName());
131            int oldHeight = lp.height;
132            lp.height = ViewGroup.LayoutParams.WRAP_CONTENT;
133            mView.setLayoutParams(lp);
134            mView.measure(
135                    View.MeasureSpec.makeMeasureSpec(mView.getMeasuredWidth(),
136                                                     View.MeasureSpec.EXACTLY),
137                    View.MeasureSpec.makeMeasureSpec(maximum,
138                                                     View.MeasureSpec.AT_MOST));
139            lp.height = oldHeight;
140            mView.setLayoutParams(lp);
141            return mView.getMeasuredHeight();
142        }
143    }
144
145    class PopState {
146        View mCurrView;
147        View mCurrViewTopGlow;
148        View mCurrViewBottomGlow;
149        float mOldHeight;
150        float mNaturalHeight;
151        float mInitialTouchY;
152    }
153
154    private Stack<PopState> popStack;
155
156    /**
157     * Handle expansion gestures to expand and contract children of the callback.
158     *
159     * @param context application context
160     * @param callback the container that holds the items to be manipulated
161     * @param small the smallest allowable size for the manuipulated items.
162     * @param large the largest allowable size for the manuipulated items.
163     * @param scoller if non-null also manipulate the scroll position to obey the gravity.
164     */
165    public ExpandHelper(Context context, Callback callback, int small, int large) {
166        mSmallSize = small;
167        mMaximumStretch = mSmallSize * STRETCH_INTERVAL;
168        mLargeSize = large;
169        mContext = context;
170        mCallback = callback;
171        popStack = new Stack<PopState>();
172        mScaler = new ViewScaler();
173        mGravity = Gravity.TOP;
174        mScaleAnimation = ObjectAnimator.ofFloat(mScaler, "height", 0f);
175        mScaleAnimation.setDuration(EXPAND_DURATION);
176        mPopLimit = mContext.getResources().getDimension(R.dimen.one_finger_pop_limit);
177        mPopDuration = mContext.getResources().getInteger(R.integer.one_finger_pop_duration_ms);
178
179        AnimatorListenerAdapter glowVisibilityController = new AnimatorListenerAdapter() {
180            @Override
181            public void onAnimationStart(Animator animation) {
182                View target = (View) ((ObjectAnimator) animation).getTarget();
183                if (target.getAlpha() <= 0.0f) {
184                    target.setVisibility(View.VISIBLE);
185                }
186            }
187
188            @Override
189            public void onAnimationEnd(Animator animation) {
190                View target = (View) ((ObjectAnimator) animation).getTarget();
191                if (target.getAlpha() <= 0.0f) {
192                    target.setVisibility(View.INVISIBLE);
193                }
194            }
195        };
196
197        mGlowTopAnimation = ObjectAnimator.ofFloat(null, "alpha", 0f);
198        mGlowTopAnimation.addListener(glowVisibilityController);
199        mGlowBottomAnimation = ObjectAnimator.ofFloat(null, "alpha", 0f);
200        mGlowBottomAnimation.addListener(glowVisibilityController);
201        mGlowAnimationSet = new AnimatorSet();
202        mGlowAnimationSet.play(mGlowTopAnimation).with(mGlowBottomAnimation);
203        mGlowAnimationSet.setDuration(GLOW_DURATION);
204
205        final ViewConfiguration configuration = ViewConfiguration.get(mContext);
206        mTouchSlop = configuration.getScaledTouchSlop();
207
208        mDetector =
209                new ScaleGestureDetector(context,
210                                         new ScaleGestureDetector.SimpleOnScaleGestureListener() {
211            @Override
212            public boolean onScaleBegin(ScaleGestureDetector detector) {
213                if (DEBUG_SCALE) Slog.v(TAG, "onscalebegin()");
214                float x = detector.getFocusX();
215                float y = detector.getFocusY();
216
217                // your fingers have to be somewhat close to the bounds of the view in question
218                mInitialTouchFocusY = detector.getFocusY();
219                mInitialTouchSpan = Math.abs(detector.getCurrentSpan());
220                if (DEBUG_SCALE) Slog.d(TAG, "got mInitialTouchSpan: (" + mInitialTouchSpan + ")");
221
222                mStretching = initScale(findView(x, y));
223                return mStretching;
224            }
225
226            @Override
227            public boolean onScale(ScaleGestureDetector detector) {
228                if (DEBUG_SCALE) Slog.v(TAG, "onscale() on " + mCurrView);
229
230                // are we scaling or dragging?
231                float span = Math.abs(detector.getCurrentSpan()) - mInitialTouchSpan;
232                span *= USE_SPAN ? 1f : 0f;
233                float drag = detector.getFocusY() - mInitialTouchFocusY;
234                drag *= USE_DRAG ? 1f : 0f;
235                drag *= mGravity == Gravity.BOTTOM ? -1f : 1f;
236                float pull = Math.abs(drag) + Math.abs(span) + 1f;
237                float hand = drag * Math.abs(drag) / pull + span * Math.abs(span) / pull;
238                float target = hand + mOldHeight;
239                float newHeight = clamp(target);
240                mScaler.setHeight(newHeight);
241
242                setGlow(calculateGlow(target, newHeight));
243                return true;
244            }
245
246            @Override
247            public void onScaleEnd(ScaleGestureDetector detector) {
248                if (DEBUG_SCALE) Slog.v(TAG, "onscaleend()");
249                // I guess we're alone now
250                if (DEBUG_SCALE) Slog.d(TAG, "scale end");
251                finishScale(false);
252                clearView();
253                mStretching = false;
254            }
255        });
256    }
257
258    private float clamp(float target) {
259        float out = target;
260        out = out < mSmallSize ? mSmallSize : (out > mLargeSize ? mLargeSize : out);
261        out = out > mNaturalHeight ? mNaturalHeight : out;
262        return out;
263    }
264
265    private View findView(float x, float y) {
266        View v = null;
267        if (mEventSource != null) {
268            int[] location = new int[2];
269            mEventSource.getLocationOnScreen(location);
270            x += (float) location[0];
271            y += (float) location[1];
272            v = mCallback.getChildAtRawPosition(x, y);
273        } else {
274            v = mCallback.getChildAtPosition(x, y);
275        }
276        return v;
277    }
278
279    private boolean isInside(View v, float x, float y) {
280        if (DEBUG) Slog.d(TAG, "isinside (" + x + ", " + y + ")");
281
282        if (v == null) {
283            if (DEBUG) Slog.d(TAG, "isinside null subject");
284            return false;
285        }
286        if (mEventSource != null) {
287            int[] location = new int[2];
288            mEventSource.getLocationOnScreen(location);
289            x += (float) location[0];
290            y += (float) location[1];
291            if (DEBUG) Slog.d(TAG, "  to global (" + x + ", " + y + ")");
292        }
293        int[] location = new int[2];
294        v.getLocationOnScreen(location);
295        x -= (float) location[0];
296        y -= (float) location[1];
297        if (DEBUG) Slog.d(TAG, "  to local (" + x + ", " + y + ")");
298        if (DEBUG) Slog.d(TAG, "  inside (" + v.getWidth() + ", " + v.getHeight() + ")");
299        boolean inside = (x > 0f && y > 0f && x < v.getWidth() & y < v.getHeight());
300        return inside;
301    }
302
303    public void setEventSource(View eventSource) {
304        mEventSource = eventSource;
305    }
306
307    public void setGravity(int gravity) {
308        mGravity = gravity;
309    }
310
311    public void setScrollView(View scrollView) {
312        mScrollView = scrollView;
313    }
314
315    private float calculateGlow(float target, float actual) {
316        // glow if overscale
317        if (DEBUG_GLOW) Slog.d(TAG, "target: " + target + " actual: " + actual);
318        float stretch = (float) Math.abs((target - actual) / mMaximumStretch);
319        float strength = 1f / (1f + (float) Math.pow(Math.E, -1 * ((8f * stretch) - 5f)));
320        if (DEBUG_GLOW) Slog.d(TAG, "stretch: " + stretch + " strength: " + strength);
321        return (GLOW_BASE + strength * (1f - GLOW_BASE));
322    }
323
324    public void setGlow(float glow) {
325        if (!mGlowAnimationSet.isRunning() || glow == 0f) {
326            if (mGlowAnimationSet.isRunning()) {
327                mGlowAnimationSet.end();
328            }
329            if (mCurrViewTopGlow != null && mCurrViewBottomGlow != null) {
330                if (glow == 0f || mCurrViewTopGlow.getAlpha() == 0f) {
331                    // animate glow in and out
332                    mGlowTopAnimation.setTarget(mCurrViewTopGlow);
333                    mGlowBottomAnimation.setTarget(mCurrViewBottomGlow);
334                    mGlowTopAnimation.setFloatValues(glow);
335                    mGlowBottomAnimation.setFloatValues(glow);
336                    mGlowAnimationSet.setupStartValues();
337                    mGlowAnimationSet.start();
338                } else {
339                    // set it explicitly in reponse to touches.
340                    mCurrViewTopGlow.setAlpha(glow);
341                    mCurrViewBottomGlow.setAlpha(glow);
342                    handleGlowVisibility();
343                }
344            }
345        }
346    }
347
348    private void handleGlowVisibility() {
349        mCurrViewTopGlow.setVisibility(mCurrViewTopGlow.getAlpha() <= 0.0f ?
350                View.INVISIBLE : View.VISIBLE);
351        mCurrViewBottomGlow.setVisibility(mCurrViewBottomGlow.getAlpha() <= 0.0f ?
352                View.INVISIBLE : View.VISIBLE);
353    }
354
355    public boolean onInterceptTouchEvent(MotionEvent ev) {
356        if (DEBUG) Slog.d(TAG, "interceptTouch: act=" + (ev.getAction()) +
357                         " stretching=" + mStretching +
358                         " onefinger=" + mPullingWithOneFinger);
359        // check for a two-finger gesture
360        mDetector.onTouchEvent(ev);
361        if (mStretching) {
362            return true;
363        } else {
364            final int action = ev.getAction();
365            if ((action == MotionEvent.ACTION_MOVE) && mPullingWithOneFinger) {
366                return true;
367            }
368            if (mScrollView != null && mScrollView.getScrollY() > 0) {
369                return false;
370            }
371            switch (action & MotionEvent.ACTION_MASK) {
372            case MotionEvent.ACTION_MOVE: {
373                if (mWatchingForPull) {
374                    final int x = (int) ev.getX();
375                    final int y = (int) ev.getY();
376                    final int yDiff = y - mLastMotionY;
377                    if (yDiff > mTouchSlop) {
378                        mLastMotionY = y;
379                        mPullingWithOneFinger = initScale(findView(x, y));
380                        if (mPullingWithOneFinger) {
381                            mInitialTouchY = mLastMotionY;
382                            mHasPopped = false;
383                        }
384                    }
385                }
386                break;
387            }
388
389            case MotionEvent.ACTION_DOWN:
390                mWatchingForPull = isInside(mScrollView, ev.getX(), ev.getY());
391                mLastMotionY = (int) ev.getY();
392                break;
393
394            case MotionEvent.ACTION_CANCEL:
395            case MotionEvent.ACTION_UP:
396                if (mPullingWithOneFinger) {
397                    finishScale(false);
398                    clearView();
399                }
400                mPullingWithOneFinger = false;
401                mWatchingForPull = false;
402                break;
403            }
404            return mPullingWithOneFinger;
405        }
406    }
407
408    public boolean onTouchEvent(MotionEvent ev) {
409        final int action = ev.getAction();
410        if (DEBUG_SCALE) Slog.d(TAG, "touch: act=" + (action) +
411                         " stretching=" + mStretching +
412                         " onefinger=" + mPullingWithOneFinger);
413        if (mStretching) {
414            mDetector.onTouchEvent(ev);
415        }
416        switch (action) {
417            case MotionEvent.ACTION_MOVE: {
418                if (mPullingWithOneFinger) {
419                    float target = ev.getY() - mInitialTouchY + mOldHeight;
420                    float newHeight = clamp(target);
421                    if (mHasPopped || target > mPopLimit) {
422                        if (!mHasPopped) {
423                            vibrate(mPopDuration);
424                            mHasPopped = true;
425                        }
426                        mScaler.setHeight(newHeight);
427                        // glow if overscale
428                        if (target > mNaturalHeight) {
429                            View previous = mCallback.getPreviousChild(mCurrView);
430                            if (previous != null) {
431                                setGlow(0f);
432                                pushView(previous);
433                                initScale(previous);
434                                mInitialTouchY = ev.getY();
435                                target = mOldHeight;
436                                newHeight = clamp(target);
437                                mHasPopped = false;
438                            } else {
439                                setGlow(calculateGlow(target, newHeight));
440                            }
441                        } else if (target < mSmallSize && !popStack.empty()) {
442                            setGlow(0f);
443                            initScale(popView());
444                            mInitialTouchY = ev.getY();
445                            setGlow(GLOW_BASE);
446                        } else {
447                            setGlow(calculateGlow(target, newHeight));
448                        }
449                    } else {
450                         if (target < mSmallSize && !popStack.empty()) {
451                            setGlow(0f);
452                            initScale(popView());
453                            mInitialTouchY = ev.getY();
454                            setGlow(GLOW_BASE);
455                         } else {
456                             setGlow(calculateGlow(4f * target, mSmallSize));
457                         }
458                    }
459                    return true;
460                }
461                break;
462            }
463            case MotionEvent.ACTION_UP:
464            case MotionEvent.ACTION_CANCEL:
465                if (DEBUG) Slog.d(TAG, "cancel");
466                mStretching = false;
467                if (mPullingWithOneFinger) {
468                    finishScale(false);
469                    mPullingWithOneFinger = false;
470                }
471                clearView();
472                break;
473        }
474        return true;
475    }
476    private boolean initScale(View v) {
477        if (v != null) {
478            if (DEBUG) Slog.d(TAG, "scale begins on view: " + v);
479            setView(v);
480            setGlow(GLOW_BASE);
481            mScaler.setView(v);
482            mOldHeight = mScaler.getHeight();
483            if (mCallback.canChildBeExpanded(v)) {
484                if (DEBUG) Slog.d(TAG, "working on an expandable child");
485                mNaturalHeight = mScaler.getNaturalHeight(mLargeSize);
486            } else {
487                if (DEBUG) Slog.d(TAG, "working on a non-expandable child");
488                mNaturalHeight = mOldHeight;
489            }
490            if (DEBUG) Slog.d(TAG, "got mOldHeight: " + mOldHeight +
491                        " mNaturalHeight: " + mNaturalHeight);
492            v.getParent().requestDisallowInterceptTouchEvent(true);
493            return true;
494        } else {
495            return false;
496        }
497    }
498
499    private void finishScale(boolean force) {
500        float h = mScaler.getHeight();
501        final boolean wasClosed = (mOldHeight == mSmallSize);
502        if (wasClosed) {
503            h = (force || h > mSmallSize) ? mNaturalHeight : mSmallSize;
504        } else {
505            h = (force || h < mNaturalHeight) ? mSmallSize : mNaturalHeight;
506        }
507        if (mScaleAnimation.isRunning()) {
508            mScaleAnimation.cancel();
509        }
510        mScaleAnimation.setFloatValues(h);
511        mScaleAnimation.setupStartValues();
512        mScaleAnimation.start();
513        setGlow(0f);
514        mCallback.setUserExpandedChild(mCurrView, h == mNaturalHeight);
515        if (DEBUG) Slog.d(TAG, "scale was finished on view: " + mCurrView);
516    }
517
518    private void clearView() {
519        while (!popStack.empty()) {
520            popStack.pop();
521        }
522        mCurrView = null;
523        mCurrViewTopGlow = null;
524        mCurrViewBottomGlow = null;
525    }
526
527    private void setView(View v) {
528        mCurrView = v;
529        if (v instanceof ViewGroup) {
530            ViewGroup g = (ViewGroup) v;
531            mCurrViewTopGlow = g.findViewById(R.id.top_glow);
532            mCurrViewBottomGlow = g.findViewById(R.id.bottom_glow);
533            if (DEBUG) {
534                String debugLog = "Looking for glows: " +
535                        (mCurrViewTopGlow != null ? "found top " : "didn't find top") +
536                        (mCurrViewBottomGlow != null ? "found bottom " : "didn't find bottom");
537                Slog.v(TAG,  debugLog);
538            }
539        }
540    }
541
542    private void pushView(View v) {
543        PopState state = new PopState();
544        state.mCurrView = mCurrView;
545        state.mCurrViewTopGlow = mCurrViewTopGlow;
546        state.mCurrViewBottomGlow = mCurrViewBottomGlow;
547        state.mOldHeight = mOldHeight;
548        state.mNaturalHeight = mNaturalHeight;
549        state.mInitialTouchY = mInitialTouchY;
550        popStack.push(state);
551    }
552
553    private View popView() {
554        if (popStack.empty()) {
555            return null;
556        }
557
558        PopState state = popStack.pop();
559        mCurrView = state.mCurrView;
560        mCurrViewTopGlow = state.mCurrViewTopGlow;
561        mCurrViewBottomGlow = state.mCurrViewBottomGlow;
562        mOldHeight = state.mOldHeight;
563        mNaturalHeight = state.mNaturalHeight;
564        mInitialTouchY = state.mInitialTouchY;
565
566        return mCurrView;
567    }
568
569    @Override
570    public void onClick(View v) {
571        initScale(v);
572        finishScale(true);
573        clearView();
574    }
575
576    /**
577     * Triggers haptic feedback.
578     */
579    private synchronized void vibrate(long duration) {
580        if (mVibrator == null) {
581            mVibrator = (android.os.Vibrator)
582                    mContext.getSystemService(Context.VIBRATOR_SERVICE);
583        }
584        mVibrator.vibrate(duration);
585    }
586}
587
588