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 250120 192f73676e5d
parent 250119 5eccb63fdec6
child 250121 67dceb9e0479
push id13706
push usermhaigh@mozilla.com
push dateWed, 24 Jun 2015 21:52:57 +0000
treeherderfx-team@192f73676e5d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerss
bugs1017338
milestone41.0a1
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);
+                }
+            }
+        }
+    }
 }