Bug 1017338 - Swipe to close a tab from tab panel (r=s.kaspari)
authorMartyn Haigh <mhaigh@mozilla.org>
Wed, 24 Jun 2015 12:22:28 -0700
changeset 250132 192f73676e5d
parent 250131 5eccb63fdec6
child 250133 67dceb9e0479
push id28946
push usercbook@mozilla.com
push dateThu, 25 Jun 2015 09:04:42 +0000
treeherdermozilla-central@5ed704eb26e9 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerss
bugs1017338
milestone41.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 1017338 - Swipe to close a tab from tab panel (r=s.kaspari)
mobile/android/base/tabs/TabsGridLayout.java
--- a/mobile/android/base/tabs/TabsGridLayout.java
+++ b/mobile/android/base/tabs/TabsGridLayout.java
@@ -1,46 +1,50 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 /* 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/. */
 
 package org.mozilla.gecko.tabs;
 
-import java.util.ArrayList;
-import java.util.List;
-
-import org.mozilla.gecko.animation.ViewHelper;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEvent;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.animation.PropertyAnimator;
+import org.mozilla.gecko.animation.ViewHelper;
 import org.mozilla.gecko.tabs.TabsPanel.TabsLayout;
-import org.mozilla.gecko.Tabs;
 
 import android.content.Context;
 import android.content.res.Resources;
 import android.content.res.TypedArray;
 import android.graphics.PointF;
+import android.graphics.Rect;
 import android.util.AttributeSet;
 import android.util.SparseArray;
-import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
 import android.view.View;
+import android.view.ViewConfiguration;
 import android.view.ViewGroup;
 import android.view.ViewTreeObserver;
 import android.view.animation.DecelerateInterpolator;
+import android.widget.AbsListView;
 import android.widget.AdapterView;
 import android.widget.Button;
 import android.widget.GridView;
 import com.nineoldandroids.animation.Animator;
 import com.nineoldandroids.animation.AnimatorSet;
 import com.nineoldandroids.animation.ObjectAnimator;
 import com.nineoldandroids.animation.PropertyValuesHolder;
 import com.nineoldandroids.animation.ValueAnimator;
 
+import java.util.ArrayList;
+import java.util.List;
 
 /**
  * A tabs layout implementation for the tablet redesign (bug 1014156).
  * Expected to replace TabsListLayout once complete.
  */
 
 class TabsGridLayout extends GridView
                      implements TabsLayout,
@@ -49,20 +53,19 @@ class TabsGridLayout extends GridView
 
     private static final int ANIM_TIME_MS = 200;
     public static final int ANIM_DELAY_MULTIPLE_MS = 20;
     private static final DecelerateInterpolator ANIM_INTERPOLATOR = new DecelerateInterpolator();
 
     private final Context mContext;
     private TabsPanel mTabsPanel;
     private final SparseArray<PointF> mTabLocations = new SparseArray<PointF>();
+    private final boolean mIsPrivate;
+    private final TabsLayoutAdapter mTabsAdapter;
 
-    final private boolean mIsPrivate;
-
-    private final TabsLayoutAdapter mTabsAdapter;
     private final int mColumnWidth;
 
     public TabsGridLayout(Context context, AttributeSet attrs) {
         super(context, attrs, R.attr.tabGridLayoutViewStyle);
         mContext = context;
 
         TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabsLayout);
         mIsPrivate = (a.getInt(R.styleable.TabsLayout_tabs, 0x0) == 1);
@@ -98,23 +101,27 @@ class TabsGridLayout extends GridView
         setOnItemClickListener(new OnItemClickListener() {
             @Override
             public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                 TabsLayoutItemView tab = (TabsLayoutItemView) view;
                 Tabs.getInstance().selectTab(tab.getTabId());
                 autoHidePanel();
             }
         });
+
+        TabSwipeGestureListener mSwipeListener = new TabSwipeGestureListener();
+        setOnTouchListener(mSwipeListener);
+        setOnScrollListener(mSwipeListener.makeScrollListener());
     }
 
     private class TabsGridLayoutAdapter extends TabsLayoutAdapter {
 
         final private Button.OnClickListener mCloseClickListener;
 
-        public TabsGridLayoutAdapter (Context context) {
+        public TabsGridLayoutAdapter(Context context) {
             super(context, R.layout.new_tablet_tabs_item_cell);
 
             mCloseClickListener = new Button.OnClickListener() {
                 @Override
                 public void onClick(View v) {
                     closeTab(v);
                 }
             };
@@ -193,17 +200,17 @@ class TabsGridLayout extends GridView
         Tabs.registerOnTabsChangedListener(this);
         refreshTabsData();
     }
 
     @Override
     public void hide() {
         setVisibility(View.GONE);
         Tabs.unregisterOnTabsChangedListener(this);
-        GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Tab:Screenshot:Cancel",""));
+        GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Tab:Screenshot:Cancel", ""));
         mTabsAdapter.clear();
     }
 
     @Override
     public boolean shouldExpand() {
         return true;
     }
 
@@ -226,17 +233,17 @@ class TabsGridLayout extends GridView
 
                 final Tabs tabsInstance = Tabs.getInstance();
 
                 if (mTabsAdapter.removeTab(tab)) {
                     if (tab.isPrivate() == mIsPrivate && mTabsAdapter.getCount() > 0) {
                         int selected = mTabsAdapter.getPositionForTab(tabsInstance.getSelectedTab());
                         updateSelectedStyle(selected);
                     }
-                    if(!tab.isPrivate()) {
+                    if (!tab.isPrivate()) {
                         // Make sure we always have at least one normal tab
                         final Iterable<Tab> tabs = tabsInstance.getTabsInOrder();
                         boolean removedTabIsLastNormalTab = true;
                         for (Tab singleTab : tabs) {
                             if (!singleTab.isPrivate()) {
                                 removedTabIsLastNormalTab = false;
                                 break;
                             }
@@ -299,16 +306,19 @@ class TabsGridLayout extends GridView
 
         mTabsAdapter.setTabs(tabData);
         updateSelectedPosition();
     }
 
     private void resetTransforms(View view) {
         ViewHelper.setAlpha(view, 1);
         ViewHelper.setTranslationX(view, 0);
+        ViewHelper.setTranslationY(view, 0);
+
+        ((TabsLayoutItemView) view).setCloseVisible(true);
     }
 
     @Override
     public void closeAll() {
 
         autoHidePanel();
 
         if (getChildCount() == 0) {
@@ -330,17 +340,16 @@ class TabsGridLayout extends GridView
         return getChildAt(position - getFirstVisiblePosition());
     }
 
     void closeTab(View v) {
         TabsLayoutItemView itemView = (TabsLayoutItemView) v.getTag();
         Tab tab = Tabs.getInstance().getTab(itemView.getTabId());
 
         Tabs.getInstance().closeTab(tab);
-        updateSelectedPosition();
     }
 
     private void animateRemoveTab(final Tab removedTab) {
         final int removedPosition = mTabsAdapter.getPositionForTab(removedTab);
 
         final View removedView = getViewForTab(removedTab);
 
         // The removed position might not have a matching child view
@@ -361,17 +370,17 @@ class TabsGridLayout extends GridView
                 // within the visible viewport.
                 final int childCount = getChildCount();
                 final int firstPosition = getFirstVisiblePosition();
                 final int numberOfColumns = getNumColumns();
 
                 final List<Animator> childAnimators = new ArrayList<>();
 
                 PropertyValuesHolder translateX, translateY;
-                for (int x = 0, i = removedPosition - firstPosition ; i < childCount; i++, x++) {
+                for (int x = 0, i = removedPosition - firstPosition; i < childCount; i++, x++) {
                     final View child = getChildAt(i);
                     ObjectAnimator animator;
 
                     if (i % numberOfColumns == numberOfColumns - 1) {
                         // Animate X & Y
                         translateX = PropertyValuesHolder.ofFloat("translationX", -(mColumnWidth * numberOfColumns), 0);
                         translateY = PropertyValuesHolder.ofFloat("translationY", removedHeight, 0);
                         animator = ObjectAnimator.ofPropertyValuesHolder(child, translateX, translateY);
@@ -391,22 +400,263 @@ class TabsGridLayout extends GridView
                 animatorSet.start();
 
                 // Set the starting position of the child views - because we are delaying the start
                 // of the animation, we need to prevent the items being drawn in their final position
                 // prior to the animation starting
                 for (int x = 1, i = (removedPosition - firstPosition) + 1; i < childCount; i++, x++) {
                     final View child = getChildAt(i);
 
-                    final PointF targetLocation = mTabLocations.get(x+1);
+                    final PointF targetLocation = mTabLocations.get(x + 1);
                     if (targetLocation == null) {
                         continue;
                     }
 
                     child.setX(targetLocation.x);
                     child.setY(targetLocation.y);
                 }
 
                 return true;
             }
         });
     }
+
+    private void animateCancel(final View view) {
+        PropertyAnimator animator = new PropertyAnimator(ANIM_TIME_MS);
+        animator.attach(view, PropertyAnimator.Property.ALPHA, 1);
+        animator.attach(view, PropertyAnimator.Property.TRANSLATION_X, 0);
+
+        animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
+            @Override
+            public void onPropertyAnimationStart() {
+            }
+
+            @Override
+            public void onPropertyAnimationEnd() {
+                TabsLayoutItemView tab = (TabsLayoutItemView) view;
+                tab.setCloseVisible(true);
+            }
+        });
+
+        animator.start();
+    }
+
+
+    private class TabSwipeGestureListener implements View.OnTouchListener {
+        // same value the stock browser uses for after drag animation velocity in pixels/sec
+        // http://androidxref.com/4.0.4/xref/packages/apps/Browser/src/com/android/browser/NavTabScroller.java#61
+        private static final float MIN_VELOCITY = 750;
+
+        private final int mSwipeThreshold;
+        private final int mMinFlingVelocity;
+
+        private final int mMaxFlingVelocity;
+        private VelocityTracker mVelocityTracker;
+
+        private int mTabWidth = 1;
+
+        private View mSwipeView;
+        private Runnable mPendingCheckForTap;
+
+        private float mSwipeStartX;
+        private boolean mSwiping;
+        private boolean mEnabled;
+
+        public TabSwipeGestureListener() {
+            mEnabled = true;
+
+            ViewConfiguration vc = ViewConfiguration.get(TabsGridLayout.this.getContext());
+            mSwipeThreshold = vc.getScaledTouchSlop();
+            mMinFlingVelocity = (int) (TabsGridLayout.this.getContext().getResources().getDisplayMetrics().density * MIN_VELOCITY);
+            mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity();
+        }
+
+        public void setEnabled(boolean enabled) {
+            mEnabled = enabled;
+        }
+
+        public OnScrollListener makeScrollListener() {
+            return new OnScrollListener() {
+                @Override
+                public void onScrollStateChanged(AbsListView view, int scrollState) {
+                    setEnabled(scrollState != GridView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL);
+                }
+
+                @Override
+                public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
+
+                }
+            };
+        }
+
+        @Override
+        public boolean onTouch(View view, MotionEvent e) {
+            if (!mEnabled) {
+                return false;
+            }
+
+            switch (e.getActionMasked()) {
+                case MotionEvent.ACTION_DOWN: {
+                    // Check if we should set pressed state on the
+                    // touched view after a standard delay.
+                    triggerCheckForTap();
+
+                    final float x = e.getRawX();
+                    final float y = e.getRawY();
+
+                    // Find out which view is being touched
+                    mSwipeView = findViewAt(x, y);
+
+                    if (mSwipeView != null) {
+                        if (mTabWidth < 2) {
+                            mTabWidth = mSwipeView.getWidth();
+                        }
+
+                        mSwipeStartX = e.getRawX();
+
+                        mVelocityTracker = VelocityTracker.obtain();
+                        mVelocityTracker.addMovement(e);
+                    }
+
+                    view.onTouchEvent(e);
+                    return true;
+                }
+
+                case MotionEvent.ACTION_UP: {
+                    if (mSwipeView == null) {
+                        break;
+                    }
+
+                    cancelCheckForTap();
+                    mSwipeView.setPressed(false);
+
+                    if (!mSwiping) {
+                        TabsLayoutItemView item = (TabsLayoutItemView) mSwipeView;
+                        Tabs.getInstance().selectTab(item.getTabId());
+                        autoHidePanel();
+
+                        mVelocityTracker.recycle();
+                        mVelocityTracker = null;
+                        break;
+                    }
+
+                    mVelocityTracker.addMovement(e);
+                    mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
+
+                    float velocityX = Math.abs(mVelocityTracker.getXVelocity());
+
+                    boolean dismiss = false;
+
+                    float deltaX = ViewHelper.getTranslationX(mSwipeView);
+
+                    if (Math.abs(deltaX) > mTabWidth / 2) {
+                        dismiss = true;
+                    } else if (mMinFlingVelocity <= velocityX && velocityX <= mMaxFlingVelocity) {
+                        dismiss = mSwiping && (deltaX * mVelocityTracker.getYVelocity() > 0);
+                    }
+                    if (dismiss) {
+                       closeTab(mSwipeView.findViewById(R.id.close));
+                    } else {
+                        animateCancel(mSwipeView);
+                    }
+                    mVelocityTracker.recycle();
+                    mVelocityTracker = null;
+                    mSwipeView = null;
+
+                    mSwipeStartX = 0;
+                    mSwiping = false;
+                }
+
+                case MotionEvent.ACTION_MOVE: {
+                    if (mSwipeView == null || mVelocityTracker == null) {
+                        break;
+                    }
+
+                    mVelocityTracker.addMovement(e);
+
+                    float delta = e.getRawX() - mSwipeStartX;
+
+                    boolean isScrollingX = Math.abs(delta) > mSwipeThreshold;
+                    boolean isSwipingToClose = isScrollingX;
+
+                    // If we're actually swiping, make sure we don't
+                    // set pressed state on the swiped view.
+                    if (isScrollingX) {
+                        cancelCheckForTap();
+                    }
+
+                    if (isSwipingToClose) {
+                        mSwiping = true;
+                        TabsGridLayout.this.requestDisallowInterceptTouchEvent(true);
+
+                        ((TabsLayoutItemView) mSwipeView).setCloseVisible(false);
+
+                        // 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));
+                        TabsGridLayout.this.onTouchEvent(cancelEvent);
+                        cancelEvent.recycle();
+                    }
+
+                    if (mSwiping) {
+                        ViewHelper.setTranslationX(mSwipeView, delta);
+
+                        ViewHelper.setAlpha(mSwipeView, Math.min(1f,
+                                1f - 2f * Math.abs(delta) / mTabWidth));
+
+                        return true;
+                    }
+
+                    break;
+                }
+            }
+            return false;
+        }
+
+        private View findViewAt(float rawX, float rawY) {
+            Rect rect = new Rect();
+
+            int[] listViewCoords = new int[2];
+            TabsGridLayout.this.getLocationOnScreen(listViewCoords);
+
+            int x = (int) rawX - listViewCoords[0];
+            int y = (int) rawY - listViewCoords[1];
+
+            for (int i = 0; i < TabsGridLayout.this.getChildCount(); i++) {
+                View child = TabsGridLayout.this.getChildAt(i);
+                child.getHitRect(rect);
+
+                if (rect.contains(x, y)) {
+                    return child;
+                }
+            }
+
+            return null;
+        }
+
+        private void triggerCheckForTap() {
+            if (mPendingCheckForTap == null) {
+                mPendingCheckForTap = new CheckForTap();
+            }
+
+            TabsGridLayout.this.postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
+        }
+
+        private void cancelCheckForTap() {
+            if (mPendingCheckForTap == null) {
+                return;
+            }
+
+            TabsGridLayout.this.removeCallbacks(mPendingCheckForTap);
+        }
+
+        private class CheckForTap implements Runnable {
+            @Override
+            public void run() {
+                if (!mSwiping && mSwipeView != null && mEnabled) {
+                    mSwipeView.setPressed(true);
+                }
+            }
+        }
+    }
 }