| // Copyright 2014 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package org.chromium.chrome.browser.widget.accessibility; |
| |
| import android.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.animation.AnimatorSet; |
| import android.animation.ObjectAnimator; |
| import android.content.Context; |
| import android.graphics.Bitmap; |
| import android.os.Handler; |
| import android.util.AttributeSet; |
| import android.view.GestureDetector; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.View.OnClickListener; |
| import android.view.ViewGroup; |
| import android.widget.AbsListView; |
| import android.widget.Button; |
| import android.widget.FrameLayout; |
| import android.widget.ImageButton; |
| import android.widget.ImageView; |
| import android.widget.LinearLayout; |
| import android.widget.TextView; |
| |
| import org.chromium.base.VisibleForTesting; |
| import org.chromium.chrome.R; |
| import org.chromium.chrome.browser.EmptyTabObserver; |
| import org.chromium.chrome.browser.Tab; |
| import org.chromium.chrome.browser.TabObserver; |
| |
| /** |
| * A widget that shows a single row of the {@link AccessibilityTabModelListView} list. |
| * This list shows both the title of the {@link Tab} as well as a close button to close |
| * the tab. |
| */ |
| public class AccessibilityTabModelListItem extends FrameLayout implements OnClickListener { |
| private static final int CLOSE_ANIMATION_DURATION_MS = 100; |
| private static final int DEFAULT_ANIMATION_DURATION_MS = 300; |
| private static final int VELOCITY_SCALING_FACTOR = 150; |
| private static final int CLOSE_TIMEOUT_MS = 2000; |
| |
| private int mCloseAnimationDurationMs; |
| private int mDefaultAnimationDurationMs; |
| private int mCloseTimeoutMs; |
| // The last run animation (if non-null, it still might have already completed). |
| private Animator mActiveAnimation; |
| |
| private final float mSwipeCommitDistance; |
| private final float mFlingCommitDistance; |
| |
| // Keeps track of how a tab was closed |
| // < 0 : swiped to the left. |
| // > 0 : swiped to the right. |
| // = 0 : closed with the close button. |
| private float mSwipedAway; |
| |
| // The children on the standard view. |
| private LinearLayout mTabContents; |
| private TextView mTitleView; |
| private ImageView mFaviconView; |
| private ImageButton mCloseButton; |
| |
| // The children on the undo view. |
| private LinearLayout mUndoContents; |
| private Button mUndoButton; |
| |
| private Tab mTab; |
| private boolean mCanUndo; |
| private AccessibilityTabModelListItemListener mListener; |
| private final GestureDetector mSwipeGestureDetector; |
| private final int mDefaultHeight; |
| private AccessibilityTabModelListView mCanScrollListener; |
| |
| /** |
| * An interface that exposes actions taken on this item. The registered listener will be |
| * sent selection and close events based on user input. |
| */ |
| public interface AccessibilityTabModelListItemListener { |
| /** |
| * Called when a user clicks on this list item. |
| * @param tabId The ID of the tab that this list item represents. |
| */ |
| public void tabSelected(int tabId); |
| |
| /** |
| * Called when a user clicks on the close button of this list item. |
| * @param tabId The ID of the tab that this list item represents. |
| */ |
| public void tabClosed(int tabId); |
| |
| /** |
| * Called when the data corresponding to this list item has changed. |
| * @param tabId The ID of the tab that this list item represents. |
| */ |
| public void tabChanged(int tabId); |
| |
| /** |
| * @return Whether or not the tab is scheduled to be closed. |
| */ |
| public boolean hasPendingClosure(int tabId); |
| |
| /** |
| * Schedule a tab to be closed in the future. |
| * @param tabId The ID of the tab to close. |
| */ |
| public void schedulePendingClosure(int tabId); |
| |
| /** |
| * Cancel a tab's closure. |
| * @param tabId The ID of the tab that should no longer be closed. |
| */ |
| public void cancelPendingClosure(int tabId); |
| } |
| |
| private final Runnable mCloseRunnable = new Runnable() { |
| @Override |
| public void run() { |
| runCloseAnimation(); |
| } |
| }; |
| |
| private final Handler mHandler = new Handler(); |
| |
| /** |
| * Used with the swipe away and blink out animations to bring in the undo view. |
| */ |
| private final AnimatorListenerAdapter mCloseAnimatorListener = |
| new AnimatorListenerAdapter() { |
| private boolean mIsCancelled; |
| |
| @Override |
| public void onAnimationStart(Animator animation) { |
| mIsCancelled = false; |
| } |
| |
| @Override |
| public void onAnimationCancel(Animator animation) { |
| mIsCancelled = true; |
| } |
| |
| @Override |
| public void onAnimationEnd(Animator animator) { |
| if (mIsCancelled) return; |
| |
| mListener.schedulePendingClosure(mTab.getId()); |
| setTranslationX(0.f); |
| setScaleX(1.f); |
| setScaleY(1.f); |
| setAlpha(0.f); |
| showUndoView(true); |
| runResetAnimation(false); |
| mHandler.postDelayed(mCloseRunnable, mCloseTimeoutMs); |
| } |
| }; |
| |
| /** |
| * Used with the close animation to actually close a tab after it has shrunk away. |
| */ |
| private final AnimatorListenerAdapter mActuallyCloseAnimatorListener = |
| new AnimatorListenerAdapter() { |
| private boolean mIsCancelled; |
| |
| @Override |
| public void onAnimationStart(Animator animation) { |
| mIsCancelled = false; |
| } |
| |
| @Override |
| public void onAnimationCancel(Animator animation) { |
| mIsCancelled = true; |
| } |
| |
| @Override |
| public void onAnimationEnd(Animator animator) { |
| if (mIsCancelled) return; |
| |
| showUndoView(false); |
| setAlpha(1.f); |
| mTabContents.setAlpha(1.f); |
| mUndoContents.setAlpha(1.f); |
| cancelRunningAnimation(); |
| mListener.tabClosed(mTab.getId()); |
| } |
| }; |
| |
| /** |
| * @param context The Context to build this widget in. |
| * @param attrs The AttributeSet to use to build this widget. |
| */ |
| public AccessibilityTabModelListItem(Context context, AttributeSet attrs) { |
| super(context, attrs); |
| mSwipeGestureDetector = new GestureDetector(context, new SwipeGestureListener()); |
| mSwipeCommitDistance = |
| context.getResources().getDimension(R.dimen.swipe_commit_distance); |
| mFlingCommitDistance = mSwipeCommitDistance / 3; |
| |
| mDefaultHeight = |
| context.getResources().getDimensionPixelOffset(R.dimen.accessibility_tab_height); |
| |
| mCloseAnimationDurationMs = CLOSE_ANIMATION_DURATION_MS; |
| mDefaultAnimationDurationMs = DEFAULT_ANIMATION_DURATION_MS; |
| mCloseTimeoutMs = CLOSE_TIMEOUT_MS; |
| } |
| |
| @Override |
| public void onFinishInflate() { |
| super.onFinishInflate(); |
| mTabContents = (LinearLayout) findViewById(R.id.tab_contents); |
| mTitleView = (TextView) findViewById(R.id.tab_title); |
| mFaviconView = (ImageView) findViewById(R.id.tab_favicon); |
| mCloseButton = (ImageButton) findViewById(R.id.close_btn); |
| |
| mUndoContents = (LinearLayout) findViewById(R.id.undo_contents); |
| mUndoButton = (Button) findViewById(R.id.undo_button); |
| |
| setClickable(true); |
| setFocusable(true); |
| |
| mCloseButton.setOnClickListener(this); |
| mUndoButton.setOnClickListener(this); |
| setOnClickListener(this); |
| } |
| |
| /** |
| * Sets the {@link Tab} this {@link View} will represent in the list. |
| * @param tab The {@link Tab} to represent. |
| * @param canUndo Whether or not closing this {@link Tab} can be undone. |
| */ |
| public void setTab(Tab tab, boolean canUndo) { |
| if (mTab != null) mTab.removeObserver(mTabObserver); |
| mTab = tab; |
| tab.addObserver(mTabObserver); |
| mCanUndo = canUndo; |
| updateTabTitle(); |
| updateFavicon(); |
| } |
| |
| private void showUndoView(boolean showView) { |
| if (showView && mCanUndo) { |
| mUndoContents.setVisibility(View.VISIBLE); |
| mTabContents.setVisibility(View.INVISIBLE); |
| } else { |
| mTabContents.setVisibility(View.VISIBLE); |
| mUndoContents.setVisibility(View.INVISIBLE); |
| updateTabTitle(); |
| updateFavicon(); |
| } |
| } |
| |
| /** |
| * Registers a listener to be notified of selection and close events taken on this list item. |
| * @param listener The listener to be notified of selection and close events. |
| */ |
| public void setListeners(AccessibilityTabModelListItemListener listener, |
| AccessibilityTabModelListView canScrollListener) { |
| mListener = listener; |
| mCanScrollListener = canScrollListener; |
| } |
| |
| private void updateTabTitle() { |
| String title = mTab != null ? mTab.getTitle() : null; |
| if (title == null || title.isEmpty()) { |
| title = getContext().getResources().getString(R.string.tab_loading_default_title); |
| } |
| |
| if (!title.equals(mTitleView.getText())) mTitleView.setText(title); |
| |
| String accessibilityString = getContext().getString(R.string.accessibility_tabstrip_tab, |
| title); |
| if (!accessibilityString.equals(getContentDescription())) { |
| setContentDescription(getContext().getString(R.string.accessibility_tabstrip_tab, |
| title)); |
| } |
| } |
| |
| private void updateFavicon() { |
| if (mTab != null) { |
| Bitmap bitmap = mTab.getFavicon(); |
| if (bitmap != null) { |
| mFaviconView.setImageBitmap(bitmap); |
| } else { |
| mFaviconView.setImageResource(R.drawable.globe_incognito_favicon); |
| } |
| } |
| } |
| |
| @Override |
| public void onClick(View v) { |
| if (mListener == null) return; |
| |
| int tabId = mTab.getId(); |
| if (v == AccessibilityTabModelListItem.this && !mListener.hasPendingClosure(tabId)) { |
| mListener.tabSelected(tabId); |
| } else if (v == mCloseButton) { |
| if (mCanUndo) { |
| runBlinkOutAnimation(); |
| } else { |
| runCloseAnimation(); |
| } |
| } else if (v == mUndoButton) { |
| // Kill the close action. |
| mHandler.removeCallbacks(mCloseRunnable); |
| |
| mListener.cancelPendingClosure(tabId); |
| showUndoView(false); |
| setAlpha(0.f); |
| if (mSwipedAway > 0.f) { |
| setTranslationX(getWidth()); |
| runResetAnimation(false); |
| } else if (mSwipedAway < 0.f) { |
| setTranslationX(-getWidth()); |
| runResetAnimation(false); |
| } else { |
| setScaleX(1.2f); |
| setScaleY(0.f); |
| runResetAnimation(true); |
| } |
| } |
| } |
| |
| @Override |
| protected void onDetachedFromWindow() { |
| super.onDetachedFromWindow(); |
| if (mTab != null) mTab.removeObserver(mTabObserver); |
| cancelRunningAnimation(); |
| } |
| |
| private final TabObserver mTabObserver = new EmptyTabObserver() { |
| @Override |
| public void onFaviconUpdated(Tab tab) { |
| updateFavicon(); |
| notifyTabUpdated(tab); |
| } |
| |
| @Override |
| public void onTitleUpdated(Tab tab) { |
| updateTabTitle(); |
| notifyTabUpdated(tab); |
| } |
| |
| @Override |
| public void onUrlUpdated(Tab tab) { |
| updateTabTitle(); |
| notifyTabUpdated(tab); |
| } |
| }; |
| |
| @Override |
| public boolean onTouchEvent(MotionEvent e) { |
| // If there is a pending close task, remove it. |
| mHandler.removeCallbacks(mCloseRunnable); |
| |
| boolean handled = mSwipeGestureDetector.onTouchEvent(e); |
| if (handled) return true; |
| if (e.getActionMasked() == MotionEvent.ACTION_UP) { |
| if (Math.abs(getTranslationX()) > mSwipeCommitDistance) { |
| runSwipeAnimation(DEFAULT_ANIMATION_DURATION_MS); |
| } else { |
| runResetAnimation(false); |
| } |
| mCanScrollListener.setCanScroll(true); |
| return true; |
| } |
| return super.onTouchEvent(e); |
| } |
| |
| /** |
| * This call is exposed for the benefit of the animators. |
| * |
| * @param height The height of the current view. |
| */ |
| public void setHeight(int height) { |
| AbsListView.LayoutParams params = (AbsListView.LayoutParams) getLayoutParams(); |
| if (params == null) { |
| params = new AbsListView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height); |
| } else { |
| if (params.height == height) return; |
| params.height = height; |
| } |
| setLayoutParams(params); |
| } |
| |
| /** |
| * Used to reset the state because views are recycled. |
| */ |
| public void resetState() { |
| setTranslationX(0.f); |
| setAlpha(1.f); |
| setScaleX(1.f); |
| setScaleY(1.f); |
| setHeight(mDefaultHeight); |
| cancelRunningAnimation(); |
| // Remove any callbacks. |
| mHandler.removeCallbacks(mCloseRunnable); |
| |
| if (mListener != null) { |
| boolean hasPendingClosure = mListener.hasPendingClosure(mTab.getId()); |
| showUndoView(hasPendingClosure); |
| if (hasPendingClosure) mHandler.postDelayed(mCloseRunnable, mCloseTimeoutMs); |
| } else { |
| showUndoView(false); |
| } |
| } |
| |
| /** |
| * Simple gesture listener to catch the scroll and fling gestures on the list item. |
| */ |
| private class SwipeGestureListener extends GestureDetector.SimpleOnGestureListener { |
| @Override |
| public boolean onDown(MotionEvent e) { |
| // Returns true so that we can handle events that start with an onDown. |
| return true; |
| } |
| |
| @Override |
| public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { |
| // Don't scroll if we're waiting for user interaction. |
| if (mListener.hasPendingClosure(mTab.getId())) return false; |
| |
| // Stop the ListView from scrolling vertically. |
| mCanScrollListener.setCanScroll(false); |
| |
| float distance = e2.getX() - e1.getX(); |
| setTranslationX(distance + getTranslationX()); |
| setAlpha(1 - Math.abs(getTranslationX() / getWidth())); |
| return true; |
| } |
| |
| @Override |
| public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { |
| // Arbitrary threshold that feels right. |
| if (Math.abs(getTranslationX()) < mFlingCommitDistance) return false; |
| |
| double velocityMagnitude = Math.sqrt(velocityX * velocityX + velocityY * velocityY); |
| long closeTime = (long) Math.abs((getWidth() / velocityMagnitude)) * |
| VELOCITY_SCALING_FACTOR; |
| runSwipeAnimation(Math.min(closeTime, mDefaultAnimationDurationMs)); |
| mCanScrollListener.setCanScroll(true); |
| return true; |
| } |
| |
| @Override |
| public boolean onSingleTapConfirmed(MotionEvent e) { |
| performClick(); |
| return true; |
| } |
| } |
| |
| @VisibleForTesting |
| public void disableAnimations() { |
| mCloseAnimationDurationMs = 0; |
| mDefaultAnimationDurationMs = 0; |
| mCloseTimeoutMs = 0; |
| } |
| |
| @VisibleForTesting |
| public boolean hasPendingClosure() { |
| if (mListener != null) return mListener.hasPendingClosure(mTab.getId()); |
| return false; |
| } |
| |
| private void runSwipeAnimation(long time) { |
| cancelRunningAnimation(); |
| mSwipedAway = getTranslationX(); |
| |
| ObjectAnimator swipe = ObjectAnimator.ofFloat(this, View.TRANSLATION_X, |
| getTranslationX() > 0 ? getWidth() : -getWidth()); |
| ObjectAnimator fadeOut = ObjectAnimator.ofFloat(this, View.ALPHA, 0.f); |
| |
| AnimatorSet set = new AnimatorSet(); |
| set.playTogether(fadeOut, swipe); |
| set.addListener(mCloseAnimatorListener); |
| set.setDuration(Math.min(time, mDefaultAnimationDurationMs)); |
| set.start(); |
| |
| mActiveAnimation = set; |
| } |
| |
| private void runResetAnimation(boolean useCloseAnimationDuration) { |
| cancelRunningAnimation(); |
| |
| ObjectAnimator swipe = ObjectAnimator.ofFloat(this, View.TRANSLATION_X, 0.f); |
| ObjectAnimator fadeIn = ObjectAnimator.ofFloat(this, View.ALPHA, 1.f); |
| ObjectAnimator scaleX = ObjectAnimator.ofFloat(this, View.SCALE_X, 1.f); |
| ObjectAnimator scaleY = ObjectAnimator.ofFloat(this, View.SCALE_Y, 1.f); |
| ObjectAnimator resetHeight = ObjectAnimator.ofInt(this, "height", mDefaultHeight); |
| |
| AnimatorSet set = new AnimatorSet(); |
| set.playTogether(swipe, fadeIn, scaleX, scaleY, resetHeight); |
| set.setDuration(useCloseAnimationDuration |
| ? mCloseAnimationDurationMs : mDefaultAnimationDurationMs); |
| set.start(); |
| |
| mActiveAnimation = set; |
| } |
| |
| private void runBlinkOutAnimation() { |
| cancelRunningAnimation(); |
| mSwipedAway = 0; |
| |
| ObjectAnimator stretchX = ObjectAnimator.ofFloat(this, View.SCALE_X, 1.2f); |
| ObjectAnimator shrinkY = ObjectAnimator.ofFloat(this, View.SCALE_Y, 0.f); |
| ObjectAnimator fadeOut = ObjectAnimator.ofFloat(this, View.ALPHA, 0.f); |
| |
| AnimatorSet set = new AnimatorSet(); |
| set.playTogether(fadeOut, shrinkY, stretchX); |
| set.addListener(mCloseAnimatorListener); |
| set.setDuration(mCloseAnimationDurationMs); |
| set.start(); |
| |
| mActiveAnimation = set; |
| } |
| |
| private void runCloseAnimation() { |
| cancelRunningAnimation(); |
| |
| ObjectAnimator shrinkHeight = ObjectAnimator.ofInt(this, "height", 0); |
| ObjectAnimator shrinkY = ObjectAnimator.ofFloat(this, View.SCALE_Y, 0.f); |
| |
| AnimatorSet set = new AnimatorSet(); |
| set.playTogether(shrinkHeight, shrinkY); |
| set.addListener(mActuallyCloseAnimatorListener); |
| set.setDuration(mDefaultAnimationDurationMs); |
| set.start(); |
| |
| mActiveAnimation = set; |
| } |
| |
| private void cancelRunningAnimation() { |
| if (mActiveAnimation != null && mActiveAnimation.isRunning()) mActiveAnimation.cancel(); |
| |
| mActiveAnimation = null; |
| } |
| |
| private void notifyTabUpdated(Tab tab) { |
| if (mListener != null) mListener.tabChanged(tab.getId()); |
| } |
| } |