Bug 772940/778625/766710/787335/797987 - Re-implement tab swipe for more robustness (r=mfinkle)
authorLucas Rocha <lucasr@mozilla.com>
Fri, 12 Oct 2012 13:22:03 +0100
changeset 110203 d15a22572dbd5476a200b7f326b5245f357dffe2
parent 110202 94ea880274516fadcbd0e95616d03e4cbcd3ecfc
child 110204 a7e92a55d26fea7540dc37cfb255433c28ce535a
push id93
push usernmatsakis@mozilla.com
push dateWed, 31 Oct 2012 21:26:57 +0000
reviewersmfinkle
bugs772940, 778625, 766710, 787335, 797987
milestone19.0a1
Bug 772940/778625/766710/787335/797987 - Re-implement tab swipe for more robustness (r=mfinkle)
mobile/android/base/TabsTray.java
mobile/android/base/resources/layout/tabs_tray.xml
--- a/mobile/android/base/TabsTray.java
+++ b/mobile/android/base/TabsTray.java
@@ -3,26 +3,27 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko;
 
 import org.mozilla.gecko.PropertyAnimator.Property;
 
 import android.content.Context;
-import android.graphics.PointF;
+import android.graphics.Rect;
 import android.graphics.drawable.Drawable;
 import android.text.TextUtils;
 import android.util.AttributeSet;
-import android.view.GestureDetector;
-import android.view.GestureDetector.SimpleOnGestureListener;
 import android.view.LayoutInflater;
 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.AbsListView.RecyclerListener;
 import android.widget.BaseAdapter;
 import android.widget.Button;
 import android.widget.ImageButton;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.ListView;
 import android.widget.TextView;
@@ -35,66 +36,38 @@ public class TabsTray extends LinearLayo
 
     private Context mContext;
     private TabsPanel mTabsPanel;
 
     private static ListView mList;
     private TabsAdapter mTabsAdapter;
     private boolean mWaitingForClose;
 
-    private GestureDetector mGestureDetector;
-    private TabSwipeGestureListener mListener;
-    // Minimum velocity swipe that will close a tab, in inches/sec
-    private static final int SWIPE_CLOSE_VELOCITY = 5;
-    // Time to animate non-flicked tabs of screen, in milliseconds
-    private static final int MAX_ANIMATION_TIME = 250;
-    // Extra weight given to detecting vertical swipes over horizontal ones
-    private static final float SWIPE_VERTICAL_WEIGHT = 1.5f;
-    private static enum DragDirection {
-        UNKNOWN,
-        HORIZONTAL,
-        VERTICAL
-    }
+    private TabSwipeGestureListener mSwipeListener;
+
+    // Time to animate non-flinged tabs of screen, in milliseconds
+    private static final int ANIMATION_DURATION = 250;
 
     private static final String ABOUT_HOME = "about:home";
 
     public TabsTray(Context context, AttributeSet attrs) {
         super(context, attrs);
         mContext = context;
 
         LayoutInflater.from(context).inflate(R.layout.tabs_tray, this);
 
         mList = (ListView) findViewById(R.id.list);
         mList.setItemsCanFocus(true);
 
         mTabsAdapter = new TabsAdapter(mContext);
         mList.setAdapter(mTabsAdapter);
 
-        mListener = new TabSwipeGestureListener(mList);
-        mGestureDetector = new GestureDetector(context, mListener);
-
-        mList.setOnTouchListener(new View.OnTouchListener() {
-            public  boolean onTouch(View v, MotionEvent event) {
-                boolean result = mGestureDetector.onTouchEvent(event);
-
-                // if this is an touch end event, we need to reset the state
-                // of the gesture listener
-                switch (event.getAction() & MotionEvent.ACTION_MASK) {
-                    case MotionEvent.ACTION_UP:
-                      mListener.onTouchEnd(event);
-                }
-
-                // the simple gesture detector doesn't actually call our methods for every touch event
-                // if we're horizontally scrolling we should always return true to prevent scrolling the list
-                if (mListener.getDirection() == DragDirection.HORIZONTAL)
-                    result = true;
-
-                return result;
-            }
-        });
+        mSwipeListener = new TabSwipeGestureListener(mList);
+        mList.setOnTouchListener(mSwipeListener);
+        mList.setOnScrollListener(mSwipeListener.makeScrollListener());
 
         mList.setRecyclerListener(new RecyclerListener() {
             @Override
             public void onMovedToScrapHeap(View view) {
                 TabRow row = (TabRow) view.getTag();
                 row.thumbnail.setImageDrawable(null);
             }
         });
@@ -154,17 +127,17 @@ public class TabsTray extends LinearLayo
 
         public TabsAdapter(Context context) {
             mContext = context;
             mInflater = LayoutInflater.from(mContext);
 
             mOnCloseClickListener = new Button.OnClickListener() {
                 public void onClick(View v) {
                     TabRow tab = (TabRow) v.getTag();
-                    animateTo(tab.info, tab.info.getWidth(), MAX_ANIMATION_TIME);
+                    animateClose(tab.info, tab.info.getWidth());
                 }
             };
         }
 
         public void onTabChanged(Tab tab, Tabs.TabEvents msg, Object data) {
             switch (msg) {
                 case ADDED:
                     // Refresh the list to make sure the new tab is added in the right position.
@@ -259,20 +232,16 @@ public class TabsTray extends LinearLayo
             else
                 row.thumbnail.setImageResource(R.drawable.tab_thumbnail_default);
 
             if (Tabs.getInstance().isSelectedTab(tab))
                 row.info.setBackgroundResource(R.drawable.tabs_tray_active_selector);
             else
                 row.info.setBackgroundResource(R.drawable.tabs_tray_default_selector);
 
-            // this may be a recycled view that was animated off screen
-            // reset the scroll state here
-            row.info.scrollTo(0,0);
-
             row.title.setText(tab.getDisplayTitle());
 
             row.close.setTag(row);
             row.close.setVisibility(mTabs.size() > 1 ? View.VISIBLE : View.INVISIBLE);
         }
 
         public View getView(int position, View convertView, ViewGroup parent) {
             TabRow row;
@@ -290,160 +259,283 @@ public class TabsTray extends LinearLayo
 
             Tab tab = mTabs.get(position);
             assignValues(row, tab);
 
             return convertView;
         }
     }
 
-    private void animateTo(final View view, int x, int duration) {
-        PropertyAnimator pa = new PropertyAnimator(duration);
-        pa.attach(view, Property.SCROLL_X, -x);
-        if (x != 0 && !mWaitingForClose) {
-            mWaitingForClose = true;
+    private boolean hasOnlyOneTab() {
+        return (mTabsAdapter != null && mTabsAdapter.getCount() == 1);
+    }
 
-            TabRow tab = (TabRow)view.getTag();
-            final int tabId = tab.id;
+    private void animateClose(final View view, int x) {
+        // Just bail out, if we're already closing
+        if (mWaitingForClose)
+            return;
+
+        PropertyAnimator animator = new PropertyAnimator(ANIMATION_DURATION);
+        animator.attach(view, Property.ALPHA, 0);
+        animator.attach(view, Property.TRANSLATION_X, x);
+
+        mWaitingForClose = true;
 
-            pa.setPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
-                public void onPropertyAnimationStart() { }
-                public void onPropertyAnimationEnd() {
-                    Tabs tabs = Tabs.getInstance();
-                    Tab tab = tabs.getTab(tabId);
-                    tabs.closeTab(tab);
-                }
-            });
-        } else if (x != 0 && mWaitingForClose) {
-          // if this asked us to close, but we were already doing it just bail out
-          return;
-        }
-        pa.start();
+        TabRow tab = (TabRow)view.getTag();
+        final int tabId = tab.id;
+
+        animator.setPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
+            public void onPropertyAnimationStart() { }
+            public void onPropertyAnimationEnd() {
+                // Reset view presentation as it will be recycled in the
+                // list view by the adapter.
+                AnimatorProxy proxy = AnimatorProxy.create(view);
+                proxy.setAlpha(1);
+                proxy.setTranslationX(0);
+
+                Tabs tabs = Tabs.getInstance();
+                Tab tab = tabs.getTab(tabId);
+                tabs.closeTab(tab);
+            }
+        });
+
+        animator.start();
     }
 
-    private class TabSwipeGestureListener extends SimpleOnGestureListener {
-        private View mList = null;
-        private View mView = null;
-        private PointF start = null;
-        private DragDirection dir = DragDirection.UNKNOWN;
+    private void animateCancel(final View view) {
+        PropertyAnimator animator = new PropertyAnimator(ANIMATION_DURATION);
+        animator.attach(view, Property.ALPHA, 1);
+        animator.attach(view, Property.TRANSLATION_X, 0);
+
+        animator.setPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
+            public void onPropertyAnimationStart() { }
+            public void onPropertyAnimationEnd() {
+                if (!hasOnlyOneTab()) {
+                    TabRow tab = (TabRow) view.getTag();
+                    tab.close.setVisibility(View.VISIBLE);
+                }
+            }
+        });
+
+        animator.start();
+    }
+
+    private class TabSwipeGestureListener implements View.OnTouchListener {
+        private int mSwipeThreshold;
+        private int mMinFlingVelocity;
+        private int mMaxFlingVelocity;
+        private VelocityTracker mVelocityTracker;
 
-        public TabSwipeGestureListener(View v) {
-            mList = v;
+        private ListView mListView;
+        private int mListWidth = 1;
+
+        private View mSwipeView;
+        private AnimatorProxy mSwipeProxy;
+        private int mSwipeViewPosition;
+        private Runnable mPendingCheckForTap;
+
+        private float mSwipeStart;
+        private boolean mSwiping;
+        private boolean mEnabled;
+
+        public TabSwipeGestureListener(ListView listView) {
+            mListView = listView;
+
+            mSwipeView = null;
+            mSwipeProxy = null;
+            mSwipeViewPosition = ListView.INVALID_POSITION;
+            mSwiping = false;
+            mEnabled = true;
+
+            ViewConfiguration vc = ViewConfiguration.get(listView.getContext());
+            mSwipeThreshold = vc.getScaledTouchSlop();
+            mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
+            mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity();
         }
 
-        public DragDirection getDirection() {
-            return dir;
+        public void setEnabled(boolean enabled) {
+            mEnabled = enabled;
+        }
+
+        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 onDown(MotionEvent e) {
-            mView = findViewAt((int)e.getX(), (int)e.getY());
-            if (mView == null)
+        public boolean onTouch(View view, MotionEvent e) {
+            if (!mEnabled)
                 return false;
 
-            mView.setPressed(true);
-            start = new PointF(e.getX(), e.getY());
-            return false;
-        }
+            if (mListWidth < 2)
+                mListWidth = mListView.getWidth();
+
+            switch (e.getActionMasked()) {
+                case MotionEvent.ACTION_DOWN: {
+                    // Check if we should set pressed state on the
+                    // touched view after a standard delay.
+                    triggerCheckForTap();
+
+                    // Find out which view is being touched
+                    mSwipeView = findViewAt(e.getRawX(), e.getRawY());
 
-        public boolean onTouchEnd(MotionEvent e) {
-            if (mView != null) {
+                    if (mSwipeView != null) {
+                        mSwipeStart = e.getRawX();
+                        mSwipeViewPosition = mListView.getPositionForView(mSwipeView);
+
+                        mVelocityTracker = VelocityTracker.obtain();
+                        mVelocityTracker.addMovement(e);
+                    }
+
+                    view.onTouchEvent(e);
+                    return true;
+                }
+
+                case MotionEvent.ACTION_UP: {
+                    if (mSwipeView == null)
+                        break;
 
-                // if the user was dragging horizontally, check to see if we should close the tab
-                if (dir == DragDirection.HORIZONTAL) {
-                    int finalPos = 0;
-                    // if the swipe started on the left and ended in the right 25% of the tray
-                    // or vice versa, close the tab
-                    if ((start.x > mList.getWidth() / 2 && e.getX() < mList.getWidth() * 0.25 )) {
-                        finalPos = -1 * mView.getWidth();
-                    } else if (start.x < mList.getWidth() / 2 && e.getX() > mList.getWidth() * 0.75) {
-                        finalPos = mView.getWidth();
+                    mSwipeView.setPressed(false);
+
+                    if (!mSwiping) {
+                        TabRow tab = (TabRow) mSwipeView.getTag();
+                        Tabs.getInstance().selectTab(tab.id);
+                        autoHidePanel();
+                        break;
                     }
-    
-                    animateTo(mView, finalPos, MAX_ANIMATION_TIME);
-                } else if (mView != null && dir == DragDirection.UNKNOWN) {
-                    // the user didn't attempt to scroll the view, so select the row
-                    TabRow tab = (TabRow)mView.getTag();
-                    int tabId = tab.id;
-                    Tabs.getInstance().selectTab(tabId);
-                    autoHidePanel();
-                }
-            }
+
+                    float deltaX = mSwipeProxy.getTranslationX();
+
+                    mVelocityTracker.addMovement(e);
+                    mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
+
+                    float velocityX = Math.abs(mVelocityTracker.getXVelocity());
+                    float velocityY = Math.abs(mVelocityTracker.getYVelocity());
+
+                    boolean dismiss = false;
+                    boolean dismissRight = false;
+
+                    if (Math.abs(deltaX) > mListWidth / 2) {
+                        dismiss = true;
+                        dismissRight = (deltaX > 0);
+                    } else if (mMinFlingVelocity <= velocityX && velocityX <= mMaxFlingVelocity
+                            && velocityY < velocityX) {
+                        dismiss = mSwiping && !hasOnlyOneTab() && (deltaX * mVelocityTracker.getXVelocity() > 0);
+                        dismissRight = (mVelocityTracker.getXVelocity() > 0);
+                    }
 
-            mView = null;
-            start = null;
-            dir = DragDirection.UNKNOWN;
-            return false;
-        }
+                    if (dismiss)
+                        animateClose(mSwipeView, (dismissRight ? mListWidth : -mListWidth));
+                    else
+                        animateCancel(mSwipeView);
+
+                    mVelocityTracker = null;
+                    mSwipeView = null;
+                    mSwipeViewPosition = ListView.INVALID_POSITION;
+                    mSwipeProxy = null;
+
+                    mSwipeStart = 0;
+                    mSwiping = false;
 
-        @Override
-        public boolean onScroll(MotionEvent event1, MotionEvent event2, float distanceX, float distanceY) {
-            if (mView == null)
-                return false;
+                    break;
+                }
+
+                case MotionEvent.ACTION_MOVE: {
+                    if (mSwipeView == null)
+                        break;
+
+                    mVelocityTracker.addMovement(e);
+
+                    float deltaX = e.getRawX() - mSwipeStart;
+                    if (Math.abs(deltaX) > mSwipeThreshold) {
+                        // If we're actually swiping, make sure we don't
+                        // set pressed state on the swiped view.
+                        cancelCheckForTap();
 
-            // if there is only one tab left, we want to recognize the scroll and
-            // stop any click/selection events, but not scroll/close the view
-            if (Tabs.getInstance().getCount() == 1) {
-                mView.setPressed(false);
-                mView = null;
-                return false;
-            }
+                        mSwiping = true;
+                        mListView.requestDisallowInterceptTouchEvent(true);
+
+                        TabRow tab = (TabRow) mSwipeView.getTag();
+                        tab.close.setVisibility(View.INVISIBLE);
+
+                        // Stops listview from highlighting the touched item
+                        // in the list when swiping.
+                        MotionEvent cancelEvent = MotionEvent.obtain(e);
+                        cancelEvent.setAction(MotionEvent.ACTION_CANCEL |
+                                (e.getActionIndex() << MotionEvent.ACTION_POINTER_INDEX_SHIFT));
+                        mListView.onTouchEvent(cancelEvent);
 
-            if (dir == DragDirection.UNKNOWN) {
-                // check if this scroll is more horizontal than vertical. Weight vertical drags a little higher
-                // by using a multiplier
-                if (Math.abs(distanceX) > Math.abs(distanceY) * SWIPE_VERTICAL_WEIGHT) {
-                    dir = DragDirection.HORIZONTAL;
-                } else {
-                    dir = DragDirection.VERTICAL;
+                        mSwipeProxy = AnimatorProxy.create(mSwipeView);
+                    }
+
+                    if (mSwiping) {
+                        if (hasOnlyOneTab()) {
+                            mSwipeProxy.setTranslationX(deltaX / 4);
+                        } else {
+                            mSwipeProxy.setTranslationX(deltaX);
+                            mSwipeProxy.setAlpha(Math.max(0.1f, Math.min(1f,
+                                    1f - 2f * Math.abs(deltaX) / mListWidth)));
+                        }
+
+                        return true;
+                    }
+
+                    break;
                 }
-                mView.setPressed(false);
-            }
-
-            if (dir == DragDirection.HORIZONTAL) {
-                mView.scrollBy((int) distanceX, 0);
-                return true;
             }
 
             return false;
         }
 
-        @Override
-        public boolean onFling(MotionEvent event1, MotionEvent event2, float velocityX, float velocityY) {
-            if (mView == null || Tabs.getInstance().getCount() == 1)
-                return false;
-
-            // velocityX is in pixels/sec. divide by pixels/inch to compare it with swipe velocity
-            // also make sure that the swipe is in a mostly horizontal direction
-            if (Math.abs(velocityX) > Math.abs(velocityY * SWIPE_VERTICAL_WEIGHT) &&
-                Math.abs(velocityX)/GeckoAppShell.getDpi() > SWIPE_CLOSE_VELOCITY) {
-                // is this is a swipe, we want to continue the row moving at the swipe velocity
-                float d = (velocityX > 0 ? 1 : -1) * mView.getWidth();
-                // convert the velocity (px/sec) to ms by taking the distance
-                // multiply by 1000 to convert seconds to milliseconds
-                animateTo(mView, (int)d, (int)((d + mView.getScrollX())*1000/velocityX));
-            }
-
-            return false; 
-        }
-
-        private View findViewAt(int x, int y) {
+        private View findViewAt(float rawX, float rawY) {
             if (mList == null)
                 return null;
 
-            ListView list = (ListView)mList;
-            x += list.getScrollX();
-            y += list.getScrollY();
+            Rect rect = new Rect();
+
+            int[] listViewCoords = new int[2];
+            mListView.getLocationOnScreen(listViewCoords);
+
+            int x = (int) rawX - listViewCoords[0];
+            int y = (int) rawY - listViewCoords[1];
+
+            for (int i = 0; i < mListView.getChildCount(); i++) {
+                View child = mListView.getChildAt(i);
+                child.getHitRect(rect);
+
+                if (rect.contains(x, y))
+                    return child;
+            }
+
+            return null;
+        }
 
-            final int count = list.getChildCount();
-            for (int i = count - 1; i >= 0; i--) {
-                View child = list.getChildAt(i);
-                if (child.getVisibility() == View.VISIBLE) {
-                    if ((x >= child.getLeft()) && (x < child.getRight())
-                            && (y >= child.getTop()) && (y < child.getBottom())) {
-                        return child;
-                    }
-                }
+        private void triggerCheckForTap() {
+            if (mPendingCheckForTap == null)
+                mPendingCheckForTap = new CheckForTap();
+
+            mListView.postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
+        }
+
+        private void cancelCheckForTap() {
+            if (mPendingCheckForTap == null)
+                return;
+
+            mListView.removeCallbacks(mPendingCheckForTap);
+        }
+
+        private class CheckForTap implements Runnable {
+            @Override
+            public void run() {
+                if (!mSwiping && mSwipeView != null && mEnabled)
+                    mSwipeView.setPressed(true);
             }
-            return null;
         }
     }
 }
--- a/mobile/android/base/resources/layout/tabs_tray.xml
+++ b/mobile/android/base/resources/layout/tabs_tray.xml
@@ -1,9 +1,10 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- This Source Code Form is subject to the terms of the Mozilla Public
    - License, v. 2.0. If a copy of the MPL was not distributed with this
    - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
 
 <ListView xmlns:android="http://schemas.android.com/apk/res/android"
           android:id="@+id/list"
           style="@style/TabsList"
+          android:background="@drawable/tabs_tray_bg_repeat"
           android:divider="@drawable/tabs_tray_list_divider"/>