Bug 1030896 - Import SwipeToDismissNOA. r=margaret
authorBrian Nicholson <bnicholson@mozilla.com>
Mon, 22 Sep 2014 15:20:31 -0700
changeset 229890 7fb7d61bc5d922c638c8ffcf4cc6335d5b4deec1
parent 229889 70aaf98d3bb0cd6d7ebfd1981a969ec08e784087
child 229891 70e9091da10dc8e7294d70fb900a8831fdd147fe
push id4187
push userbhearsum@mozilla.com
push dateFri, 28 Nov 2014 15:29:12 +0000
treeherdermozilla-beta@f23cc6a30c11 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmargaret
bugs1030896
milestone35.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1030896 - Import SwipeToDismissNOA. r=margaret SwipeToDismissNOA (https://github.com/JakeWharton/SwipeToDismissNOA) is a port of Roman Nurik's swipe-to-dismiss library to use NineOldAndroids. SwipeToDismissNOA adds swipe behavior to ListView that animates and removes the item. As-is, there are a few problems with this library: * The comments about the v12 API requirements are wrong * It has problems with async adapters, such as CursorAdapter * The MotionEvent#obtain calls are not followed by recycle * There are beta warnings in the comments, likely due to the problems above As a result, this library is being imported to mobile/android/base instead of the normal thirdparty lib directory so we can make the necessary modifications. Subsequent patches in bug 1030896 will aim to address these issues to make this code production-ready.
mobile/android/base/moz.build
mobile/android/base/widget/SwipeDismissListViewTouchListener.java
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -462,16 +462,17 @@ gbjar.sources += [
     'widget/FloatingHintEditText.java',
     'widget/FlowLayout.java',
     'widget/GeckoActionProvider.java',
     'widget/GeckoPopupMenu.java',
     'widget/GeckoSwipeRefreshLayout.java',
     'widget/GeckoViewFlipper.java',
     'widget/IconTabWidget.java',
     'widget/SquaredImageView.java',
+    'widget/SwipeDismissListViewTouchListener.java',
     'widget/TabRow.java',
     'widget/TabThumbnailWrapper.java',
     'widget/ThumbnailView.java',
     'widget/TwoWayView.java',
     'ZoomConstraints.java',
 ]
 gbjar.sources += [ thirdparty_source_dir + f for f in [
     'com/googlecode/eyesfree/braille/selfbraille/ISelfBrailleService.java',
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/widget/SwipeDismissListViewTouchListener.java
@@ -0,0 +1,359 @@
+// THIS IS A BETA! I DON'T RECOMMEND USING IT IN PRODUCTION CODE JUST YET
+
+/*
+ * Copyright 2012 Roman Nurik
+ *
+ * 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 org.mozilla.gecko.widget;
+
+import android.graphics.Rect;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.widget.AbsListView;
+import android.widget.ListView;
+import com.nineoldandroids.animation.Animator;
+import com.nineoldandroids.animation.AnimatorListenerAdapter;
+import com.nineoldandroids.animation.ValueAnimator;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static com.nineoldandroids.view.ViewHelper.setAlpha;
+import static com.nineoldandroids.view.ViewHelper.setTranslationX;
+import static com.nineoldandroids.view.ViewPropertyAnimator.animate;
+
+/**
+ * A {@link android.view.View.OnTouchListener} that makes the list items in a {@link ListView}
+ * dismissable. {@link ListView} is given special treatment because by default it handles touches
+ * for its list items... i.e. it's in charge of drawing the pressed state (the list selector),
+ * handling list item clicks, etc.
+ *
+ * <p>After creating the listener, the caller should also call
+ * {@link ListView#setOnScrollListener(android.widget.AbsListView.OnScrollListener)}, passing
+ * in the scroll listener returned by {@link #makeScrollListener()}. If a scroll listener is
+ * already assigned, the caller should still pass scroll changes through to this listener. This will
+ * ensure that this {@link SwipeDismissListViewTouchListener} is paused during list view
+ * scrolling.</p>
+ *
+ * <p>Example usage:</p>
+ *
+ * <pre>
+ * SwipeDismissListViewTouchListener touchListener =
+ *         new SwipeDismissListViewTouchListener(
+ *                 listView,
+ *                 new SwipeDismissListViewTouchListener.OnDismissCallback() {
+ *                     public void onDismiss(ListView listView, int[] reverseSortedPositions) {
+ *                         for (int position : reverseSortedPositions) {
+ *                             adapter.remove(adapter.getItem(position));
+ *                         }
+ *                         adapter.notifyDataSetChanged();
+ *                     }
+ *                 });
+ * listView.setOnTouchListener(touchListener);
+ * listView.setOnScrollListener(touchListener.makeScrollListener());
+ * </pre>
+ *
+ * <p>This class Requires API level 12 or later due to use of {@link
+ * android.view.ViewPropertyAnimator}.</p>
+ *
+ * <p>For a generalized {@link android.view.View.OnTouchListener} that makes any view dismissable,
+ * see {@link SwipeDismissTouchListener}.</p>
+ *
+ * @see SwipeDismissTouchListener
+ */
+public class SwipeDismissListViewTouchListener implements View.OnTouchListener {
+    // Cached ViewConfiguration and system-wide constant values
+    private int mSlop;
+    private int mMinFlingVelocity;
+    private int mMaxFlingVelocity;
+    private long mAnimationTime;
+
+    // Fixed properties
+    private ListView mListView;
+    private OnDismissCallback mCallback;
+    private int mViewWidth = 1; // 1 and not 0 to prevent dividing by zero
+
+    // Transient properties
+    private List<PendingDismissData> mPendingDismisses = new ArrayList<PendingDismissData>();
+    private int mDismissAnimationRefCount = 0;
+    private float mDownX;
+    private boolean mSwiping;
+    private VelocityTracker mVelocityTracker;
+    private int mDownPosition;
+    private View mDownView;
+    private boolean mPaused;
+
+    /**
+     * The callback interface used by {@link SwipeDismissListViewTouchListener} to inform its client
+     * about a successful dismissal of one or more list item positions.
+     */
+    public interface OnDismissCallback {
+        /**
+         * Called when the user has indicated they she would like to dismiss one or more list item
+         * positions.
+         *
+         * @param listView               The originating {@link ListView}.
+         * @param reverseSortedPositions An array of positions to dismiss, sorted in descending
+         *                               order for convenience.
+         */
+        void onDismiss(ListView listView, int[] reverseSortedPositions);
+    }
+
+    /**
+     * Constructs a new swipe-to-dismiss touch listener for the given list view.
+     *
+     * @param listView The list view whose items should be dismissable.
+     * @param callback The callback to trigger when the user has indicated that she would like to
+     *                 dismiss one or more list items.
+     */
+    public SwipeDismissListViewTouchListener(ListView listView, OnDismissCallback callback) {
+        ViewConfiguration vc = ViewConfiguration.get(listView.getContext());
+        mSlop = vc.getScaledTouchSlop();
+        mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
+        mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity();
+        mAnimationTime = listView.getContext().getResources().getInteger(
+                android.R.integer.config_shortAnimTime);
+        mListView = listView;
+        mCallback = callback;
+    }
+
+    /**
+     * Enables or disables (pauses or resumes) watching for swipe-to-dismiss gestures.
+     *
+     * @param enabled Whether or not to watch for gestures.
+     */
+    public void setEnabled(boolean enabled) {
+        mPaused = !enabled;
+    }
+
+    /**
+     * Returns an {@link android.widget.AbsListView.OnScrollListener} to be added to the
+     * {@link ListView} using
+     * {@link ListView#setOnScrollListener(android.widget.AbsListView.OnScrollListener)}.
+     * If a scroll listener is already assigned, the caller should still pass scroll changes
+     * through to this listener. This will ensure that this
+     * {@link SwipeDismissListViewTouchListener} is paused during list view scrolling.</p>
+     *
+     * @see {@link SwipeDismissListViewTouchListener}
+     */
+    public AbsListView.OnScrollListener makeScrollListener() {
+        return new AbsListView.OnScrollListener() {
+            @Override
+            public void onScrollStateChanged(AbsListView absListView, int scrollState) {
+                setEnabled(scrollState != AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL);
+            }
+
+            @Override
+            public void onScroll(AbsListView absListView, int i, int i1, int i2) {
+            }
+        };
+    }
+
+    @Override
+    public boolean onTouch(View view, MotionEvent motionEvent) {
+        if (mViewWidth < 2) {
+            mViewWidth = mListView.getWidth();
+        }
+
+        switch (motionEvent.getActionMasked()) {
+            case MotionEvent.ACTION_DOWN: {
+                if (mPaused) {
+                    return false;
+                }
+
+                // TODO: ensure this is a finger, and set a flag
+
+                // Find the child view that was touched (perform a hit test)
+                Rect rect = new Rect();
+                int childCount = mListView.getChildCount();
+                int[] listViewCoords = new int[2];
+                mListView.getLocationOnScreen(listViewCoords);
+                int x = (int) motionEvent.getRawX() - listViewCoords[0];
+                int y = (int) motionEvent.getRawY() - listViewCoords[1];
+                View child;
+                for (int i = 0; i < childCount; i++) {
+                    child = mListView.getChildAt(i);
+                    child.getHitRect(rect);
+                    if (rect.contains(x, y)) {
+                        mDownView = child;
+                        break;
+                    }
+                }
+
+                if (mDownView != null) {
+                    mDownX = motionEvent.getRawX();
+                    mDownPosition = mListView.getPositionForView(mDownView);
+
+                    mVelocityTracker = VelocityTracker.obtain();
+                    mVelocityTracker.addMovement(motionEvent);
+                }
+                view.onTouchEvent(motionEvent);
+                return true;
+            }
+
+            case MotionEvent.ACTION_UP: {
+                if (mVelocityTracker == null) {
+                    break;
+                }
+
+                float deltaX = motionEvent.getRawX() - mDownX;
+                mVelocityTracker.addMovement(motionEvent);
+                mVelocityTracker.computeCurrentVelocity(1000);
+                float velocityX = Math.abs(mVelocityTracker.getXVelocity());
+                float velocityY = Math.abs(mVelocityTracker.getYVelocity());
+                boolean dismiss = false;
+                boolean dismissRight = false;
+                if (Math.abs(deltaX) > mViewWidth / 2) {
+                    dismiss = true;
+                    dismissRight = deltaX > 0;
+                } else if (mMinFlingVelocity <= velocityX && velocityX <= mMaxFlingVelocity
+                        && velocityY < velocityX) {
+                    dismiss = true;
+                    dismissRight = mVelocityTracker.getXVelocity() > 0;
+                }
+                if (dismiss) {
+                    // dismiss
+                    final View downView = mDownView; // mDownView gets null'd before animation ends
+                    final int downPosition = mDownPosition;
+                    ++mDismissAnimationRefCount;
+                    animate(mDownView)
+                            .translationX(dismissRight ? mViewWidth : -mViewWidth)
+                            .alpha(0)
+                            .setDuration(mAnimationTime)
+                            .setListener(new AnimatorListenerAdapter() {
+                                @Override
+                                public void onAnimationEnd(Animator animation) {
+                                    performDismiss(downView, downPosition);
+                                }
+                            });
+                } else {
+                    // cancel
+                    animate(mDownView)
+                            .translationX(0)
+                            .alpha(1)
+                            .setDuration(mAnimationTime)
+                            .setListener(null);
+                }
+                mVelocityTracker = null;
+                mDownX = 0;
+                mDownView = null;
+                mDownPosition = ListView.INVALID_POSITION;
+                mSwiping = false;
+                break;
+            }
+
+            case MotionEvent.ACTION_MOVE: {
+                if (mVelocityTracker == null || mPaused) {
+                    break;
+                }
+
+                mVelocityTracker.addMovement(motionEvent);
+                float deltaX = motionEvent.getRawX() - mDownX;
+                if (Math.abs(deltaX) > mSlop) {
+                    mSwiping = true;
+                    mListView.requestDisallowInterceptTouchEvent(true);
+
+                    // Cancel ListView's touch (un-highlighting the item)
+                    MotionEvent cancelEvent = MotionEvent.obtain(motionEvent);
+                    cancelEvent.setAction(MotionEvent.ACTION_CANCEL |
+                            (motionEvent.getActionIndex()
+                                    << MotionEvent.ACTION_POINTER_INDEX_SHIFT));
+                    mListView.onTouchEvent(cancelEvent);
+                }
+
+                if (mSwiping) {
+                    setTranslationX(mDownView, deltaX);
+                    setAlpha(mDownView, Math.max(0f, Math.min(1f,
+                            1f - 2f * Math.abs(deltaX) / mViewWidth)));
+                    return true;
+                }
+                break;
+            }
+        }
+        return false;
+    }
+
+    class PendingDismissData implements Comparable<PendingDismissData> {
+        public int position;
+        public View view;
+
+        public PendingDismissData(int position, View view) {
+            this.position = position;
+            this.view = view;
+        }
+
+        @Override
+        public int compareTo(PendingDismissData other) {
+            // Sort by descending position
+            return other.position - position;
+        }
+    }
+
+    private void performDismiss(final View dismissView, final int dismissPosition) {
+        // Animate the dismissed list item to zero-height and fire the dismiss callback when
+        // all dismissed list item animations have completed. This triggers layout on each animation
+        // frame; in the future we may want to do something smarter and more performant.
+
+        final ViewGroup.LayoutParams lp = dismissView.getLayoutParams();
+        final int originalHeight = dismissView.getHeight();
+
+        ValueAnimator animator = ValueAnimator.ofInt(originalHeight, 1).setDuration(mAnimationTime);
+
+        animator.addListener(new AnimatorListenerAdapter() {
+            @Override
+            public void onAnimationEnd(Animator animation) {
+                --mDismissAnimationRefCount;
+                if (mDismissAnimationRefCount == 0) {
+                    // No active animations, process all pending dismisses.
+                    // Sort by descending position
+                    Collections.sort(mPendingDismisses);
+
+                    int[] dismissPositions = new int[mPendingDismisses.size()];
+                    for (int i = mPendingDismisses.size() - 1; i >= 0; i--) {
+                        dismissPositions[i] = mPendingDismisses.get(i).position;
+                    }
+                    mCallback.onDismiss(mListView, dismissPositions);
+
+                    ViewGroup.LayoutParams lp;
+                    for (PendingDismissData pendingDismiss : mPendingDismisses) {
+                        // Reset view presentation
+                        setAlpha(pendingDismiss.view, 1f);
+                        setTranslationX(pendingDismiss.view, 0);
+                        lp = pendingDismiss.view.getLayoutParams();
+                        lp.height = originalHeight;
+                        pendingDismiss.view.setLayoutParams(lp);
+                    }
+
+                    mPendingDismisses.clear();
+                }
+            }
+        });
+
+        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+            @Override
+            public void onAnimationUpdate(ValueAnimator valueAnimator) {
+                lp.height = (Integer) valueAnimator.getAnimatedValue();
+                dismissView.setLayoutParams(lp);
+            }
+        });
+
+        mPendingDismisses.add(new PendingDismissData(dismissPosition, dismissView));
+        animator.start();
+    }
+}