/* * Copyright (C) 2013 Simon Vig Therkildsen * * 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 net.simonvt.cathode.widget; import android.animation.Animator; import android.animation.ObjectAnimator; import android.annotation.TargetApi; import android.content.Context; import android.graphics.Canvas; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.WindowInsets; public class HiddenPaneLayout extends ViewGroup { private static final int INVALID_POINTER = -1; private static final int CLOSE_ENOUGH = 3; private static final SmoothInterpolator SMOOTH_INTERPOLATOR = new SmoothInterpolator(); private static final String ANIMATE_PROPERTY = "offsetPixels"; public static final int STATE_CLOSED = 0; public static final int STATE_CLOSING = 1; public static final int STATE_DRAGGING = 2; public static final int STATE_OPENING = 4; public static final int STATE_OPEN = 8; private int state = STATE_CLOSED; private ObjectAnimator animator; private int hiddenPaneWidth = -1; private float offsetPixels = 0; private View contentPane; private View hiddenPane; private boolean isDragging; private int closeEnough; private VelocityTracker velocityTracker; private float lastMotionX; private float lastMotionY; private float initialMotionX; private float initialMotionY; private int activePointerId; private int touchSlop; private int maxVelocity; private boolean layerTypeHardware; private Drawable dropShadow; private int dropShadowWidth; public HiddenPaneLayout(Context context) { super(context); init(context); } public HiddenPaneLayout(Context context, AttributeSet attrs) { super(context, attrs); init(context); } public HiddenPaneLayout(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context); } private void init(Context context) { final ViewConfiguration configuration = ViewConfiguration.get(context); touchSlop = configuration.getScaledTouchSlop(); maxVelocity = configuration.getScaledMaximumFlingVelocity(); closeEnough = dpToPx(CLOSE_ENOUGH); dropShadow = new GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, new int[] { 0x80000000, 0x00000000, }); dropShadowWidth = dpToPx(6); setWillNotDraw(false); } @TargetApi(Build.VERSION_CODES.LOLLIPOP) @Override public WindowInsets onApplyWindowInsets(WindowInsets insets) { hiddenPane.setPadding(0, insets.getSystemWindowInsetTop(), 0, 0); return insets; } protected int dpToPx(int dp) { return (int) (getResources().getDisplayMetrics().density * dp + 0.5f); } public void toggle() { if (state == STATE_OPEN || state == STATE_OPENING) { close(); } else if (state == STATE_CLOSED || state == STATE_CLOSING) { open(); } } public void open() { open(true); } public void open(boolean animate) { animateOffsetTo(-hiddenPaneWidth, 0, animate); } public void close() { close(true); } public void close(boolean animate) { animateOffsetTo(0, 0, animate); } public int getState() { return state; } @Override public void addView(View child, int index, LayoutParams params) { super.addView(child, index, params); if (contentPane == null) { contentPane = child; } else if (hiddenPane == null) { hiddenPane = child; } else { throw new IllegalStateException("HiddenPaneLayout can only have two direct children"); } } @Override protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); final int width = getWidth(); final int height = getHeight(); final int offsetPixels = (int) this.offsetPixels; dropShadow.setBounds(width + offsetPixels, 0, width + offsetPixels + dropShadowWidth, height); dropShadow.draw(canvas); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { final int width = r - l; final int height = b - t; View contentPane = getChildAt(0); contentPane.layout(0, 0, width, height); final View hiddenPane = getChildAt(1); final int hiddenPaneWidth = hiddenPane.getMeasuredWidth(); hiddenPane.layout(width, 0, width + hiddenPaneWidth, height); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { final int widthMode = MeasureSpec.getMode(widthMeasureSpec); final int heightMode = MeasureSpec.getMode(heightMeasureSpec); if (widthMode == MeasureSpec.UNSPECIFIED || heightMode == MeasureSpec.UNSPECIFIED) { throw new IllegalStateException("Must measure with an exact size"); } final int width = MeasureSpec.getSize(widthMeasureSpec); final int height = MeasureSpec.getSize(heightMeasureSpec); final int contentWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, 0, width); final int contentHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec, 0, height); contentPane.measure(contentWidthMeasureSpec, contentHeightMeasureSpec); LayoutParams lp = hiddenPane.getLayoutParams(); hiddenPaneWidth = lp.width; final int hiddenWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, 0, lp.width); final int hiddenHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec, 0, height); hiddenPane.measure(hiddenWidthMeasureSpec, hiddenHeightMeasureSpec); setMeasuredDimension(width, height); } private void setOffsetPixels(float offsetPixels) { final int oldOffset = (int) this.offsetPixels; this.offsetPixels = offsetPixels; final int newOffset = (int) offsetPixels; if (newOffset != oldOffset) { contentPane.setTranslationX(newOffset); hiddenPane.setTranslationX(newOffset); invalidate(); } } private boolean isContentTouch(int x) { return offsetPixels < 0.0f && x < getWidth() + (int) offsetPixels; } protected boolean onDownAllowDrag(int x) { return offsetPixels < 0.0f && x < getWidth() + (int) offsetPixels; } protected boolean onMoveAllowDrag(int x) { return offsetPixels < 0.0f && x < getWidth() + (int) offsetPixels; } protected void onMoveEvent(float dx) { setOffsetPixels(Math.max(Math.min(offsetPixels + dx, 0), -hiddenPaneWidth)); } protected boolean checkTouchSlop(float dx, float dy) { return Math.abs(dx) > touchSlop && Math.abs(dx) > Math.abs(dy); } private boolean isCloseEnough() { return Math.abs(offsetPixels) <= closeEnough; } private void stopAnimation() { if (animator != null) { animator.cancel(); animator = null; } stopLayerTranslation(); } /** Called when a drag has been ended. */ protected void endDrag() { isDragging = false; if (velocityTracker != null) { velocityTracker.recycle(); velocityTracker = null; } } private void setState(int state) { if (state != this.state) { this.state = state; } } protected void animateOffsetTo(int position, int velocity, boolean animate) { endDrag(); stopAnimation(); final int startX = (int) offsetPixels; final int dx = position - startX; if (dx == 0 || !animate) { setOffsetPixels(position); setState(position == 0 ? STATE_CLOSED : STATE_OPEN); stopLayerTranslation(); return; } int duration; velocity = Math.abs(velocity); if (velocity > 0) { duration = 4 * Math.round(1000.f * Math.abs((float) dx / velocity)); } else { duration = (int) (600.f * Math.abs((float) dx / hiddenPaneWidth)); } duration = Math.min(duration, 600); animateOffsetTo(position, duration); } protected void animateOffsetTo(final int position, int duration) { if (position < offsetPixels) { setState(STATE_OPENING); } else { setState(STATE_CLOSING); } animator = ObjectAnimator.ofFloat(this, ANIMATE_PROPERTY, offsetPixels, position); animator.setInterpolator(SMOOTH_INTERPOLATOR); animator.setDuration(duration); animator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animator) { startLayerTranslation(); } @Override public void onAnimationEnd(Animator animator) { stopLayerTranslation(); setState(position == 0 ? STATE_CLOSED : STATE_OPEN); } @Override public void onAnimationCancel(Animator animator) { } @Override public void onAnimationRepeat(Animator animator) { } }); animator.start(); } protected void startLayerTranslation() { if (!layerTypeHardware) { layerTypeHardware = true; contentPane.setLayerType(View.LAYER_TYPE_HARDWARE, null); hiddenPane.setLayerType(View.LAYER_TYPE_HARDWARE, null); } } /** * If the current layer type is {@link android.view.View#LAYER_TYPE_HARDWARE}, this will set it * to * {@link View#LAYER_TYPE_NONE}. */ protected void stopLayerTranslation() { if (layerTypeHardware) { layerTypeHardware = false; contentPane.setLayerType(View.LAYER_TYPE_NONE, null); hiddenPane.setLayerType(View.LAYER_TYPE_NONE, null); } } public boolean onInterceptTouchEvent(MotionEvent ev) { final int action = ev.getAction() & MotionEvent.ACTION_MASK; if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { activePointerId = INVALID_POINTER; isDragging = false; if (velocityTracker != null) { velocityTracker.recycle(); velocityTracker = null; } if (Math.abs(offsetPixels) > hiddenPaneWidth / 2) { open(); } else { close(); } return false; } if (action == MotionEvent.ACTION_DOWN && offsetPixels < 0.0f && isCloseEnough()) { setOffsetPixels(0); stopAnimation(); setState(STATE_CLOSED); isDragging = false; } // Always intercept events over the content while menu is visible. if (offsetPixels < 0.0f) { int index = 0; if (activePointerId != INVALID_POINTER) { index = ev.findPointerIndex(activePointerId); index = index == -1 ? 0 : index; } final int x = (int) ev.getX(index); final int y = (int) ev.getY(index); if (isContentTouch(x)) { return true; } } if (offsetPixels == 0.0f) { return false; } if (action != MotionEvent.ACTION_DOWN && isDragging) { return true; } switch (action) { case MotionEvent.ACTION_DOWN: { lastMotionX = initialMotionX = ev.getX(); lastMotionY = initialMotionY = ev.getY(); final boolean allowDrag = onDownAllowDrag((int) lastMotionX); activePointerId = ev.getPointerId(0); if (allowDrag) { setState(offsetPixels < 0.0f ? STATE_OPEN : STATE_CLOSED); stopAnimation(); startLayerTranslation(); isDragging = false; } break; } case MotionEvent.ACTION_MOVE: { final int activePointerId = this.activePointerId; if (activePointerId == INVALID_POINTER) { // If we don't have a valid id, the touch down wasn't on content. break; } final int pointerIndex = ev.findPointerIndex(activePointerId); final float x = ev.getX(pointerIndex); final float dx = x - lastMotionX; final float y = ev.getY(pointerIndex); final float dy = y - lastMotionY; if (checkTouchSlop(dx, dy)) { final boolean allowDrag = onMoveAllowDrag((int) x); if (allowDrag) { startLayerTranslation(); setState(STATE_DRAGGING); isDragging = true; lastMotionX = x; lastMotionY = y; } } break; } case MotionEvent.ACTION_POINTER_UP: onPointerUp(ev); lastMotionX = ev.getX(ev.findPointerIndex(activePointerId)); lastMotionY = ev.getY(ev.findPointerIndex(activePointerId)); break; } if (velocityTracker == null) velocityTracker = VelocityTracker.obtain(); velocityTracker.addMovement(ev); return isDragging; } @Override public boolean onTouchEvent(MotionEvent ev) { final int action = ev.getAction() & MotionEvent.ACTION_MASK; if (action == MotionEvent.ACTION_DOWN && offsetPixels == 0.0f) { return false; } if (velocityTracker == null) velocityTracker = VelocityTracker.obtain(); velocityTracker.addMovement(ev); switch (action) { case MotionEvent.ACTION_DOWN: { lastMotionX = initialMotionX = ev.getX(); lastMotionY = initialMotionY = ev.getY(); final boolean allowDrag = onDownAllowDrag((int) lastMotionX); activePointerId = ev.getPointerId(0); if (allowDrag) { stopAnimation(); startLayerTranslation(); } break; } case MotionEvent.ACTION_MOVE: { if (!isDragging) { final int pointerIndex = ev.findPointerIndex(activePointerId); final float x = ev.getX(pointerIndex); final float dx = x - lastMotionX; final float y = ev.getY(pointerIndex); final float dy = y - lastMotionY; if (checkTouchSlop(dx, dy)) { final boolean allowDrag = onMoveAllowDrag((int) x); if (allowDrag) { isDragging = true; setState(STATE_DRAGGING); lastMotionX = x; lastMotionY = y; } else { initialMotionX = x; initialMotionY = y; } } } if (isDragging) { startLayerTranslation(); final int pointerIndex = ev.findPointerIndex(activePointerId); final float x = ev.getX(pointerIndex); final float dx = x - lastMotionX; final float y = ev.getY(pointerIndex); final float dy = y - lastMotionY; lastMotionX = x; lastMotionY = y; onMoveEvent(dx); } break; } case MotionEvent.ACTION_UP: { final int index = ev.findPointerIndex(activePointerId); final int x = (int) ev.getX(index); final int offsetPixels = (int) this.offsetPixels; if (isDragging) { velocityTracker.computeCurrentVelocity(1000, maxVelocity); final int initialVelocity = (int) velocityTracker.getXVelocity(activePointerId); lastMotionX = x; animateOffsetTo(initialVelocity < 0 ? -hiddenPaneWidth : 0, initialVelocity, true); // Close the menu when content is clicked while the menu is visible. } else if (this.offsetPixels < 0.0f && x < getWidth() + offsetPixels) { close(); } activePointerId = INVALID_POINTER; isDragging = false; break; } case MotionEvent.ACTION_CANCEL: { close(); activePointerId = INVALID_POINTER; isDragging = false; break; } case MotionEvent.ACTION_POINTER_DOWN: final int index = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) >> MotionEvent.ACTION_POINTER_INDEX_SHIFT; lastMotionX = ev.getX(index); lastMotionY = ev.getY(index); activePointerId = ev.getPointerId(index); break; case MotionEvent.ACTION_POINTER_UP: onPointerUp(ev); lastMotionX = ev.getX(ev.findPointerIndex(activePointerId)); lastMotionY = ev.getY(ev.findPointerIndex(activePointerId)); break; } return true; } private void onPointerUp(MotionEvent ev) { final int pointerIndex = ev.getActionIndex(); final int pointerId = ev.getPointerId(pointerIndex); if (pointerId == activePointerId) { final int newPointerIndex = pointerIndex == 0 ? 1 : 0; lastMotionX = ev.getX(newPointerIndex); activePointerId = ev.getPointerId(newPointerIndex); if (velocityTracker != null) { velocityTracker.clear(); } } } @Override protected Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); SavedState state = new SavedState(superState); state.open = this.state == STATE_OPENING || this.state == STATE_OPEN; return state; } @Override protected void onRestoreInstanceState(Parcelable state) { SavedState savedState = (SavedState) state; super.onRestoreInstanceState(savedState.getSuperState()); final int hiddenPaneWidth = hiddenPane.getLayoutParams().width; setOffsetPixels(savedState.open ? -hiddenPaneWidth : 0); setState(savedState.open ? STATE_OPEN : STATE_CLOSED); } static class SavedState extends BaseSavedState { boolean open; public SavedState(Parcelable superState) { super(superState); } public SavedState(Parcel in) { super(in); open = in.readInt() == 1; } @Override public void writeToParcel(Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeInt(open ? 1 : 0); } @SuppressWarnings("UnusedDeclaration") public static final Creator<SavedState> CREATOR = new Creator<SavedState>() { @Override public SavedState createFromParcel(Parcel in) { return new SavedState(in); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }; } }