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