/* * 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.internal.policy.impl.keyguard; import com.android.internal.R; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Paint; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.util.FloatProperty; import android.util.Log; import android.util.Property; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.accessibility.AccessibilityManager; import android.view.animation.Interpolator; import android.widget.Scroller; /** * This layout handles interaction with the sliding security challenge views * that overlay/resize other keyguard contents. */ public class SlidingChallengeLayout extends ViewGroup implements ChallengeLayout { private static final String TAG = "SlidingChallengeLayout"; private static final boolean DEBUG = false; // The drag handle is measured in dp above & below the top edge of the // challenge view; these parameters change based on whether the challenge // is open or closed. private static final int DRAG_HANDLE_CLOSED_ABOVE = 8; // dp private static final int DRAG_HANDLE_CLOSED_BELOW = 0; // dp private static final int DRAG_HANDLE_OPEN_ABOVE = 8; // dp private static final int DRAG_HANDLE_OPEN_BELOW = 0; // dp private static final int HANDLE_ANIMATE_DURATION = 250; // ms // Drawn to show the drag handle in closed state; crossfades to the challenge view // when challenge is fully visible private boolean mEdgeCaptured; private DisplayMetrics mDisplayMetrics; // Initialized during measurement from child layoutparams private View mExpandChallengeView; private KeyguardSecurityContainer mChallengeView; private View mScrimView; private View mWidgetsView; // Range: 0 (fully hidden) to 1 (fully visible) private float mChallengeOffset = 1.f; private boolean mChallengeShowing = true; private boolean mChallengeShowingTargetState = true; private boolean mWasChallengeShowing = true; private boolean mIsBouncing = false; private final Scroller mScroller; private ObjectAnimator mFader; private int mScrollState; private OnChallengeScrolledListener mScrollListener; private OnBouncerStateChangedListener mBouncerListener; public static final int SCROLL_STATE_IDLE = 0; public static final int SCROLL_STATE_DRAGGING = 1; public static final int SCROLL_STATE_SETTLING = 2; public static final int SCROLL_STATE_FADING = 3; private static final int CHALLENGE_FADE_OUT_DURATION = 100; private static final int CHALLENGE_FADE_IN_DURATION = 160; private static final int MAX_SETTLE_DURATION = 600; // ms // ID of the pointer in charge of a current drag private int mActivePointerId = INVALID_POINTER; private static final int INVALID_POINTER = -1; // True if the user is currently dragging the slider private boolean mDragging; // True if the user may not drag until a new gesture begins private boolean mBlockDrag; private VelocityTracker mVelocityTracker; private int mMinVelocity; private int mMaxVelocity; private float mGestureStartX, mGestureStartY; // where did you first touch the screen? private int mGestureStartChallengeBottom; // where was the challenge at that time? private int mDragHandleClosedBelow; // handle hitrect extension into the challenge view private int mDragHandleClosedAbove; // extend the handle's hitrect this far above the line private int mDragHandleOpenBelow; // handle hitrect extension into the challenge view private int mDragHandleOpenAbove; // extend the handle's hitrect this far above the line private int mDragHandleEdgeSlop; private int mChallengeBottomBound; // Number of pixels from the top of the challenge view // that should remain on-screen private int mTouchSlop; private int mTouchSlopSquare; float mHandleAlpha; float mFrameAlpha; float mFrameAnimationTarget = Float.MIN_VALUE; private ObjectAnimator mHandleAnimation; private ObjectAnimator mFrameAnimation; private boolean mHasGlowpad; // We have an internal and external version, and we and them together. private boolean mChallengeInteractiveExternal = true; private boolean mChallengeInteractiveInternal = true; static final Property HANDLE_ALPHA = new FloatProperty("handleAlpha") { @Override public void setValue(SlidingChallengeLayout view, float value) { view.mHandleAlpha = value; view.invalidate(); } @Override public Float get(SlidingChallengeLayout view) { return view.mHandleAlpha; } }; // True if at least one layout pass has happened since the view was attached. private boolean mHasLayout; private static final Interpolator sMotionInterpolator = new Interpolator() { public float getInterpolation(float t) { t -= 1.0f; return t * t * t * t * t + 1.0f; } }; private static final Interpolator sHandleFadeInterpolator = new Interpolator() { public float getInterpolation(float t) { return t * t; } }; private final Runnable mEndScrollRunnable = new Runnable () { public void run() { completeChallengeScroll(); } }; private final OnClickListener mScrimClickListener = new OnClickListener() { @Override public void onClick(View v) { hideBouncer(); } }; private final OnClickListener mExpandChallengeClickListener = new OnClickListener() { @Override public void onClick(View v) { if (!isChallengeShowing()) { showChallenge(true); } } }; /** * Listener interface that reports changes in scroll state of the challenge area. */ public interface OnChallengeScrolledListener { /** * The scroll state itself changed. * *

scrollState will be one of the following:

* * * *

Do not perform expensive operations (e.g. layout) * while the scroll state is not SCROLL_STATE_IDLE.

* * @param scrollState The new scroll state of the challenge area. */ public void onScrollStateChanged(int scrollState); /** * The precise position of the challenge area has changed. * *

NOTE: It is NOT safe to modify layout or call any View methods that may * result in a requestLayout anywhere in your view hierarchy as a result of this call. * It may be called during drawing.

* * @param scrollPosition New relative position of the challenge area. * 1.f = fully visible/ready to be interacted with. * 0.f = fully invisible/inaccessible to the user. * @param challengeTop Position of the top edge of the challenge view in px in the * SlidingChallengeLayout's coordinate system. */ public void onScrollPositionChanged(float scrollPosition, int challengeTop); } public SlidingChallengeLayout(Context context) { this(context, null); } public SlidingChallengeLayout(Context context, AttributeSet attrs) { this(context, attrs, 0); } public SlidingChallengeLayout(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); mScroller = new Scroller(context, sMotionInterpolator); final ViewConfiguration vc = ViewConfiguration.get(context); mMinVelocity = vc.getScaledMinimumFlingVelocity(); mMaxVelocity = vc.getScaledMaximumFlingVelocity(); final Resources res = getResources(); mDragHandleEdgeSlop = res.getDimensionPixelSize(R.dimen.kg_edge_swipe_region_size); mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); mTouchSlopSquare = mTouchSlop * mTouchSlop; mDisplayMetrics = res.getDisplayMetrics(); final float density = mDisplayMetrics.density; // top half of the lock icon, plus another 25% to be sure mDragHandleClosedAbove = (int) (DRAG_HANDLE_CLOSED_ABOVE * density + 0.5f); mDragHandleClosedBelow = (int) (DRAG_HANDLE_CLOSED_BELOW * density + 0.5f); mDragHandleOpenAbove = (int) (DRAG_HANDLE_OPEN_ABOVE * density + 0.5f); mDragHandleOpenBelow = (int) (DRAG_HANDLE_OPEN_BELOW * density + 0.5f); // how much space to account for in the handle when closed mChallengeBottomBound = res.getDimensionPixelSize(R.dimen.kg_widget_pager_bottom_padding); setWillNotDraw(false); setSystemUiVisibility(SYSTEM_UI_FLAG_LAYOUT_STABLE); } public void setHandleAlpha(float alpha) { if (mExpandChallengeView != null) { mExpandChallengeView.setAlpha(alpha); } } public void setChallengeInteractive(boolean interactive) { mChallengeInteractiveExternal = interactive; if (mExpandChallengeView != null) { mExpandChallengeView.setEnabled(interactive); } } void animateHandle(boolean visible) { if (mHandleAnimation != null) { mHandleAnimation.cancel(); mHandleAnimation = null; } final float targetAlpha = visible ? 1.f : 0.f; if (targetAlpha == mHandleAlpha) { return; } mHandleAnimation = ObjectAnimator.ofFloat(this, HANDLE_ALPHA, targetAlpha); mHandleAnimation.setInterpolator(sHandleFadeInterpolator); mHandleAnimation.setDuration(HANDLE_ANIMATE_DURATION); mHandleAnimation.start(); } private void sendInitialListenerUpdates() { if (mScrollListener != null) { int challengeTop = mChallengeView != null ? mChallengeView.getTop() : 0; mScrollListener.onScrollPositionChanged(mChallengeOffset, challengeTop); mScrollListener.onScrollStateChanged(mScrollState); } } public void setOnChallengeScrolledListener(OnChallengeScrolledListener listener) { mScrollListener = listener; if (mHasLayout) { sendInitialListenerUpdates(); } } public void setOnBouncerStateChangedListener(OnBouncerStateChangedListener listener) { mBouncerListener = listener; } @Override public void onAttachedToWindow() { super.onAttachedToWindow(); mHasLayout = false; } @Override public void onDetachedFromWindow() { super.onDetachedFromWindow(); removeCallbacks(mEndScrollRunnable); mHasLayout = false; } @Override public void requestChildFocus(View child, View focused) { if (mIsBouncing && child != mChallengeView) { // Clear out of the bouncer if the user tries to move focus outside of // the security challenge view. hideBouncer(); } super.requestChildFocus(child, focused); } // We want the duration of the page snap animation to be influenced by the distance that // the screen has to travel, however, we don't want this duration to be effected in a // purely linear fashion. Instead, we use this method to moderate the effect that the distance // of travel has on the overall snap duration. float distanceInfluenceForSnapDuration(float f) { f -= 0.5f; // center the values about 0. f *= 0.3f * Math.PI / 2.0f; return (float) Math.sin(f); } void setScrollState(int state) { if (mScrollState != state) { mScrollState = state; animateHandle(state == SCROLL_STATE_IDLE && !mChallengeShowing); if (mScrollListener != null) { mScrollListener.onScrollStateChanged(state); } } } void completeChallengeScroll() { setChallengeShowing(mChallengeShowingTargetState); mChallengeOffset = mChallengeShowing ? 1.f : 0.f; setScrollState(SCROLL_STATE_IDLE); mChallengeInteractiveInternal = true; mChallengeView.setLayerType(LAYER_TYPE_NONE, null); } void setScrimView(View scrim) { if (mScrimView != null) { mScrimView.setOnClickListener(null); } mScrimView = scrim; mScrimView.setVisibility(mIsBouncing ? VISIBLE : GONE); mScrimView.setFocusable(true); mScrimView.setOnClickListener(mScrimClickListener); } /** * Animate the bottom edge of the challenge view to the given position. * * @param y desired final position for the bottom edge of the challenge view in px * @param velocity velocity in */ void animateChallengeTo(int y, int velocity) { if (mChallengeView == null) { // Nothing to do. return; } cancelTransitionsInProgress(); mChallengeInteractiveInternal = false; mChallengeView.setLayerType(LAYER_TYPE_HARDWARE, null); final int sy = mChallengeView.getBottom(); final int dy = y - sy; if (dy == 0) { completeChallengeScroll(); return; } setScrollState(SCROLL_STATE_SETTLING); final int childHeight = mChallengeView.getHeight(); final int halfHeight = childHeight / 2; final float distanceRatio = Math.min(1f, 1.0f * Math.abs(dy) / childHeight); final float distance = halfHeight + halfHeight * distanceInfluenceForSnapDuration(distanceRatio); int duration = 0; velocity = Math.abs(velocity); if (velocity > 0) { duration = 4 * Math.round(1000 * Math.abs(distance / velocity)); } else { final float childDelta = (float) Math.abs(dy) / childHeight; duration = (int) ((childDelta + 1) * 100); } duration = Math.min(duration, MAX_SETTLE_DURATION); mScroller.startScroll(0, sy, 0, dy, duration); postInvalidateOnAnimation(); } private void setChallengeShowing(boolean showChallenge) { if (mChallengeShowing == showChallenge) { return; } mChallengeShowing = showChallenge; if (mExpandChallengeView == null || mChallengeView == null) { // These might not be here yet if we haven't been through layout. // If we haven't, the first layout pass will set everything up correctly // based on mChallengeShowing as set above. return; } if (mChallengeShowing) { mExpandChallengeView.setVisibility(View.INVISIBLE); mChallengeView.setVisibility(View.VISIBLE); if (AccessibilityManager.getInstance(mContext).isEnabled()) { mChallengeView.requestAccessibilityFocus(); mChallengeView.announceForAccessibility(mContext.getString( R.string.keyguard_accessibility_unlock_area_expanded)); } } else { mExpandChallengeView.setVisibility(View.VISIBLE); mChallengeView.setVisibility(View.INVISIBLE); if (AccessibilityManager.getInstance(mContext).isEnabled()) { mExpandChallengeView.requestAccessibilityFocus(); mChallengeView.announceForAccessibility(mContext.getString( R.string.keyguard_accessibility_unlock_area_collapsed)); } } } /** * @return true if the challenge is at all visible. */ public boolean isChallengeShowing() { return mChallengeShowing; } @Override public boolean isChallengeOverlapping() { return mChallengeShowing; } @Override public boolean isBouncing() { return mIsBouncing; } @Override public int getBouncerAnimationDuration() { return HANDLE_ANIMATE_DURATION; } @Override public void showBouncer() { if (mIsBouncing) return; mWasChallengeShowing = mChallengeShowing; mIsBouncing = true; showChallenge(true); if (mScrimView != null) { Animator anim = ObjectAnimator.ofFloat(mScrimView, "alpha", 1f); anim.setDuration(HANDLE_ANIMATE_DURATION); anim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { mScrimView.setVisibility(VISIBLE); } }); anim.start(); } if (mChallengeView != null) { mChallengeView.showBouncer(HANDLE_ANIMATE_DURATION); } if (mBouncerListener != null) { mBouncerListener.onBouncerStateChanged(true); } } @Override public void hideBouncer() { if (!mIsBouncing) return; if (!mWasChallengeShowing) showChallenge(false); mIsBouncing = false; if (mScrimView != null) { Animator anim = ObjectAnimator.ofFloat(mScrimView, "alpha", 0f); anim.setDuration(HANDLE_ANIMATE_DURATION); anim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { mScrimView.setVisibility(GONE); } }); anim.start(); } if (mChallengeView != null) { mChallengeView.hideBouncer(HANDLE_ANIMATE_DURATION); } if (mBouncerListener != null) { mBouncerListener.onBouncerStateChanged(false); } } private int getChallengeMargin(boolean expanded) { return expanded && mHasGlowpad ? 0 : mDragHandleEdgeSlop; } private float getChallengeAlpha() { float x = mChallengeOffset - 1; return x * x * x + 1.f; } @Override public void requestDisallowInterceptTouchEvent(boolean allowIntercept) { // We'll intercept whoever we feel like! ...as long as it isn't a challenge view. // If there are one or more pointers in the challenge view before we take over // touch events, onInterceptTouchEvent will set mBlockDrag. } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (mVelocityTracker == null) { mVelocityTracker = VelocityTracker.obtain(); } mVelocityTracker.addMovement(ev); final int action = ev.getActionMasked(); switch (action) { case MotionEvent.ACTION_DOWN: mGestureStartX = ev.getX(); mGestureStartY = ev.getY(); mBlockDrag = false; break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: resetTouch(); break; case MotionEvent.ACTION_MOVE: final int count = ev.getPointerCount(); for (int i = 0; i < count; i++) { final float x = ev.getX(i); final float y = ev.getY(i); if (!mIsBouncing && mActivePointerId == INVALID_POINTER && (crossedDragHandle(x, y, mGestureStartY) || (isInChallengeView(x, y) && mScrollState == SCROLL_STATE_SETTLING))) { mActivePointerId = ev.getPointerId(i); mGestureStartX = x; mGestureStartY = y; mGestureStartChallengeBottom = getChallengeBottom(); mDragging = true; mChallengeView.setLayerType(LAYER_TYPE_HARDWARE, null); } else if (mChallengeShowing && isInChallengeView(x, y)) { mBlockDrag = true; } } break; } if (mBlockDrag || isChallengeInteractionBlocked()) { mActivePointerId = INVALID_POINTER; mDragging = false; } return mDragging; } private boolean isChallengeInteractionBlocked() { return !mChallengeInteractiveExternal || !mChallengeInteractiveInternal; } private void resetTouch() { mVelocityTracker.recycle(); mVelocityTracker = null; mActivePointerId = INVALID_POINTER; mDragging = mBlockDrag = false; } @Override public boolean onTouchEvent(MotionEvent ev) { if (mVelocityTracker == null) { mVelocityTracker = VelocityTracker.obtain(); } mVelocityTracker.addMovement(ev); final int action = ev.getActionMasked(); switch (action) { case MotionEvent.ACTION_DOWN: mBlockDrag = false; mGestureStartX = ev.getX(); mGestureStartY = ev.getY(); break; case MotionEvent.ACTION_CANCEL: if (mDragging && !isChallengeInteractionBlocked()) { showChallenge(0); } resetTouch(); break; case MotionEvent.ACTION_POINTER_UP: if (mActivePointerId != ev.getPointerId(ev.getActionIndex())) { break; } case MotionEvent.ACTION_UP: if (mDragging && !isChallengeInteractionBlocked()) { mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity); showChallenge((int) mVelocityTracker.getYVelocity(mActivePointerId)); } resetTouch(); break; case MotionEvent.ACTION_MOVE: if (!mDragging && !mBlockDrag && !mIsBouncing) { final int count = ev.getPointerCount(); for (int i = 0; i < count; i++) { final float x = ev.getX(i); final float y = ev.getY(i); if ((isInDragHandle(x, y) || crossedDragHandle(x, y, mGestureStartY) || (isInChallengeView(x, y) && mScrollState == SCROLL_STATE_SETTLING)) && mActivePointerId == INVALID_POINTER && !isChallengeInteractionBlocked()) { mGestureStartX = x; mGestureStartY = y; mActivePointerId = ev.getPointerId(i); mGestureStartChallengeBottom = getChallengeBottom(); mDragging = true; mChallengeView.setLayerType(LAYER_TYPE_HARDWARE, null); break; } } } // Not an else; this can be set above. if (mDragging) { // No-op if already in this state, but set it here in case we arrived // at this point from either intercept or the above. setScrollState(SCROLL_STATE_DRAGGING); final int index = ev.findPointerIndex(mActivePointerId); if (index < 0) { // Oops, bogus state. We lost some touch events somewhere. // Just drop it with no velocity and let things settle. resetTouch(); showChallenge(0); return true; } final float y = ev.getY(index); final float pos = Math.min(y - mGestureStartY, getLayoutBottom() - mChallengeBottomBound); moveChallengeTo(mGestureStartChallengeBottom + (int) pos); } break; } return true; } /** * The lifecycle of touch events is subtle and it's very easy to do something * that will cause bugs that will be nasty to track when overriding this method. * Normally one should always override onInterceptTouchEvent instead. * * To put it another way, don't try this at home. */ @Override public boolean dispatchTouchEvent(MotionEvent ev) { final int action = ev.getActionMasked(); boolean handled = false; if (action == MotionEvent.ACTION_DOWN) { // Defensive programming: if we didn't get the UP or CANCEL, reset anyway. mEdgeCaptured = false; } if (mWidgetsView != null && !mIsBouncing && (mEdgeCaptured || isEdgeSwipeBeginEvent(ev))) { // Normally we would need to do a lot of extra stuff here. // We can only get away with this because we haven't padded in // the widget pager or otherwise transformed it during layout. // We also don't support things like splitting MotionEvents. // We set handled to captured even if dispatch is returning false here so that // we don't send a different view a busted or incomplete event stream. handled = mEdgeCaptured |= mWidgetsView.dispatchTouchEvent(ev); } if (!handled && !mEdgeCaptured) { handled = super.dispatchTouchEvent(ev); } if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { mEdgeCaptured = false; } return handled; } private boolean isEdgeSwipeBeginEvent(MotionEvent ev) { if (ev.getActionMasked() != MotionEvent.ACTION_DOWN) { return false; } final float x = ev.getX(); return x < mDragHandleEdgeSlop || x >= getWidth() - mDragHandleEdgeSlop; } /** * We only want to add additional vertical space to the drag handle when the panel is fully * closed. */ private int getDragHandleSizeAbove() { return isChallengeShowing() ? mDragHandleOpenAbove : mDragHandleClosedAbove; } private int getDragHandleSizeBelow() { return isChallengeShowing() ? mDragHandleOpenBelow : mDragHandleClosedBelow; } private boolean isInChallengeView(float x, float y) { return isPointInView(x, y, mChallengeView); } private boolean isInDragHandle(float x, float y) { return isPointInView(x, y, mExpandChallengeView); } private boolean isPointInView(float x, float y, View view) { if (view == null) { return false; } return x >= view.getLeft() && y >= view.getTop() && x < view.getRight() && y < view.getBottom(); } private boolean crossedDragHandle(float x, float y, float initialY) { final int challengeTop = mChallengeView.getTop(); final boolean horizOk = x >= 0 && x < getWidth(); final boolean vertOk; if (mChallengeShowing) { vertOk = initialY < (challengeTop - getDragHandleSizeAbove()) && y > challengeTop + getDragHandleSizeBelow(); } else { vertOk = initialY > challengeTop + getDragHandleSizeBelow() && y < challengeTop - getDragHandleSizeAbove(); } return horizOk && vertOk; } private int makeChildMeasureSpec(int maxSize, int childDimen) { final int mode; final int size; switch (childDimen) { case LayoutParams.WRAP_CONTENT: mode = MeasureSpec.AT_MOST; size = maxSize; break; case LayoutParams.MATCH_PARENT: mode = MeasureSpec.EXACTLY; size = maxSize; break; default: mode = MeasureSpec.EXACTLY; size = Math.min(maxSize, childDimen); break; } return MeasureSpec.makeMeasureSpec(size, mode); } @Override protected void onMeasure(int widthSpec, int heightSpec) { if (MeasureSpec.getMode(widthSpec) != MeasureSpec.EXACTLY || MeasureSpec.getMode(heightSpec) != MeasureSpec.EXACTLY) { throw new IllegalArgumentException( "SlidingChallengeLayout must be measured with an exact size"); } final int width = MeasureSpec.getSize(widthSpec); final int height = MeasureSpec.getSize(heightSpec); setMeasuredDimension(width, height); // Find one and only one challenge view. final View oldChallengeView = mChallengeView; final View oldExpandChallengeView = mChallengeView; mChallengeView = null; mExpandChallengeView = null; final int count = getChildCount(); // First iteration through the children finds special children and sets any associated // state. for (int i = 0; i < count; i++) { final View child = getChildAt(i); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (lp.childType == LayoutParams.CHILD_TYPE_CHALLENGE) { if (mChallengeView != null) { throw new IllegalStateException( "There may only be one child with layout_isChallenge=\"true\""); } if (!(child instanceof KeyguardSecurityContainer)) { throw new IllegalArgumentException( "Challenge must be a KeyguardSecurityContainer"); } mChallengeView = (KeyguardSecurityContainer) child; if (mChallengeView != oldChallengeView) { mChallengeView.setVisibility(mChallengeShowing ? VISIBLE : INVISIBLE); } // We're going to play silly games with the frame's background drawable later. if (!mHasLayout) { // Set up the margin correctly based on our content for the first run. mHasGlowpad = child.findViewById(R.id.keyguard_selector_view) != null; lp.leftMargin = lp.rightMargin = getChallengeMargin(true); } } else if (lp.childType == LayoutParams.CHILD_TYPE_EXPAND_CHALLENGE_HANDLE) { if (mExpandChallengeView != null) { throw new IllegalStateException( "There may only be one child with layout_childType" + "=\"expandChallengeHandle\""); } mExpandChallengeView = child; if (mExpandChallengeView != oldExpandChallengeView) { mExpandChallengeView.setVisibility(mChallengeShowing ? INVISIBLE : VISIBLE); mExpandChallengeView.setOnClickListener(mExpandChallengeClickListener); } } else if (lp.childType == LayoutParams.CHILD_TYPE_SCRIM) { setScrimView(child); } else if (lp.childType == LayoutParams.CHILD_TYPE_WIDGETS) { mWidgetsView = child; } } // We want to measure the challenge view first, since the KeyguardWidgetPager // needs to do things its measure pass that are dependent on the challenge view // having been measured. if (mChallengeView != null && mChallengeView.getVisibility() != View.GONE) { // This one's a little funny. If the IME is present - reported in the form // of insets on the root view - we only give the challenge the space it would // have had if the IME wasn't there in order to keep the rest of the layout stable. // We base this on the layout_maxHeight on the challenge view. If it comes out // negative or zero, either we didn't have a maxHeight or we're totally out of space, // so give up and measure as if this rule weren't there. int challengeHeightSpec = heightSpec; final View root = getRootView(); if (root != null) { final LayoutParams lp = (LayoutParams) mChallengeView.getLayoutParams(); final int specSize = MeasureSpec.getSize(heightSpec); final int windowHeight = mDisplayMetrics.heightPixels - root.getPaddingTop(); final int diff = windowHeight - specSize; final int maxChallengeHeight = lp.maxHeight - diff; if (maxChallengeHeight > 0) { challengeHeightSpec = makeChildMeasureSpec(maxChallengeHeight, lp.height); } } measureChildWithMargins(mChallengeView, widthSpec, 0, challengeHeightSpec, 0); } // Measure the rest of the children for (int i = 0; i < count; i++) { final View child = getChildAt(i); if (child.getVisibility() == GONE) { continue; } // Don't measure the challenge view twice! if (child == mChallengeView) continue; // Measure children. Widget frame measures special, so that we can ignore // insets for the IME. int parentWidthSpec = widthSpec, parentHeightSpec = heightSpec; final LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (lp.childType == LayoutParams.CHILD_TYPE_WIDGETS) { final View root = getRootView(); if (root != null) { // This calculation is super dodgy and relies on several assumptions. // Specifically that the root of the window will be padded in for insets // and that the window is LAYOUT_IN_SCREEN. final int windowWidth = mDisplayMetrics.widthPixels; final int windowHeight = mDisplayMetrics.heightPixels - root.getPaddingTop(); parentWidthSpec = MeasureSpec.makeMeasureSpec( windowWidth, MeasureSpec.EXACTLY); parentHeightSpec = MeasureSpec.makeMeasureSpec( windowHeight, MeasureSpec.EXACTLY); } } measureChildWithMargins(child, parentWidthSpec, 0, parentHeightSpec, 0); } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { final int paddingLeft = getPaddingLeft(); final int paddingTop = getPaddingTop(); final int paddingRight = getPaddingRight(); final int paddingBottom = getPaddingBottom(); final int width = r - l; final int height = b - t; final int count = getChildCount(); for (int i = 0; i < count; i++) { final View child = getChildAt(i); if (child.getVisibility() == GONE) continue; final LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (lp.childType == LayoutParams.CHILD_TYPE_CHALLENGE) { // Challenge views pin to the bottom, offset by a portion of their height, // and center horizontally. final int center = (paddingLeft + width - paddingRight) / 2; final int childWidth = child.getMeasuredWidth(); final int childHeight = child.getMeasuredHeight(); final int left = center - childWidth / 2; final int layoutBottom = height - paddingBottom - lp.bottomMargin; // We use the top of the challenge view to position the handle, so // we never want less than the handle size showing at the bottom. final int bottom = layoutBottom + (int) ((childHeight - mChallengeBottomBound) * (1 - mChallengeOffset)); child.setAlpha(getChallengeAlpha()); child.layout(left, bottom - childHeight, left + childWidth, bottom); } else if (lp.childType == LayoutParams.CHILD_TYPE_EXPAND_CHALLENGE_HANDLE) { final int center = (paddingLeft + width - paddingRight) / 2; final int left = center - child.getMeasuredWidth() / 2; final int right = left + child.getMeasuredWidth(); final int bottom = height - paddingBottom - lp.bottomMargin; final int top = bottom - child.getMeasuredHeight(); child.layout(left, top, right, bottom); } else { // Non-challenge views lay out from the upper left, layered. child.layout(paddingLeft + lp.leftMargin, paddingTop + lp.topMargin, paddingLeft + child.getMeasuredWidth(), paddingTop + child.getMeasuredHeight()); } } if (!mHasLayout) { mHasLayout = true; } } @Override public void draw(Canvas c) { super.draw(c); if (DEBUG) { final Paint debugPaint = new Paint(); debugPaint.setColor(0x40FF00CC); // show the isInDragHandle() rect c.drawRect(mDragHandleEdgeSlop, mChallengeView.getTop() - getDragHandleSizeAbove(), getWidth() - mDragHandleEdgeSlop, mChallengeView.getTop() + getDragHandleSizeBelow(), debugPaint); } } public void computeScroll() { super.computeScroll(); if (!mScroller.isFinished()) { if (mChallengeView == null) { // Can't scroll if the view is missing. Log.e(TAG, "Challenge view missing in computeScroll"); mScroller.abortAnimation(); return; } mScroller.computeScrollOffset(); moveChallengeTo(mScroller.getCurrY()); if (mScroller.isFinished()) { post(mEndScrollRunnable); } } } private void cancelTransitionsInProgress() { if (!mScroller.isFinished()) { mScroller.abortAnimation(); completeChallengeScroll(); } if (mFader != null) { mFader.cancel(); } } public void fadeInChallenge() { fadeChallenge(true); } public void fadeOutChallenge() { fadeChallenge(false); } public void fadeChallenge(final boolean show) { if (mChallengeView != null) { cancelTransitionsInProgress(); float alpha = show ? 1f : 0f; int duration = show ? CHALLENGE_FADE_IN_DURATION : CHALLENGE_FADE_OUT_DURATION; mFader = ObjectAnimator.ofFloat(mChallengeView, "alpha", alpha); mFader.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { onFadeStart(show); } @Override public void onAnimationEnd(Animator animation) { onFadeEnd(show); } }); mFader.setDuration(duration); mFader.start(); } } private int getMaxChallengeBottom() { if (mChallengeView == null) return 0; final int layoutBottom = getLayoutBottom(); final int challengeHeight = mChallengeView.getMeasuredHeight(); return (layoutBottom + challengeHeight - mChallengeBottomBound); } private int getMinChallengeBottom() { return getLayoutBottom(); } private void onFadeStart(boolean show) { mChallengeInteractiveInternal = false; mChallengeView.setLayerType(LAYER_TYPE_HARDWARE, null); if (show) { moveChallengeTo(getMinChallengeBottom()); } setScrollState(SCROLL_STATE_FADING); } private void onFadeEnd(boolean show) { mChallengeInteractiveInternal = true; setChallengeShowing(show); if (!show) { moveChallengeTo(getMaxChallengeBottom()); } mChallengeView.setLayerType(LAYER_TYPE_NONE, null); mFader = null; setScrollState(SCROLL_STATE_IDLE); } public int getMaxChallengeTop() { if (mChallengeView == null) return 0; final int layoutBottom = getLayoutBottom(); final int challengeHeight = mChallengeView.getMeasuredHeight(); return layoutBottom - challengeHeight; } /** * Move the bottom edge of mChallengeView to a new position and notify the listener * if it represents a change in position. Changes made through this method will * be stable across layout passes. If this method is called before first layout of * this SlidingChallengeLayout it will have no effect. * * @param bottom New bottom edge in px in this SlidingChallengeLayout's coordinate system. * @return true if the challenge view was moved */ private boolean moveChallengeTo(int bottom) { if (mChallengeView == null || !mHasLayout) { return false; } final int layoutBottom = getLayoutBottom(); final int challengeHeight = mChallengeView.getHeight(); bottom = Math.max(getMinChallengeBottom(), Math.min(bottom, getMaxChallengeBottom())); float offset = 1.f - (float) (bottom - layoutBottom) / (challengeHeight - mChallengeBottomBound); mChallengeOffset = offset; if (offset > 0 && !mChallengeShowing) { setChallengeShowing(true); } mChallengeView.layout(mChallengeView.getLeft(), bottom - mChallengeView.getHeight(), mChallengeView.getRight(), bottom); mChallengeView.setAlpha(getChallengeAlpha()); if (mScrollListener != null) { mScrollListener.onScrollPositionChanged(offset, mChallengeView.getTop()); } postInvalidateOnAnimation(); return true; } /** * The bottom edge of this SlidingChallengeLayout's coordinate system; will coincide with * the bottom edge of mChallengeView when the challenge is fully opened. */ private int getLayoutBottom() { final int bottomMargin = (mChallengeView == null) ? 0 : ((LayoutParams) mChallengeView.getLayoutParams()).bottomMargin; final int layoutBottom = getMeasuredHeight() - getPaddingBottom() - bottomMargin; return layoutBottom; } /** * The bottom edge of mChallengeView; essentially, where the sliding challenge 'is'. */ private int getChallengeBottom() { if (mChallengeView == null) return 0; return mChallengeView.getBottom(); } /** * Show or hide the challenge view, animating it if necessary. * @param show true to show, false to hide */ public void showChallenge(boolean show) { showChallenge(show, 0); if (!show) { // Block any drags in progress so that callers can use this to disable dragging // for other touch interactions. mBlockDrag = true; } } private void showChallenge(int velocity) { boolean show = false; if (Math.abs(velocity) > mMinVelocity) { show = velocity < 0; } else { show = mChallengeOffset >= 0.5f; } showChallenge(show, velocity); } private void showChallenge(boolean show, int velocity) { if (mChallengeView == null) { setChallengeShowing(false); return; } if (mHasLayout) { mChallengeShowingTargetState = show; final int layoutBottom = getLayoutBottom(); animateChallengeTo(show ? layoutBottom : layoutBottom + mChallengeView.getHeight() - mChallengeBottomBound, velocity); } } @Override public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) { return new LayoutParams(getContext(), attrs); } @Override protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { return p instanceof LayoutParams ? new LayoutParams((LayoutParams) p) : p instanceof MarginLayoutParams ? new LayoutParams((MarginLayoutParams) p) : new LayoutParams(p); } @Override protected ViewGroup.LayoutParams generateDefaultLayoutParams() { return new LayoutParams(); } @Override protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { return p instanceof LayoutParams; } public static class LayoutParams extends MarginLayoutParams { public int childType = CHILD_TYPE_NONE; public static final int CHILD_TYPE_NONE = 0; public static final int CHILD_TYPE_CHALLENGE = 2; public static final int CHILD_TYPE_SCRIM = 4; public static final int CHILD_TYPE_WIDGETS = 5; public static final int CHILD_TYPE_EXPAND_CHALLENGE_HANDLE = 6; public int maxHeight; public LayoutParams() { this(MATCH_PARENT, WRAP_CONTENT); } public LayoutParams(int width, int height) { super(width, height); } public LayoutParams(android.view.ViewGroup.LayoutParams source) { super(source); } public LayoutParams(MarginLayoutParams source) { super(source); } public LayoutParams(LayoutParams source) { super(source); childType = source.childType; } public LayoutParams(Context c, AttributeSet attrs) { super(c, attrs); final TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.SlidingChallengeLayout_Layout); childType = a.getInt(R.styleable.SlidingChallengeLayout_Layout_layout_childType, CHILD_TYPE_NONE); maxHeight = a.getDimensionPixelSize( R.styleable.SlidingChallengeLayout_Layout_layout_maxHeight, 0); a.recycle(); } } }