/** * Wire * Copyright (C) 2016 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.waz.zclient.pages.main.conversationlist.views.listview; import android.content.Context; import android.graphics.Color; import android.graphics.Rect; import android.support.annotation.Nullable; import android.support.v4.view.MotionEventCompat; import android.support.v7.widget.RecyclerView; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import com.waz.zclient.R; import com.waz.zclient.ui.pullforaction.OverScrollListener; import com.waz.zclient.ui.pullforaction.OverScrollMode; import com.waz.zclient.ui.pullforaction.PullForActionView; import timber.log.Timber; /** * ListView subclass that provides the swipe functionality */ public class SwipeListView extends RecyclerView implements PullForActionView { public static final String TAG = SwipeListView.class.getName(); // touch states to distinguish between list and drawer private final static int TOUCH_STATE_REST = 0; private final static int TOUCH_STATE_SCROLLING_X = 1; private final static int TOUCH_STATE_SCROLLING_Y = 2; private int touchState = TOUCH_STATE_REST; // used to track touch movement private float lastMotionX; private float lastMotionY; private int touchSlop; // Cached ViewConfiguration and system-wide constant values private int minFlingVelocity; private int maxFlingVelocity; // drawer goes to.... private float rightOffset = 0; // the listener that wants to be informed by the overscroll event OverScrollListener overScrollListener; // no overscroll needed from here, instead we dispatch overscrolling to the container private int maxOverScrollDistance = 0; // initialize rect once to help performance. Used to determine of touch is on view private Rect targetRect = new Rect(); // Fixed properties private int viewWidth = 1; // 1 and not 0 to prevent dividing by zero // the initial touch down position private float downX; // tracks touch move to calculate swipe speed private VelocityTracker velocityTracker; // the touched view from the list private SwipeListRow targetView; // tracking the child position to see if updates are necessary private int targetChildPosition; // Max distance needed to swipe to fully reveal the menu indicator private int listRowMenuIndicatorMaxSwipeOffset; // flags target open boolean isItemOpen; boolean allowSwipeAway; /** * Opens the drawer by calling an animation on an item. */ private void openItem() { isItemOpen = true; if (targetView != null) { targetView.open(); } } /** * Closes the drawer by calling an animation on an item. */ private void closeItem() { isItemOpen = false; if (targetView != null) { targetView.close(); } } /** * Sets the overscroll listener from the PullToRefreshContainer. */ public void setOverScrollListener(OverScrollListener listener) { overScrollListener = listener; } public SwipeListView(Context context, @Nullable AttributeSet attrs) { super(new ContextWrapperEdgeEffect(context), attrs, 0); init(); } public SwipeListView(Context context, @Nullable AttributeSet attrs, int defStyle) { super(new ContextWrapperEdgeEffect(context), attrs, defStyle); init(); } public SwipeListView(Context context) { super(new ContextWrapperEdgeEffect(context)); init(); } /** * Checking animation const and wrapping context to get rod of overscroll animation. */ private void init() { ((ContextWrapperEdgeEffect) getContext()).setEdgeEffectColor(Color.TRANSPARENT); touchSlop = 10; //ViewConfigurationCompat.getScaledPagingTouchSlop(configuration); ViewConfiguration vc = ViewConfiguration.get(getContext()); minFlingVelocity = vc.getScaledMinimumFlingVelocity(); maxFlingVelocity = vc.getScaledMaximumFlingVelocity(); listRowMenuIndicatorMaxSwipeOffset = getContext().getResources().getDimensionPixelSize(R.dimen.list__menu_indicator__max_swipe_offset); } /** * Set offset on right after onMeasurement is called in PullToRefreshContainer. */ public void setOffsetRight(float offsetRight) { rightOffset = offsetRight; } /** * onInterceptTouchEvent is not called on move even it returns false. (Bug?) * This function determines the hit element and the movement. */ @Override public boolean dispatchTouchEvent(MotionEvent ev) { if (viewWidth < 2) { viewWidth = getWidth(); } switch (MotionEventCompat.getActionMasked(ev)) { case MotionEvent.ACTION_DOWN: touchState = TOUCH_STATE_REST; getHitChild(ev); break; case MotionEvent.ACTION_MOVE: if (touchState != TOUCH_STATE_REST) { super.dispatchTouchEvent(ev); } final float x = ev.getX(); final float y = ev.getY(); // make sure we hit an item if (targetView != null) { checkMotionDirection(x, y); } break; } return super.dispatchTouchEvent(ev); } /** * @see android.widget.ListView#onInterceptTouchEvent(android.view.MotionEvent) */ @Override public boolean onInterceptTouchEvent(MotionEvent ev) { int action = MotionEventCompat.getActionMasked(ev); final float x = ev.getX(); final float y = ev.getY(); switch (action) { case MotionEvent.ACTION_DOWN: touchState = TOUCH_STATE_REST; velocityTracker = VelocityTracker.obtain(); velocityTracker.addMovement(ev); lastMotionX = x; lastMotionY = y; if (isItemOpen) { getParent().requestDisallowInterceptTouchEvent(true); //return true; } return super.onInterceptTouchEvent(ev); case MotionEvent.ACTION_MOVE: if (touchState != TOUCH_STATE_REST) { return true; } break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: velocityTracker.recycle(); velocityTracker = null; break; } return super.onInterceptTouchEvent(ev); } @Override @SuppressWarnings("PMD.UselessOverridingMethod") public int computeVerticalScrollOffset() { return super.computeVerticalScrollOffset(); } /** * @see android.widget.ListView#onTouchEvent(android.view.MotionEvent) */ @Override public boolean onTouchEvent(MotionEvent ev) { // swiping is not enabled, let parent (ListView) eat that event if (targetView == null || !targetView.isSwipeable() || touchState == TOUCH_STATE_SCROLLING_Y) { return super.onTouchEvent(ev); } float deltaX = ev.getRawX() - downX; switch (MotionEventCompat.getActionMasked(ev)) { case MotionEvent.ACTION_MOVE: velocityTracker.addMovement(ev); velocityTracker.computeCurrentVelocity(1000); targetView.setOffset(deltaX); return true; case MotionEvent.ACTION_UP: if (velocityTracker == null) { break; } velocityTracker.addMovement(ev); velocityTracker.computeCurrentVelocity(1000); float velocityX = Math.abs(velocityTracker.getXVelocity()); if (velocityTracker.getXVelocity() < 0) { velocityX = 0; } float velocityY = Math.abs(velocityTracker.getYVelocity()); if (targetView != null) { boolean flingRight = false; if (minFlingVelocity <= velocityX && velocityX <= maxFlingVelocity && velocityY * 2 < velocityX && deltaX > 0) { flingRight = true; } if (allowSwipeAway && deltaX > viewWidth / 2) { if (targetView != null) { targetView.swipeAway(); } } else if ((!allowSwipeAway && (deltaX > viewWidth / 2 || flingRight)) || (allowSwipeAway && (deltaX > viewWidth / 4 || flingRight))) { if (targetView.isOpen()) { closeItem(); } else { openItem(); } } else { closeItem(); } } velocityTracker.recycle(); velocityTracker = null; downX = 0; return true; } return super.onTouchEvent(ev); } /** * Check if the user is moving the cell or the list. */ private void checkMotionDirection(float x, float y) { final int distX = (int) (x - lastMotionX); final int distY = (int) (y - lastMotionY); final boolean yMoved = Math.abs(distY) > this.touchSlop; // vertical is bigger than horizontal if (yMoved && Math.abs(distY) > Math.abs(distX)) { touchState = TOUCH_STATE_SCROLLING_Y; closeItem(); return; } final boolean isMovingRight = distX > 0; final boolean xMoved = Math.abs(distX) > this.touchSlop; if (xMoved) { if (isMovingRight) { getParent().requestDisallowInterceptTouchEvent(true); } touchState = TOUCH_STATE_SCROLLING_X; lastMotionX = x; lastMotionY = y; } } private MotionDirection getMotionDirection(float x, float y) { MotionDirection direction = null; final int distX = (int) (x - lastMotionX); final int distY = (int) (y - lastMotionY); boolean xMoved = Math.abs(distX) > this.touchSlop; boolean yMoved = Math.abs(distY) > this.touchSlop && Math.abs(distY) > Math.abs(distX); if (yMoved) { if (distY < 0) { direction = MotionDirection.UP; } else { direction = MotionDirection.DOWN; } } else if (xMoved) { if (distX < 0) { direction = MotionDirection.LEFT; } else { direction = MotionDirection.RIGHT; } } return direction; } /** * Retrieves the child of the list view that is hit by the touch event. If it * is the first item or a disabled one, targetView is set to null. */ private void getHitChild(MotionEvent motionEvent) { int[] listViewCoords = new int[2]; getLocationOnScreen(listViewCoords); int x = (int) motionEvent.getRawX() - listViewCoords[0]; int y = (int) motionEvent.getRawY() - listViewCoords[1]; int childCount = getChildCount(); View child; for (int i = 0; i < childCount; i++) { child = getChildAt(i); child.getHitRect(targetRect); if (targetRect.contains(x, y)) { int position = getChildLayoutPosition(child); boolean allowSwipe = child instanceof SwipeListRow && ((SwipeListRow) child).isSwipeable(); if (allowSwipe) { if (position != targetChildPosition) { closeItem(); } targetChildPosition = position; // TODO: Investigate causes of ClassCastExecption. Perhaps archiving related views? try { targetView = (SwipeListRow) child; targetView.setMaxOffset(allowSwipeAway ? viewWidth / 2 : listRowMenuIndicatorMaxSwipeOffset); } catch (ClassCastException e) { Timber.e(e, "ClassCastException when swiping"); } downX = motionEvent.getRawX(); } else { closeItem(); targetView = null; } return; } } // no child hit closeItem(); targetView = null; } /** * The BouncingListVieContainer is notified from this spot. * At this place the default MaxOverscrollY can be overwritten. */ @Override protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY, int scrollRangeX, int scrollRangeY, int maxOverScrollX, int maxOverScrollY, boolean isTouchEvent) { if (overScrollListener != null) { if (deltaY < 0) { overScrollListener.onOverScrolled(OverScrollMode.TOP); } else { overScrollListener.onOverScrolled(OverScrollMode.BOTTOM); } } return super.overScrollBy(deltaX, deltaY, scrollX, scrollY, scrollRangeX, scrollRangeY, maxOverScrollX, maxOverScrollDistance, isTouchEvent); } public void onPagerOffsetChanged(float offset) { for (int i = 0; i < getChildCount(); i++) { View view = getChildAt(i); if (view instanceof SwipeListRow) { ((SwipeListRow) view).setPagerOffset(offset); } } } public void resetListRowALpha() { for (int i = 0; i < getChildCount(); i++) { View view = getChildAt(i); if (view instanceof SwipeListRow) { ((SwipeListRow) view).dimOnListRowMenuSwiped(1f); } } } public void setAllowSwipeAway(boolean allowSwipeAway) { this.allowSwipeAway = allowSwipeAway; } public interface SwipeListRow { void open(); void close(); void setMaxOffset(float maxOffset); void setOffset(float offset); boolean isSwipeable(); boolean isOpen(); void swipeAway(); void dimOnListRowMenuSwiped(float alpha); void setPagerOffset(float pagerOffset); } }