package com.prolificinteractive.chandelier.widget; import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; import android.os.Build; import android.support.annotation.Nullable; import android.support.v4.view.MotionEventCompat; import android.support.v4.view.ScrollingView; import android.support.v4.view.ViewCompat; import android.support.v4.widget.NestedScrollView; import android.support.v7.widget.RecyclerView; import android.util.AttributeSet; import android.util.Log; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.animation.Animation; import android.view.animation.Animation.AnimationListener; import android.view.animation.DecelerateInterpolator; import android.view.animation.Transformation; import android.widget.AbsListView; import com.prolificinteractive.chandelier.R; import java.util.List; public class ChandelierLayout extends ViewGroup { private static final String LOG_TAG = ChandelierLayout.class.getSimpleName(); private static final float DECELERATE_INTERPOLATION_FACTOR = 2f; private static final int INVALID_POINTER = -1; private static final float DRAG_RATE = .8f; private static final int ANIMATE_TO_START_DURATION = 300; private static final int[] LAYOUT_ATTRS = new int[] { android.R.attr.enabled }; private final AttributeSet attrs; private final DecelerateInterpolator decelerateInterpolator; protected int from; protected int originalOffsetTop; private boolean actionSelected; private View absListView; private View target; // the target of the gesture private OnActionListener listener; private int touchSlop; private float totalDragDistance = -1; private int currentTargetOffsetTop; // Whether or not the starting offset has been determined. private boolean originalOffsetCalculated = false; private float initialMotionY; private float initialDownY; private boolean isBeingDragged; private int activePointerId = INVALID_POINTER; // Target is returning to its start offset because it was cancelled or a // refresh was triggered. private boolean isReturningToStart; private int animateToStartDuration; private OrnamentLayout ornamentLayout; private float spinnerFinalOffset; private IdleScrollListener scrollListener = new IdleScrollListener(); private final AnimationListener moveToStartListener = new SimpleAnimationListener() { @Override public void onAnimationEnd(Animation animation) { if (actionSelected) { int selectedIndex = ornamentLayout.getSelectedIndex(); listener.onActionSelected(selectedIndex, ornamentLayout.getActionItem(selectedIndex)); actionSelected = false; } } }; private final Animation animateToStartPosition = new Animation() { @Override public void applyTransformation(float interpolatedTime, Transformation t) { moveToStart(interpolatedTime); } }; private boolean isShowingAction = false; /** * Simple constructor to use when creating a SwipeRefreshLayout from code. */ public ChandelierLayout(Context context) { this(context, null); } /** * Constructor that is called when inflating SwipeRefreshLayout from XML. */ public ChandelierLayout(Context context, AttributeSet attrs) { super(context, attrs); this.attrs = attrs; final Resources res = getResources(); // Defaults final int defaultElevation = res.getDimensionPixelSize(R.dimen.default_elevation); touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); setWillNotDraw(false); decelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); setEnabled(a.getBoolean(0, true)); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { setElevation( a.getDimensionPixelSize(R.styleable.ChandelierLayout_chandelier_elevation, defaultElevation)); } animateToStartDuration = a.getInteger(R.styleable.ChandelierLayout_chandelier_animate_to_start_duration, ANIMATE_TO_START_DURATION); a.recycle(); createProgressView(); ViewCompat.setChildrenDrawingOrderEnabled(this, true); } private void createProgressView() { ornamentLayout = new OrnamentLayout(getContext(), attrs); ornamentLayout.setVisibility(View.GONE); addView(ornamentLayout); } /** * Set the listener to be notified when a refresh is triggered via the swipe * gesture. */ public void setOnActionSelectedListener(OnActionListener listener) { this.listener = listener; } private void ensureTarget() { // Don't bother getting the parent height if the parent hasn't been laid // out yet. if (target == null) { for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); if (!child.equals(ornamentLayout)) { target = child; break; } } } if (absListView == null) { for (int i = 0; i < getChildCount(); i++) { final View child = getChildAt(i); if (child instanceof ScrollingView || child instanceof NestedScrollView) { // TODO fix validation absListView = child; scrollListener.setParent(absListView); if (absListView instanceof AbsListView) { ((AbsListView) absListView).setOnScrollListener(scrollListener); } else if (absListView instanceof RecyclerView) { ((RecyclerView) absListView).addOnScrollListener(scrollListener); } absListView.setOnTouchListener(new OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { if (isShowingAction) { onTouchEvent(event); return true; } return false; } }); break; } } } } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { final int width = getMeasuredWidth(); final int height = getMeasuredHeight(); if (getChildCount() == 0) { return; } if (target == null) { ensureTarget(); } if (target == null) { return; } final View child = target; final int childLeft = getPaddingLeft(); final int childTop = getPaddingTop(); final int childWidth = width - getPaddingLeft() - getPaddingRight(); final int childHeight = height - getPaddingTop() - getPaddingBottom(); child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); ornamentLayout.layout(0, currentTargetOffsetTop, width, currentTargetOffsetTop + ornamentLayout.getMeasuredHeight()); } @Override public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); if (target == null) { ensureTarget(); } if (target == null) { return; } target.measure( MeasureSpec.makeMeasureSpec( getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY ), MeasureSpec.makeMeasureSpec( getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY )); ornamentLayout.measure( MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY), ornamentLayout.getMeasuredHeight() ); if (!originalOffsetCalculated) { originalOffsetCalculated = true; spinnerFinalOffset = ornamentLayout.getMeasuredHeight(); totalDragDistance = spinnerFinalOffset; currentTargetOffsetTop = originalOffsetTop = -ornamentLayout.getMeasuredHeight(); } } /** * @return Whether it is possible for the child view of this layout to * scroll up. Override this if the child view is a custom view. */ public boolean canChildScrollUp() { if (android.os.Build.VERSION.SDK_INT < 14) { if (target instanceof AbsListView) { final AbsListView absListView = (AbsListView) target; return absListView.getChildCount() > 0 && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0) .getTop() < absListView.getPaddingTop()); } else { return ViewCompat.canScrollVertically(target, -1) || target.getScrollY() > 0; } } else { return ViewCompat.canScrollVertically(target, -1); } } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { ensureTarget(); final int action = MotionEventCompat.getActionMasked(ev); if (isReturningToStart && action == MotionEvent.ACTION_DOWN) { isReturningToStart = false; } if (!isEnabled() || isReturningToStart || canChildScrollUp()) { // Fail fast if we're not in a state where a swipe is possible return false; } switch (action) { case MotionEvent.ACTION_DOWN: setTargetOffsetTopAndBottom(originalOffsetTop - ornamentLayout.getTop()); activePointerId = MotionEventCompat.getPointerId(ev, 0); isBeingDragged = false; final float initialDownY = getMotionEventY(ev, activePointerId); if (initialDownY == -1) { return false; } this.initialDownY = initialDownY; break; case MotionEvent.ACTION_MOVE: if (activePointerId == INVALID_POINTER) { Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id."); return false; } final float y = getMotionEventY(ev, activePointerId); if (y == -1) { return false; } final float yDiff = y - this.initialDownY; if (yDiff > touchSlop && !isBeingDragged) { initialMotionY = this.initialDownY + touchSlop; isBeingDragged = true; } break; case MotionEventCompat.ACTION_POINTER_UP: onSecondaryPointerUp(ev); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: isBeingDragged = false; activePointerId = INVALID_POINTER; break; } return isBeingDragged; } private float getMotionEventY(MotionEvent ev, int activePointerId) { final int index = MotionEventCompat.findPointerIndex(ev, activePointerId); if (index < 0) { return -1; } return MotionEventCompat.getY(ev, index); } @Override public void requestDisallowInterceptTouchEvent(boolean b) { // if this is a List < L or another view that doesn't support nested // scrolling, ignore this request so that the vertical scroll event // isn't stolen if ((android.os.Build.VERSION.SDK_INT >= 21 || !(target instanceof AbsListView)) && (target == null || ViewCompat.isNestedScrollingEnabled(target))) { super.requestDisallowInterceptTouchEvent(b); } } private void moveActionLayout(final float overscrollTop) { Log.d(LOG_TAG, "### overscrollTop: " + overscrollTop + "; mOriginalOffsetTop: " + originalOffsetTop); final float originalDragPercent = overscrollTop / totalDragDistance; final float dragPercent = Math.min(1f, Math.abs(originalDragPercent)); final int targetY = originalOffsetTop + (int) (spinnerFinalOffset * dragPercent); if (ornamentLayout.getVisibility() != View.VISIBLE) { ornamentLayout.setVisibility(View.VISIBLE); } setTargetOffsetTopAndBottom(targetY - currentTargetOffsetTop); ornamentLayout.onLayoutTranslated(1 - (float) targetY / currentTargetOffsetTop); } private void finishAction(final float overscrollTop) { actionSelected = overscrollTop > totalDragDistance; if (actionSelected) { ornamentLayout.finishAction(new SimpleAnimationListener() { @Override public void onAnimationEnd(Animation animation) { animateOffsetToStartPosition(); } }); } else { animateOffsetToStartPosition(); } } @Override public boolean onTouchEvent(MotionEvent ev) { final int action = MotionEventCompat.getActionMasked(ev); if (isReturningToStart && action == MotionEvent.ACTION_DOWN) { isReturningToStart = false; } if (action == MotionEvent.ACTION_UP && isShowingAction) { hideActions(); return true; } if (!isEnabled() || isReturningToStart || canChildScrollUp() || (!scrollListener.isIdle() && !isShowingAction)) { // Fail fast if we're not in a state where a swipe is possible if (ornamentLayout != null && isShowingAction) { ornamentLayout.onParentTouchEvent(ev); } return false; } switch (action) { case MotionEvent.ACTION_DOWN: activePointerId = MotionEventCompat.getPointerId(ev, 0); isBeingDragged = false; break; case MotionEvent.ACTION_MOVE: { final int pointerIndex = MotionEventCompat.findPointerIndex(ev, activePointerId); if (pointerIndex < 0) { Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id."); return false; } final float y = MotionEventCompat.getY(ev, pointerIndex); final float overscrollTop = (y - initialMotionY) * DRAG_RATE; if (isBeingDragged) { if (overscrollTop > 0) { moveActionLayout(overscrollTop); } else if (!isShowingAction) { Log.d(LOG_TAG, "### false"); return false; } } break; } case MotionEventCompat.ACTION_POINTER_DOWN: { final int index = MotionEventCompat.getActionIndex(ev); activePointerId = MotionEventCompat.getPointerId(ev, index); break; } case MotionEventCompat.ACTION_POINTER_UP: onSecondaryPointerUp(ev); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: { if (activePointerId == INVALID_POINTER) { if (action == MotionEvent.ACTION_UP) { Log.e(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id."); } return false; } final int pointerIndex = MotionEventCompat.findPointerIndex(ev, activePointerId); final float y = MotionEventCompat.getY(ev, pointerIndex); final float overscrollTop = (y - initialMotionY) * DRAG_RATE; isBeingDragged = false; finishAction(overscrollTop); activePointerId = INVALID_POINTER; return false; } } if (ornamentLayout != null) { ornamentLayout.onParentTouchEvent(ev); } return true; } private void animateOffsetToStartPosition() { from = Math.round(ViewCompat.getTranslationY(ornamentLayout)); animateToStartPosition.reset(); animateToStartPosition.setDuration(animateToStartDuration); animateToStartPosition.setInterpolator(decelerateInterpolator); animateToStartPosition.setAnimationListener(moveToStartListener); ornamentLayout.clearAnimation(); ornamentLayout.startAnimation(animateToStartPosition); } private void moveToStart(float interpolatedTime) { setTargetOffsetTopAndBottom(Math.round((1 - interpolatedTime) * from)); } private void setTargetOffsetTopAndBottom(final int offset) { ViewCompat.setTranslationY(ornamentLayout, offset); ViewCompat.setTranslationY(absListView, offset); currentTargetOffsetTop = ornamentLayout.getTop(); } private void onSecondaryPointerUp(MotionEvent ev) { final int pointerIndex = MotionEventCompat.getActionIndex(ev); final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex); if (pointerId == activePointerId) { // This was our active pointer going up. Choose a new // active pointer and adjust accordingly. final int newPointerIndex = pointerIndex == 0 ? 1 : 0; activePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex); } } /** * Add a list of actions to the {@link ChandelierLayout}. * * @param items list of {@link Ornament} to display */ public void populateActionItems(@Nullable final List<? extends Ornament> items) { ornamentLayout.populateActionItems(items); } /** * Show the actions of the {@link ChandelierLayout} */ public void showActions() { isShowingAction = true; final Animation showAnimation = new Animation() { @Override protected void applyTransformation(float interpolatedTime, Transformation t) { moveActionLayout(-interpolatedTime * originalOffsetTop); } }; showAnimation.reset(); showAnimation.setDuration(200); startAnimation(showAnimation); } /** * Hide the actions of the {@link ChandelierLayout} */ public void hideActions() { isShowingAction = false; final float top = ViewCompat.getTranslationY(ornamentLayout); final Animation hideAnimation = new Animation() { @Override protected void applyTransformation(float interpolatedTime, Transformation t) { moveActionLayout((1 - interpolatedTime) * top); } }; hideAnimation.reset(); hideAnimation.setDuration(200); startAnimation(hideAnimation); } /** * Set the duration that the layout takes to get into its original position. Default is * {@link ChandelierLayout#ANIMATE_TO_START_DURATION} = 300 millisecond. * * @param duration in millisecond */ public void setAnimateToStartDuration(final int duration) { animateToStartDuration = duration; } /** * Classes that wish to be notified when the swipe gesture correctly * triggers an action should implement this interface. */ public interface OnActionListener { void onActionSelected(int index, Ornament action); } }