/* * Copyright (C) 2012 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.systemui; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.content.Context; import android.os.Vibrator; import android.util.Slog; import android.view.Gravity; import android.view.MotionEvent; import android.view.ScaleGestureDetector; import android.view.ScaleGestureDetector.OnScaleGestureListener; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.View.OnClickListener; public class ExpandHelper implements Gefingerpoken, OnClickListener { public interface Callback { View getChildAtRawPosition(float x, float y); View getChildAtPosition(float x, float y); boolean canChildBeExpanded(View v); boolean setUserExpandedChild(View v, boolean userExpanded); boolean setUserLockedChild(View v, boolean userLocked); } private static final String TAG = "ExpandHelper"; protected static final boolean DEBUG = false; protected static final boolean DEBUG_SCALE = false; protected static final boolean DEBUG_GLOW = false; private static final long EXPAND_DURATION = 250; private static final long GLOW_DURATION = 150; // Set to false to disable focus-based gestures (spread-finger vertical pull). private static final boolean USE_DRAG = true; // Set to false to disable scale-based gestures (both horizontal and vertical). private static final boolean USE_SPAN = true; // Both gestures types may be active at the same time. // At least one gesture type should be active. // A variant of the screwdriver gesture will emerge from either gesture type. // amount of overstretch for maximum brightness expressed in U // 2f: maximum brightness is stretching a 1U to 3U, or a 4U to 6U private static final float STRETCH_INTERVAL = 2f; // level of glow for a touch, without overstretch // overstretch fills the range (GLOW_BASE, 1.0] private static final float GLOW_BASE = 0.5f; @SuppressWarnings("unused") private Context mContext; private boolean mExpanding; private static final int NONE = 0; private static final int BLINDS = 1<<0; private static final int PULL = 1<<1; private static final int STRETCH = 1<<2; private int mExpansionStyle = NONE; private boolean mWatchingForPull; private boolean mHasPopped; private View mEventSource; private View mCurrView; private View mCurrViewTopGlow; private View mCurrViewBottomGlow; private float mOldHeight; private float mNaturalHeight; private float mInitialTouchFocusY; private float mInitialTouchY; private float mInitialTouchSpan; private float mLastFocusY; private float mLastSpanY; private int mTouchSlop; private int mLastMotionY; private float mPopLimit; private int mPopDuration; private float mPullGestureMinXSpan; private Callback mCallback; private ScaleGestureDetector mSGD; private ViewScaler mScaler; private ObjectAnimator mScaleAnimation; private AnimatorSet mGlowAnimationSet; private ObjectAnimator mGlowTopAnimation; private ObjectAnimator mGlowBottomAnimation; private Vibrator mVibrator; private int mSmallSize; private int mLargeSize; private float mMaximumStretch; private int mGravity; private View mScrollView; private OnScaleGestureListener mScaleGestureListener = new ScaleGestureDetector.SimpleOnScaleGestureListener() { @Override public boolean onScaleBegin(ScaleGestureDetector detector) { if (DEBUG_SCALE) Slog.v(TAG, "onscalebegin()"); float focusX = detector.getFocusX(); float focusY = detector.getFocusY(); final View underFocus = findView(focusX, focusY); if (underFocus != null) { startExpanding(underFocus, STRETCH); } return mExpanding; } @Override public boolean onScale(ScaleGestureDetector detector) { if (DEBUG_SCALE) Slog.v(TAG, "onscale() on " + mCurrView); return true; } @Override public void onScaleEnd(ScaleGestureDetector detector) { } }; private class ViewScaler { View mView; public ViewScaler() {} public void setView(View v) { mView = v; } public void setHeight(float h) { if (DEBUG_SCALE) Slog.v(TAG, "SetHeight: setting to " + h); ViewGroup.LayoutParams lp = mView.getLayoutParams(); lp.height = (int)h; mView.setLayoutParams(lp); mView.requestLayout(); } public float getHeight() { int height = mView.getLayoutParams().height; if (height < 0) { height = mView.getMeasuredHeight(); } return height; } public int getNaturalHeight(int maximum) { ViewGroup.LayoutParams lp = mView.getLayoutParams(); if (DEBUG_SCALE) Slog.v(TAG, "Inspecting a child of type: " + mView.getClass().getName()); int oldHeight = lp.height; lp.height = ViewGroup.LayoutParams.WRAP_CONTENT; mView.setLayoutParams(lp); mView.measure( View.MeasureSpec.makeMeasureSpec(mView.getMeasuredWidth(), View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(maximum, View.MeasureSpec.AT_MOST)); lp.height = oldHeight; mView.setLayoutParams(lp); return mView.getMeasuredHeight(); } } /** * Handle expansion gestures to expand and contract children of the callback. * * @param context application context * @param callback the container that holds the items to be manipulated * @param small the smallest allowable size for the manuipulated items. * @param large the largest allowable size for the manuipulated items. * @param scoller if non-null also manipulate the scroll position to obey the gravity. */ public ExpandHelper(Context context, Callback callback, int small, int large) { mSmallSize = small; mMaximumStretch = mSmallSize * STRETCH_INTERVAL; mLargeSize = large; mContext = context; mCallback = callback; mScaler = new ViewScaler(); mGravity = Gravity.TOP; mScaleAnimation = ObjectAnimator.ofFloat(mScaler, "height", 0f); mScaleAnimation.setDuration(EXPAND_DURATION); mPopLimit = mContext.getResources().getDimension(R.dimen.blinds_pop_threshold); mPopDuration = mContext.getResources().getInteger(R.integer.blinds_pop_duration_ms); mPullGestureMinXSpan = mContext.getResources().getDimension(R.dimen.pull_span_min); AnimatorListenerAdapter glowVisibilityController = new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { View target = (View) ((ObjectAnimator) animation).getTarget(); if (target.getAlpha() <= 0.0f) { target.setVisibility(View.VISIBLE); } } @Override public void onAnimationEnd(Animator animation) { View target = (View) ((ObjectAnimator) animation).getTarget(); if (target.getAlpha() <= 0.0f) { target.setVisibility(View.INVISIBLE); } } }; mGlowTopAnimation = ObjectAnimator.ofFloat(null, "alpha", 0f); mGlowTopAnimation.addListener(glowVisibilityController); mGlowBottomAnimation = ObjectAnimator.ofFloat(null, "alpha", 0f); mGlowBottomAnimation.addListener(glowVisibilityController); mGlowAnimationSet = new AnimatorSet(); mGlowAnimationSet.play(mGlowTopAnimation).with(mGlowBottomAnimation); mGlowAnimationSet.setDuration(GLOW_DURATION); final ViewConfiguration configuration = ViewConfiguration.get(mContext); mTouchSlop = configuration.getScaledTouchSlop(); mSGD = new ScaleGestureDetector(context, mScaleGestureListener); } private void updateExpansion() { if (DEBUG_SCALE) Slog.v(TAG, "updateExpansion()"); // are we scaling or dragging? float span = mSGD.getCurrentSpan() - mInitialTouchSpan; span *= USE_SPAN ? 1f : 0f; float drag = mSGD.getFocusY() - mInitialTouchFocusY; drag *= USE_DRAG ? 1f : 0f; drag *= mGravity == Gravity.BOTTOM ? -1f : 1f; float pull = Math.abs(drag) + Math.abs(span) + 1f; float hand = drag * Math.abs(drag) / pull + span * Math.abs(span) / pull; float target = hand + mOldHeight; float newHeight = clamp(target); mScaler.setHeight(newHeight); setGlow(calculateGlow(target, newHeight)); mLastFocusY = mSGD.getFocusY(); mLastSpanY = mSGD.getCurrentSpan(); } private float clamp(float target) { float out = target; out = out < mSmallSize ? mSmallSize : (out > mLargeSize ? mLargeSize : out); out = out > mNaturalHeight ? mNaturalHeight : out; return out; } private View findView(float x, float y) { View v = null; if (mEventSource != null) { int[] location = new int[2]; mEventSource.getLocationOnScreen(location); x += location[0]; y += location[1]; v = mCallback.getChildAtRawPosition(x, y); } else { v = mCallback.getChildAtPosition(x, y); } return v; } private boolean isInside(View v, float x, float y) { if (DEBUG) Slog.d(TAG, "isinside (" + x + ", " + y + ")"); if (v == null) { if (DEBUG) Slog.d(TAG, "isinside null subject"); return false; } if (mEventSource != null) { int[] location = new int[2]; mEventSource.getLocationOnScreen(location); x += location[0]; y += location[1]; if (DEBUG) Slog.d(TAG, " to global (" + x + ", " + y + ")"); } int[] location = new int[2]; v.getLocationOnScreen(location); x -= location[0]; y -= location[1]; if (DEBUG) Slog.d(TAG, " to local (" + x + ", " + y + ")"); if (DEBUG) Slog.d(TAG, " inside (" + v.getWidth() + ", " + v.getHeight() + ")"); boolean inside = (x > 0f && y > 0f && x < v.getWidth() & y < v.getHeight()); return inside; } public void setEventSource(View eventSource) { mEventSource = eventSource; } public void setGravity(int gravity) { mGravity = gravity; } public void setScrollView(View scrollView) { mScrollView = scrollView; } private float calculateGlow(float target, float actual) { // glow if overscale if (DEBUG_GLOW) Slog.d(TAG, "target: " + target + " actual: " + actual); float stretch = Math.abs((target - actual) / mMaximumStretch); float strength = 1f / (1f + (float) Math.pow(Math.E, -1 * ((8f * stretch) - 5f))); if (DEBUG_GLOW) Slog.d(TAG, "stretch: " + stretch + " strength: " + strength); return (GLOW_BASE + strength * (1f - GLOW_BASE)); } public void setGlow(float glow) { if (!mGlowAnimationSet.isRunning() || glow == 0f) { if (mGlowAnimationSet.isRunning()) { mGlowAnimationSet.end(); } if (mCurrViewTopGlow != null && mCurrViewBottomGlow != null) { if (glow == 0f || mCurrViewTopGlow.getAlpha() == 0f) { // animate glow in and out mGlowTopAnimation.setTarget(mCurrViewTopGlow); mGlowBottomAnimation.setTarget(mCurrViewBottomGlow); mGlowTopAnimation.setFloatValues(glow); mGlowBottomAnimation.setFloatValues(glow); mGlowAnimationSet.setupStartValues(); mGlowAnimationSet.start(); } else { // set it explicitly in reponse to touches. mCurrViewTopGlow.setAlpha(glow); mCurrViewBottomGlow.setAlpha(glow); handleGlowVisibility(); } } } } private void handleGlowVisibility() { mCurrViewTopGlow.setVisibility(mCurrViewTopGlow.getAlpha() <= 0.0f ? View.INVISIBLE : View.VISIBLE); mCurrViewBottomGlow.setVisibility(mCurrViewBottomGlow.getAlpha() <= 0.0f ? View.INVISIBLE : View.VISIBLE); } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { final int action = ev.getAction(); if (DEBUG_SCALE) Slog.d(TAG, "intercept: act=" + MotionEvent.actionToString(action) + " expanding=" + mExpanding + (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") + (0 != (mExpansionStyle & PULL) ? " (pull)" : "") + (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : "")); // check for a spread-finger vertical pull gesture mSGD.onTouchEvent(ev); final int x = (int) mSGD.getFocusX(); final int y = (int) mSGD.getFocusY(); mInitialTouchFocusY = y; mInitialTouchSpan = mSGD.getCurrentSpan(); mLastFocusY = mInitialTouchFocusY; mLastSpanY = mInitialTouchSpan; if (DEBUG_SCALE) Slog.d(TAG, "set initial span: " + mInitialTouchSpan); if (mExpanding) { return true; } else { if ((action == MotionEvent.ACTION_MOVE) && 0 != (mExpansionStyle & BLINDS)) { // we've begun Venetian blinds style expansion return true; } final float xspan = mSGD.getCurrentSpanX(); if ((action == MotionEvent.ACTION_MOVE && xspan > mPullGestureMinXSpan && xspan > mSGD.getCurrentSpanY())) { // detect a vertical pulling gesture with fingers somewhat separated if (DEBUG_SCALE) Slog.v(TAG, "got pull gesture (xspan=" + xspan + "px)"); final View underFocus = findView(x, y); if (underFocus != null) { startExpanding(underFocus, PULL); } return true; } if (mScrollView != null && mScrollView.getScrollY() > 0) { return false; } // Now look for other gestures switch (action & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_MOVE: { if (mWatchingForPull) { final int yDiff = y - mLastMotionY; if (yDiff > mTouchSlop) { if (DEBUG) Slog.v(TAG, "got venetian gesture (dy=" + yDiff + "px)"); mLastMotionY = y; final View underFocus = findView(x, y); if (underFocus != null) { startExpanding(underFocus, BLINDS); mInitialTouchY = mLastMotionY; mHasPopped = false; } } } break; } case MotionEvent.ACTION_DOWN: mWatchingForPull = isInside(mScrollView, x, y); mLastMotionY = y; break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: if (DEBUG) Slog.d(TAG, "up/cancel"); finishExpanding(false); clearView(); break; } return mExpanding; } } @Override public boolean onTouchEvent(MotionEvent ev) { final int action = ev.getActionMasked(); if (DEBUG_SCALE) Slog.d(TAG, "touch: act=" + MotionEvent.actionToString(action) + " expanding=" + mExpanding + (0 != (mExpansionStyle & BLINDS) ? " (blinds)" : "") + (0 != (mExpansionStyle & PULL) ? " (pull)" : "") + (0 != (mExpansionStyle & STRETCH) ? " (stretch)" : "")); mSGD.onTouchEvent(ev); switch (action) { case MotionEvent.ACTION_MOVE: { if (0 != (mExpansionStyle & BLINDS)) { final float rawHeight = ev.getY() - mInitialTouchY + mOldHeight; final float newHeight = clamp(rawHeight); final boolean wasClosed = (mOldHeight == mSmallSize); boolean isFinished = false; if (rawHeight > mNaturalHeight) { isFinished = true; } if (rawHeight < mSmallSize) { isFinished = true; } final float pull = Math.abs(ev.getY() - mInitialTouchY); if (mHasPopped || pull > mPopLimit) { if (!mHasPopped) { vibrate(mPopDuration); mHasPopped = true; } } if (mHasPopped) { mScaler.setHeight(newHeight); setGlow(GLOW_BASE); } else { setGlow(calculateGlow(4f * pull, 0f)); } final int x = (int) mSGD.getFocusX(); final int y = (int) mSGD.getFocusY(); View underFocus = findView(x, y); if (isFinished && underFocus != null && underFocus != mCurrView) { finishExpanding(false); // @@@ needed? startExpanding(underFocus, BLINDS); mInitialTouchY = y; mHasPopped = false; } return true; } if (mExpanding) { updateExpansion(); return true; } break; } case MotionEvent.ACTION_POINTER_UP: case MotionEvent.ACTION_POINTER_DOWN: if (DEBUG) Slog.d(TAG, "pointer change"); mInitialTouchY += mSGD.getFocusY() - mLastFocusY; mInitialTouchSpan += mSGD.getCurrentSpan() - mLastSpanY; break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: if (DEBUG) Slog.d(TAG, "up/cancel"); finishExpanding(false); clearView(); break; } return true; } private void startExpanding(View v, int expandType) { mExpansionStyle = expandType; if (mExpanding && v == mCurrView) { return; } mExpanding = true; if (DEBUG) Slog.d(TAG, "scale type " + expandType + " beginning on view: " + v); mCallback.setUserLockedChild(v, true); setView(v); setGlow(GLOW_BASE); mScaler.setView(v); mOldHeight = mScaler.getHeight(); if (mCallback.canChildBeExpanded(v)) { if (DEBUG) Slog.d(TAG, "working on an expandable child"); mNaturalHeight = mScaler.getNaturalHeight(mLargeSize); } else { if (DEBUG) Slog.d(TAG, "working on a non-expandable child"); mNaturalHeight = mOldHeight; } if (DEBUG) Slog.d(TAG, "got mOldHeight: " + mOldHeight + " mNaturalHeight: " + mNaturalHeight); v.getParent().requestDisallowInterceptTouchEvent(true); } private void finishExpanding(boolean force) { if (!mExpanding) return; if (DEBUG) Slog.d(TAG, "scale in finishing on view: " + mCurrView); float currentHeight = mScaler.getHeight(); float targetHeight = mSmallSize; float h = mScaler.getHeight(); final boolean wasClosed = (mOldHeight == mSmallSize); if (wasClosed) { targetHeight = (force || currentHeight > mSmallSize) ? mNaturalHeight : mSmallSize; } else { targetHeight = (force || currentHeight < mNaturalHeight) ? mSmallSize : mNaturalHeight; } if (mScaleAnimation.isRunning()) { mScaleAnimation.cancel(); } setGlow(0f); mCallback.setUserExpandedChild(mCurrView, h == mNaturalHeight); if (targetHeight != currentHeight) { mScaleAnimation.setFloatValues(targetHeight); mScaleAnimation.setupStartValues(); mScaleAnimation.start(); } mCallback.setUserLockedChild(mCurrView, false); mExpanding = false; mExpansionStyle = NONE; if (DEBUG) Slog.d(TAG, "wasClosed is: " + wasClosed); if (DEBUG) Slog.d(TAG, "currentHeight is: " + currentHeight); if (DEBUG) Slog.d(TAG, "mSmallSize is: " + mSmallSize); if (DEBUG) Slog.d(TAG, "targetHeight is: " + targetHeight); if (DEBUG) Slog.d(TAG, "scale was finished on view: " + mCurrView); } private void clearView() { mCurrView = null; mCurrViewTopGlow = null; mCurrViewBottomGlow = null; } private void setView(View v) { mCurrView = v; if (v instanceof ViewGroup) { ViewGroup g = (ViewGroup) v; mCurrViewTopGlow = g.findViewById(R.id.top_glow); mCurrViewBottomGlow = g.findViewById(R.id.bottom_glow); if (DEBUG) { String debugLog = "Looking for glows: " + (mCurrViewTopGlow != null ? "found top " : "didn't find top") + (mCurrViewBottomGlow != null ? "found bottom " : "didn't find bottom"); Slog.v(TAG, debugLog); } } } @Override public void onClick(View v) { startExpanding(v, STRETCH); finishExpanding(true); clearView(); } /** * Use this to abort any pending expansions in progress. */ public void cancel() { finishExpanding(true); clearView(); // reset the gesture detector mSGD = new ScaleGestureDetector(mContext, mScaleGestureListener); } /** * Triggers haptic feedback. */ private synchronized void vibrate(long duration) { if (mVibrator == null) { mVibrator = (android.os.Vibrator) mContext.getSystemService(Context.VIBRATOR_SERVICE); } mVibrator.vibrate(duration); } }