Home | History | Annotate | Download | only in accessibility
      1 // Copyright 2014 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 package org.chromium.chrome.browser.widget.accessibility;
      6 
      7 import android.animation.Animator;
      8 import android.animation.AnimatorListenerAdapter;
      9 import android.animation.AnimatorSet;
     10 import android.animation.ObjectAnimator;
     11 import android.content.Context;
     12 import android.graphics.Bitmap;
     13 import android.os.Handler;
     14 import android.util.AttributeSet;
     15 import android.view.GestureDetector;
     16 import android.view.MotionEvent;
     17 import android.view.View;
     18 import android.view.View.OnClickListener;
     19 import android.view.ViewGroup;
     20 import android.widget.AbsListView;
     21 import android.widget.Button;
     22 import android.widget.FrameLayout;
     23 import android.widget.ImageButton;
     24 import android.widget.ImageView;
     25 import android.widget.LinearLayout;
     26 import android.widget.TextView;
     27 
     28 import org.chromium.base.VisibleForTesting;
     29 import org.chromium.chrome.R;
     30 import org.chromium.chrome.browser.EmptyTabObserver;
     31 import org.chromium.chrome.browser.Tab;
     32 import org.chromium.chrome.browser.TabObserver;
     33 
     34 /**
     35  * A widget that shows a single row of the {@link AccessibilityTabModelListView} list.
     36  * This list shows both the title of the {@link Tab} as well as a close button to close
     37  * the tab.
     38  */
     39 public class AccessibilityTabModelListItem extends FrameLayout implements OnClickListener {
     40     private static final int CLOSE_ANIMATION_DURATION_MS = 100;
     41     private static final int DEFAULT_ANIMATION_DURATION_MS = 300;
     42     private static final int VELOCITY_SCALING_FACTOR = 150;
     43     private static final int CLOSE_TIMEOUT_MS = 2000;
     44 
     45     private int mCloseAnimationDurationMs;
     46     private int mDefaultAnimationDurationMs;
     47     private int mCloseTimeoutMs;
     48     // The last run animation (if non-null, it still might have already completed).
     49     private Animator mActiveAnimation;
     50 
     51     private final float mSwipeCommitDistance;
     52     private final float mFlingCommitDistance;
     53 
     54     // Keeps track of how a tab was closed
     55     //  < 0 : swiped to the left.
     56     //  > 0 : swiped to the right.
     57     //  = 0 : closed with the close button.
     58     private float mSwipedAway;
     59 
     60     // The children on the standard view.
     61     private LinearLayout mTabContents;
     62     private TextView mTitleView;
     63     private ImageView mFaviconView;
     64     private ImageButton mCloseButton;
     65 
     66     // The children on the undo view.
     67     private LinearLayout mUndoContents;
     68     private Button mUndoButton;
     69 
     70     private Tab mTab;
     71     private boolean mCanUndo;
     72     private AccessibilityTabModelListItemListener mListener;
     73     private final GestureDetector mSwipeGestureDetector;
     74     private final int mDefaultHeight;
     75     private AccessibilityTabModelListView mCanScrollListener;
     76 
     77     /**
     78      * An interface that exposes actions taken on this item.  The registered listener will be
     79      * sent selection and close events based on user input.
     80      */
     81     public interface AccessibilityTabModelListItemListener {
     82         /**
     83          * Called when a user clicks on this list item.
     84          * @param tabId The ID of the tab that this list item represents.
     85          */
     86         public void tabSelected(int tabId);
     87 
     88         /**
     89          * Called when a user clicks on the close button of this list item.
     90          * @param tabId The ID of the tab that this list item represents.
     91          */
     92         public void tabClosed(int tabId);
     93 
     94         /**
     95          * Called when the data corresponding to this list item has changed.
     96          * @param tabId The ID of the tab that this list item represents.
     97          */
     98         public void tabChanged(int tabId);
     99 
    100         /**
    101          * @return Whether or not the tab is scheduled to be closed.
    102          */
    103         public boolean hasPendingClosure(int tabId);
    104 
    105         /**
    106          * Schedule a tab to be closed in the future.
    107          * @param tabId The ID of the tab to close.
    108          */
    109         public void schedulePendingClosure(int tabId);
    110 
    111         /**
    112          * Cancel a tab's closure.
    113          * @param tabId The ID of the tab that should no longer be closed.
    114          */
    115         public void cancelPendingClosure(int tabId);
    116     }
    117 
    118     private final Runnable mCloseRunnable = new Runnable() {
    119         @Override
    120         public void run() {
    121             runCloseAnimation();
    122         }
    123     };
    124 
    125     private final Handler mHandler = new Handler();
    126 
    127     /**
    128      * Used with the swipe away and blink out animations to bring in the undo view.
    129      */
    130     private final AnimatorListenerAdapter mCloseAnimatorListener =
    131             new AnimatorListenerAdapter() {
    132         private boolean mIsCancelled;
    133 
    134         @Override
    135         public void onAnimationStart(Animator animation) {
    136             mIsCancelled = false;
    137         }
    138 
    139         @Override
    140         public void onAnimationCancel(Animator animation) {
    141             mIsCancelled = true;
    142         }
    143 
    144         @Override
    145         public void onAnimationEnd(Animator animator) {
    146             if (mIsCancelled) return;
    147 
    148             mListener.schedulePendingClosure(mTab.getId());
    149             setTranslationX(0.f);
    150             setScaleX(1.f);
    151             setScaleY(1.f);
    152             setAlpha(0.f);
    153             showUndoView(true);
    154             runResetAnimation(false);
    155             mHandler.postDelayed(mCloseRunnable, mCloseTimeoutMs);
    156         }
    157     };
    158 
    159     /**
    160      * Used with the close animation to actually close a tab after it has shrunk away.
    161      */
    162     private final AnimatorListenerAdapter mActuallyCloseAnimatorListener =
    163             new AnimatorListenerAdapter() {
    164         private boolean mIsCancelled;
    165 
    166         @Override
    167         public void onAnimationStart(Animator animation) {
    168             mIsCancelled = false;
    169         }
    170 
    171         @Override
    172         public void onAnimationCancel(Animator animation) {
    173             mIsCancelled = true;
    174         }
    175 
    176         @Override
    177         public void onAnimationEnd(Animator animator) {
    178             if (mIsCancelled) return;
    179 
    180             showUndoView(false);
    181             setAlpha(1.f);
    182             mTabContents.setAlpha(1.f);
    183             mUndoContents.setAlpha(1.f);
    184             cancelRunningAnimation();
    185             mListener.tabClosed(mTab.getId());
    186         }
    187     };
    188 
    189     /**
    190      * @param context The Context to build this widget in.
    191      * @param attrs The AttributeSet to use to build this widget.
    192      */
    193     public AccessibilityTabModelListItem(Context context, AttributeSet attrs) {
    194         super(context, attrs);
    195         mSwipeGestureDetector = new GestureDetector(context, new SwipeGestureListener());
    196         mSwipeCommitDistance =
    197                 context.getResources().getDimension(R.dimen.swipe_commit_distance);
    198         mFlingCommitDistance = mSwipeCommitDistance / 3;
    199 
    200         mDefaultHeight =
    201                 context.getResources().getDimensionPixelOffset(R.dimen.accessibility_tab_height);
    202 
    203         mCloseAnimationDurationMs = CLOSE_ANIMATION_DURATION_MS;
    204         mDefaultAnimationDurationMs = DEFAULT_ANIMATION_DURATION_MS;
    205         mCloseTimeoutMs = CLOSE_TIMEOUT_MS;
    206     }
    207 
    208     @Override
    209     public void onFinishInflate() {
    210         super.onFinishInflate();
    211         mTabContents = (LinearLayout) findViewById(R.id.tab_contents);
    212         mTitleView = (TextView) findViewById(R.id.tab_title);
    213         mFaviconView = (ImageView) findViewById(R.id.tab_favicon);
    214         mCloseButton = (ImageButton) findViewById(R.id.close_btn);
    215 
    216         mUndoContents = (LinearLayout) findViewById(R.id.undo_contents);
    217         mUndoButton = (Button) findViewById(R.id.undo_button);
    218 
    219         setClickable(true);
    220         setFocusable(true);
    221 
    222         mCloseButton.setOnClickListener(this);
    223         mUndoButton.setOnClickListener(this);
    224         setOnClickListener(this);
    225     }
    226 
    227     /**
    228      * Sets the {@link Tab} this {@link View} will represent in the list.
    229      * @param tab     The {@link Tab} to represent.
    230      * @param canUndo Whether or not closing this {@link Tab} can be undone.
    231      */
    232     public void setTab(Tab tab, boolean canUndo) {
    233         if (mTab != null) mTab.removeObserver(mTabObserver);
    234         mTab = tab;
    235         tab.addObserver(mTabObserver);
    236         mCanUndo = canUndo;
    237         updateTabTitle();
    238         updateFavicon();
    239     }
    240 
    241     private void showUndoView(boolean showView) {
    242         if (showView && mCanUndo) {
    243             mUndoContents.setVisibility(View.VISIBLE);
    244             mTabContents.setVisibility(View.INVISIBLE);
    245         } else {
    246             mTabContents.setVisibility(View.VISIBLE);
    247             mUndoContents.setVisibility(View.INVISIBLE);
    248             updateTabTitle();
    249             updateFavicon();
    250         }
    251     }
    252 
    253     /**
    254      * Registers a listener to be notified of selection and close events taken on this list item.
    255      * @param listener The listener to be notified of selection and close events.
    256      */
    257     public void setListeners(AccessibilityTabModelListItemListener listener,
    258             AccessibilityTabModelListView canScrollListener) {
    259         mListener = listener;
    260         mCanScrollListener = canScrollListener;
    261     }
    262 
    263     private void updateTabTitle() {
    264         String title = mTab != null ? mTab.getTitle() : null;
    265         if (title == null || title.isEmpty()) {
    266             title = getContext().getResources().getString(R.string.tab_loading_default_title);
    267         }
    268 
    269         if (!title.equals(mTitleView.getText())) mTitleView.setText(title);
    270 
    271         String accessibilityString = getContext().getString(R.string.accessibility_tabstrip_tab,
    272                 title);
    273         if (!accessibilityString.equals(getContentDescription())) {
    274             setContentDescription(getContext().getString(R.string.accessibility_tabstrip_tab,
    275                     title));
    276         }
    277     }
    278 
    279     private void updateFavicon() {
    280         if (mTab != null) {
    281             Bitmap bitmap = mTab.getFavicon();
    282             if (bitmap != null) {
    283                 mFaviconView.setImageBitmap(bitmap);
    284             } else {
    285                 mFaviconView.setImageResource(R.drawable.globe_incognito_favicon);
    286             }
    287         }
    288     }
    289 
    290     @Override
    291     public void onClick(View v) {
    292         if (mListener == null) return;
    293 
    294         int tabId = mTab.getId();
    295         if (v == AccessibilityTabModelListItem.this && !mListener.hasPendingClosure(tabId)) {
    296             mListener.tabSelected(tabId);
    297         } else if (v == mCloseButton) {
    298             if (mCanUndo) {
    299                 runBlinkOutAnimation();
    300             } else {
    301                 runCloseAnimation();
    302             }
    303         } else if (v == mUndoButton) {
    304             // Kill the close action.
    305             mHandler.removeCallbacks(mCloseRunnable);
    306 
    307             mListener.cancelPendingClosure(tabId);
    308             showUndoView(false);
    309             setAlpha(0.f);
    310             if (mSwipedAway > 0.f) {
    311                 setTranslationX(getWidth());
    312                 runResetAnimation(false);
    313             } else if (mSwipedAway < 0.f) {
    314                 setTranslationX(-getWidth());
    315                 runResetAnimation(false);
    316             } else {
    317                 setScaleX(1.2f);
    318                 setScaleY(0.f);
    319                 runResetAnimation(true);
    320             }
    321         }
    322     }
    323 
    324     @Override
    325     protected void onDetachedFromWindow() {
    326         super.onDetachedFromWindow();
    327         if (mTab != null) mTab.removeObserver(mTabObserver);
    328         cancelRunningAnimation();
    329     }
    330 
    331     private final TabObserver mTabObserver = new EmptyTabObserver() {
    332         @Override
    333         public void onFaviconUpdated(Tab tab) {
    334             updateFavicon();
    335             notifyTabUpdated(tab);
    336         }
    337 
    338         @Override
    339         public void onTitleUpdated(Tab tab) {
    340           updateTabTitle();
    341           notifyTabUpdated(tab);
    342         }
    343 
    344         @Override
    345         public void onUrlUpdated(Tab tab) {
    346             updateTabTitle();
    347             notifyTabUpdated(tab);
    348         }
    349     };
    350 
    351     @Override
    352     public boolean onTouchEvent(MotionEvent e) {
    353         // If there is a pending close task, remove it.
    354         mHandler.removeCallbacks(mCloseRunnable);
    355 
    356         boolean handled = mSwipeGestureDetector.onTouchEvent(e);
    357         if (handled) return true;
    358         if (e.getActionMasked() == MotionEvent.ACTION_UP) {
    359             if (Math.abs(getTranslationX()) > mSwipeCommitDistance) {
    360                 runSwipeAnimation(DEFAULT_ANIMATION_DURATION_MS);
    361             } else {
    362                 runResetAnimation(false);
    363             }
    364             mCanScrollListener.setCanScroll(true);
    365             return true;
    366         }
    367         return super.onTouchEvent(e);
    368     }
    369 
    370     /**
    371      * This call is exposed for the benefit of the animators.
    372      *
    373      * @param height The height of the current view.
    374      */
    375     public void setHeight(int height) {
    376         AbsListView.LayoutParams params = (AbsListView.LayoutParams) getLayoutParams();
    377         if (params == null) {
    378             params = new AbsListView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height);
    379         } else {
    380             if (params.height == height) return;
    381             params.height = height;
    382         }
    383         setLayoutParams(params);
    384     }
    385 
    386     /**
    387      * Used to reset the state because views are recycled.
    388      */
    389     public void resetState() {
    390         setTranslationX(0.f);
    391         setAlpha(1.f);
    392         setScaleX(1.f);
    393         setScaleY(1.f);
    394         setHeight(mDefaultHeight);
    395         cancelRunningAnimation();
    396         // Remove any callbacks.
    397         mHandler.removeCallbacks(mCloseRunnable);
    398 
    399         if (mListener != null) {
    400             boolean hasPendingClosure = mListener.hasPendingClosure(mTab.getId());
    401             showUndoView(hasPendingClosure);
    402             if (hasPendingClosure) mHandler.postDelayed(mCloseRunnable, mCloseTimeoutMs);
    403         } else {
    404             showUndoView(false);
    405         }
    406     }
    407 
    408     /**
    409      * Simple gesture listener to catch the scroll and fling gestures on the list item.
    410      */
    411     private class SwipeGestureListener extends GestureDetector.SimpleOnGestureListener {
    412         @Override
    413         public boolean onDown(MotionEvent e) {
    414             // Returns true so that we can handle events that start with an onDown.
    415             return true;
    416         }
    417 
    418         @Override
    419         public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
    420             // Don't scroll if we're waiting for user interaction.
    421             if (mListener.hasPendingClosure(mTab.getId())) return false;
    422 
    423             // Stop the ListView from scrolling vertically.
    424             mCanScrollListener.setCanScroll(false);
    425 
    426             float distance = e2.getX() - e1.getX();
    427             setTranslationX(distance + getTranslationX());
    428             setAlpha(1 - Math.abs(getTranslationX() / getWidth()));
    429             return true;
    430         }
    431 
    432         @Override
    433         public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
    434             // Arbitrary threshold that feels right.
    435             if (Math.abs(getTranslationX()) < mFlingCommitDistance) return false;
    436 
    437             double velocityMagnitude = Math.sqrt(velocityX * velocityX + velocityY * velocityY);
    438             long closeTime = (long) Math.abs((getWidth() / velocityMagnitude)) *
    439                     VELOCITY_SCALING_FACTOR;
    440             runSwipeAnimation(Math.min(closeTime, mDefaultAnimationDurationMs));
    441             mCanScrollListener.setCanScroll(true);
    442             return true;
    443         }
    444 
    445         @Override
    446         public boolean onSingleTapConfirmed(MotionEvent e) {
    447             performClick();
    448             return true;
    449         }
    450     }
    451 
    452     @VisibleForTesting
    453     public void disableAnimations() {
    454         mCloseAnimationDurationMs = 0;
    455         mDefaultAnimationDurationMs = 0;
    456         mCloseTimeoutMs = 0;
    457     }
    458 
    459     @VisibleForTesting
    460     public boolean hasPendingClosure() {
    461         if (mListener != null) return mListener.hasPendingClosure(mTab.getId());
    462         return false;
    463     }
    464 
    465     private void runSwipeAnimation(long time) {
    466         cancelRunningAnimation();
    467         mSwipedAway = getTranslationX();
    468 
    469         ObjectAnimator swipe = ObjectAnimator.ofFloat(this, View.TRANSLATION_X,
    470                 getTranslationX() > 0 ? getWidth() : -getWidth());
    471         ObjectAnimator fadeOut = ObjectAnimator.ofFloat(this, View.ALPHA, 0.f);
    472 
    473         AnimatorSet set = new AnimatorSet();
    474         set.playTogether(fadeOut, swipe);
    475         set.addListener(mCloseAnimatorListener);
    476         set.setDuration(Math.min(time, mDefaultAnimationDurationMs));
    477         set.start();
    478 
    479         mActiveAnimation = set;
    480     }
    481 
    482     private void runResetAnimation(boolean useCloseAnimationDuration) {
    483         cancelRunningAnimation();
    484 
    485         ObjectAnimator swipe = ObjectAnimator.ofFloat(this, View.TRANSLATION_X, 0.f);
    486         ObjectAnimator fadeIn = ObjectAnimator.ofFloat(this, View.ALPHA, 1.f);
    487         ObjectAnimator scaleX = ObjectAnimator.ofFloat(this, View.SCALE_X, 1.f);
    488         ObjectAnimator scaleY = ObjectAnimator.ofFloat(this, View.SCALE_Y, 1.f);
    489         ObjectAnimator resetHeight = ObjectAnimator.ofInt(this, "height", mDefaultHeight);
    490 
    491         AnimatorSet set = new AnimatorSet();
    492         set.playTogether(swipe, fadeIn, scaleX, scaleY, resetHeight);
    493         set.setDuration(useCloseAnimationDuration
    494                 ? mCloseAnimationDurationMs : mDefaultAnimationDurationMs);
    495         set.start();
    496 
    497         mActiveAnimation = set;
    498     }
    499 
    500     private void runBlinkOutAnimation() {
    501         cancelRunningAnimation();
    502         mSwipedAway = 0;
    503 
    504         ObjectAnimator stretchX = ObjectAnimator.ofFloat(this, View.SCALE_X, 1.2f);
    505         ObjectAnimator shrinkY = ObjectAnimator.ofFloat(this, View.SCALE_Y, 0.f);
    506         ObjectAnimator fadeOut = ObjectAnimator.ofFloat(this, View.ALPHA, 0.f);
    507 
    508         AnimatorSet set = new AnimatorSet();
    509         set.playTogether(fadeOut, shrinkY, stretchX);
    510         set.addListener(mCloseAnimatorListener);
    511         set.setDuration(mCloseAnimationDurationMs);
    512         set.start();
    513 
    514         mActiveAnimation = set;
    515     }
    516 
    517     private void runCloseAnimation() {
    518         cancelRunningAnimation();
    519 
    520         ObjectAnimator shrinkHeight = ObjectAnimator.ofInt(this, "height", 0);
    521         ObjectAnimator shrinkY = ObjectAnimator.ofFloat(this, View.SCALE_Y, 0.f);
    522 
    523         AnimatorSet set = new AnimatorSet();
    524         set.playTogether(shrinkHeight, shrinkY);
    525         set.addListener(mActuallyCloseAnimatorListener);
    526         set.setDuration(mDefaultAnimationDurationMs);
    527         set.start();
    528 
    529         mActiveAnimation = set;
    530     }
    531 
    532     private void cancelRunningAnimation() {
    533         if (mActiveAnimation != null && mActiveAnimation.isRunning()) mActiveAnimation.cancel();
    534 
    535         mActiveAnimation = null;
    536     }
    537 
    538     private void notifyTabUpdated(Tab tab) {
    539         if (mListener != null) mListener.tabChanged(tab.getId());
    540     }
    541 }
    542