Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
4 * You can obtain one at http://mozilla.org/MPL/2.0/. */
6 package org.mozilla.gecko;
8 import java.util.ArrayList;
9 import java.util.List;
11 import org.mozilla.gecko.animation.PropertyAnimator;
12 import org.mozilla.gecko.animation.PropertyAnimator.Property;
13 import org.mozilla.gecko.animation.ViewHelper;
14 import org.mozilla.gecko.widget.TwoWayView;
15 import org.mozilla.gecko.widget.TabThumbnailWrapper;
17 import android.content.Context;
18 import android.content.res.TypedArray;
19 import android.graphics.Rect;
20 import android.graphics.drawable.Drawable;
21 import android.util.AttributeSet;
22 import android.view.LayoutInflater;
23 import android.view.MotionEvent;
24 import android.view.VelocityTracker;
25 import android.view.View;
26 import android.view.ViewConfiguration;
27 import android.view.ViewGroup;
28 import android.widget.BaseAdapter;
29 import android.widget.Button;
30 import android.widget.ImageButton;
31 import android.widget.ImageView;
32 import android.widget.TextView;
34 public class TabsTray extends TwoWayView
35 implements TabsPanel.PanelView {
36 private static final String LOGTAG = "GeckoTabsTray";
38 private Context mContext;
39 private TabsPanel mTabsPanel;
41 private TabsAdapter mTabsAdapter;
43 private List<View> mPendingClosedTabs;
44 private int mCloseAnimationCount;
46 private TabSwipeGestureListener mSwipeListener;
48 // Time to animate non-flinged tabs of screen, in milliseconds
49 private static final int ANIMATION_DURATION = 250;
51 private static final String ABOUT_HOME = "about:home";
52 private int mOriginalSize = 0;
54 public TabsTray(Context context, AttributeSet attrs) {
55 super(context, attrs);
56 mContext = context;
58 mCloseAnimationCount = 0;
59 mPendingClosedTabs = new ArrayList<View>();
61 setItemsCanFocus(true);
63 TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabsTray);
64 boolean isPrivate = (a.getInt(R.styleable.TabsTray_tabs, 0x0) == 1);
65 a.recycle();
67 mTabsAdapter = new TabsAdapter(mContext, isPrivate);
68 setAdapter(mTabsAdapter);
70 mSwipeListener = new TabSwipeGestureListener();
71 setOnTouchListener(mSwipeListener);
72 setOnScrollListener(mSwipeListener.makeScrollListener());
74 setRecyclerListener(new RecyclerListener() {
75 @Override
76 public void onMovedToScrapHeap(View view) {
77 TabRow row = (TabRow) view.getTag();
78 row.thumbnail.setImageDrawable(null);
79 row.close.setVisibility(View.VISIBLE);
80 }
81 });
82 }
84 @Override
85 public ViewGroup getLayout() {
86 return this;
87 }
89 @Override
90 public void setTabsPanel(TabsPanel panel) {
91 mTabsPanel = panel;
92 }
94 @Override
95 public void show() {
96 setVisibility(View.VISIBLE);
97 Tabs.getInstance().refreshThumbnails();
98 Tabs.registerOnTabsChangedListener(mTabsAdapter);
99 mTabsAdapter.refreshTabsData();
100 }
102 @Override
103 public void hide() {
104 setVisibility(View.GONE);
105 Tabs.unregisterOnTabsChangedListener(mTabsAdapter);
106 GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Tab:Screenshot:Cancel",""));
107 mTabsAdapter.clear();
108 }
110 @Override
111 public boolean shouldExpand() {
112 return isVertical();
113 }
115 private void autoHidePanel() {
116 mTabsPanel.autoHidePanel();
117 }
119 // ViewHolder for a row in the list
120 private class TabRow {
121 int id;
122 TextView title;
123 ImageView thumbnail;
124 ImageButton close;
125 ViewGroup info;
126 TabThumbnailWrapper thumbnailWrapper;
128 public TabRow(View view) {
129 info = (ViewGroup) view;
130 title = (TextView) view.findViewById(R.id.title);
131 thumbnail = (ImageView) view.findViewById(R.id.thumbnail);
132 close = (ImageButton) view.findViewById(R.id.close);
133 thumbnailWrapper = (TabThumbnailWrapper) view.findViewById(R.id.wrapper);
134 }
135 }
137 // Adapter to bind tabs into a list
138 private class TabsAdapter extends BaseAdapter implements Tabs.OnTabsChangedListener {
139 private Context mContext;
140 private boolean mIsPrivate;
141 private ArrayList<Tab> mTabs;
142 private LayoutInflater mInflater;
143 private Button.OnClickListener mOnCloseClickListener;
145 public TabsAdapter(Context context, boolean isPrivate) {
146 mContext = context;
147 mInflater = LayoutInflater.from(mContext);
148 mIsPrivate = isPrivate;
150 mOnCloseClickListener = new Button.OnClickListener() {
151 @Override
152 public void onClick(View v) {
153 TabRow tab = (TabRow) v.getTag();
154 final int pos = (isVertical() ? tab.info.getWidth() : tab.info.getHeight());
155 animateClose(tab.info, pos);
156 }
157 };
158 }
160 @Override
161 public void onTabChanged(Tab tab, Tabs.TabEvents msg, Object data) {
162 switch (msg) {
163 case ADDED:
164 // Refresh the list to make sure the new tab is added in the right position.
165 refreshTabsData();
166 break;
168 case CLOSED:
169 removeTab(tab);
170 break;
172 case SELECTED:
173 // Update the selected position, then fall through...
174 updateSelectedPosition();
175 case UNSELECTED:
176 // We just need to update the style for the unselected tab...
177 case THUMBNAIL:
178 case TITLE:
179 case RECORDING_CHANGE:
180 View view = TabsTray.this.getChildAt(getPositionForTab(tab) - TabsTray.this.getFirstVisiblePosition());
181 if (view == null)
182 return;
184 TabRow row = (TabRow) view.getTag();
185 assignValues(row, tab);
186 break;
187 }
188 }
190 private void refreshTabsData() {
191 // Store a different copy of the tabs, so that we don't have to worry about
192 // accidentally updating it on the wrong thread.
193 mTabs = new ArrayList<Tab>();
195 Iterable<Tab> tabs = Tabs.getInstance().getTabsInOrder();
196 for (Tab tab : tabs) {
197 if (tab.isPrivate() == mIsPrivate)
198 mTabs.add(tab);
199 }
201 notifyDataSetChanged(); // Be sure to call this whenever mTabs changes.
202 updateSelectedPosition();
203 }
205 // Updates the selected position in the list so that it will be scrolled to the right place.
206 private void updateSelectedPosition() {
207 int selected = getPositionForTab(Tabs.getInstance().getSelectedTab());
208 updateSelectedStyle(selected);
210 if (selected != -1) {
211 TabsTray.this.setSelection(selected);
212 }
213 }
215 /**
216 * Updates the selected/unselected style for the tabs.
217 *
218 * @param selected position of the selected tab
219 */
220 private void updateSelectedStyle(int selected) {
221 for (int i = 0; i < getCount(); i++) {
222 TabsTray.this.setItemChecked(i, (i == selected));
223 }
224 }
226 public void clear() {
227 mTabs = null;
228 notifyDataSetChanged(); // Be sure to call this whenever mTabs changes.
229 }
231 @Override
232 public int getCount() {
233 return (mTabs == null ? 0 : mTabs.size());
234 }
236 @Override
237 public Tab getItem(int position) {
238 return mTabs.get(position);
239 }
241 @Override
242 public long getItemId(int position) {
243 return position;
244 }
246 private int getPositionForTab(Tab tab) {
247 if (mTabs == null || tab == null)
248 return -1;
250 return mTabs.indexOf(tab);
251 }
253 private void removeTab(Tab tab) {
254 if (tab.isPrivate() == mIsPrivate && mTabs != null) {
255 mTabs.remove(tab);
256 notifyDataSetChanged(); // Be sure to call this whenever mTabs changes.
258 int selected = getPositionForTab(Tabs.getInstance().getSelectedTab());
259 updateSelectedStyle(selected);
260 }
261 }
263 private void assignValues(TabRow row, Tab tab) {
264 if (row == null || tab == null)
265 return;
267 row.id = tab.getId();
269 Drawable thumbnailImage = tab.getThumbnail();
270 if (thumbnailImage != null) {
271 row.thumbnail.setImageDrawable(thumbnailImage);
272 } else if (AboutPages.isAboutHome(tab.getURL())) {
273 row.thumbnail.setImageResource(R.drawable.abouthome_thumbnail);
274 } else {
275 row.thumbnail.setImageResource(R.drawable.tab_thumbnail_default);
276 }
277 if (row.thumbnailWrapper != null) {
278 row.thumbnailWrapper.setRecording(tab.isRecording());
279 }
280 row.title.setText(tab.getDisplayTitle());
281 row.close.setTag(row);
282 }
284 private void resetTransforms(View view) {
285 ViewHelper.setAlpha(view, 1);
286 if (mOriginalSize == 0)
287 return;
289 if (isVertical()) {
290 ViewHelper.setHeight(view, mOriginalSize);
291 ViewHelper.setTranslationX(view, 0);
292 } else {
293 ViewHelper.setWidth(view, mOriginalSize);
294 ViewHelper.setTranslationY(view, 0);
295 }
296 }
298 @Override
299 public View getView(int position, View convertView, ViewGroup parent) {
300 TabRow row;
302 if (convertView == null) {
303 convertView = mInflater.inflate(R.layout.tabs_row, null);
304 row = new TabRow(convertView);
305 row.close.setOnClickListener(mOnCloseClickListener);
306 convertView.setTag(row);
307 } else {
308 row = (TabRow) convertView.getTag();
309 // If we're recycling this view, there's a chance it was transformed during
310 // the close animation. Remove any of those properties.
311 resetTransforms(convertView);
312 }
314 Tab tab = mTabs.get(position);
315 assignValues(row, tab);
317 return convertView;
318 }
319 }
321 private boolean isVertical() {
322 return (getOrientation().compareTo(TwoWayView.Orientation.VERTICAL) == 0);
323 }
325 private void animateClose(final View view, int pos) {
326 PropertyAnimator animator = new PropertyAnimator(ANIMATION_DURATION);
327 animator.attach(view, Property.ALPHA, 0);
329 if (isVertical())
330 animator.attach(view, Property.TRANSLATION_X, pos);
331 else
332 animator.attach(view, Property.TRANSLATION_Y, pos);
334 mCloseAnimationCount++;
335 mPendingClosedTabs.add(view);
337 animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
338 @Override
339 public void onPropertyAnimationStart() { }
340 @Override
341 public void onPropertyAnimationEnd() {
342 mCloseAnimationCount--;
343 if (mCloseAnimationCount > 0)
344 return;
346 for (View pendingView : mPendingClosedTabs) {
347 animateFinishClose(pendingView);
348 }
350 mPendingClosedTabs.clear();
351 }
352 });
354 if (mTabsAdapter.getCount() == 1)
355 autoHidePanel();
357 animator.start();
358 }
360 private void animateFinishClose(final View view) {
361 PropertyAnimator animator = new PropertyAnimator(ANIMATION_DURATION);
363 final boolean isVertical = isVertical();
364 if (isVertical)
365 animator.attach(view, Property.HEIGHT, 1);
366 else
367 animator.attach(view, Property.WIDTH, 1);
369 TabRow tab = (TabRow)view.getTag();
370 final int tabId = tab.id;
371 // Caching this assumes that all rows are the same height
372 if (mOriginalSize == 0)
373 mOriginalSize = (isVertical ? view.getHeight() : view.getWidth());
375 animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
376 @Override
377 public void onPropertyAnimationStart() { }
378 @Override
379 public void onPropertyAnimationEnd() {
380 Tabs tabs = Tabs.getInstance();
381 Tab tab = tabs.getTab(tabId);
382 tabs.closeTab(tab);
383 }
384 });
386 animator.start();
387 }
389 private void animateCancel(final View view) {
390 PropertyAnimator animator = new PropertyAnimator(ANIMATION_DURATION);
391 animator.attach(view, Property.ALPHA, 1);
393 if (isVertical())
394 animator.attach(view, Property.TRANSLATION_X, 0);
395 else
396 animator.attach(view, Property.TRANSLATION_Y, 0);
399 animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() {
400 @Override
401 public void onPropertyAnimationStart() { }
402 @Override
403 public void onPropertyAnimationEnd() {
404 TabRow tab = (TabRow) view.getTag();
405 tab.close.setVisibility(View.VISIBLE);
406 }
407 });
409 animator.start();
410 }
412 private class TabSwipeGestureListener implements View.OnTouchListener {
413 // same value the stock browser uses for after drag animation velocity in pixels/sec
414 // http://androidxref.com/4.0.4/xref/packages/apps/Browser/src/com/android/browser/NavTabScroller.java#61
415 private static final float MIN_VELOCITY = 750;
417 private int mSwipeThreshold;
418 private int mMinFlingVelocity;
420 private int mMaxFlingVelocity;
421 private VelocityTracker mVelocityTracker;
423 private int mListWidth = 1;
424 private int mListHeight = 1;
426 private View mSwipeView;
427 private int mSwipeViewPosition;
428 private Runnable mPendingCheckForTap;
430 private float mSwipeStartX;
431 private float mSwipeStartY;
432 private boolean mSwiping;
433 private boolean mEnabled;
435 public TabSwipeGestureListener() {
436 mSwipeView = null;
437 mSwipeViewPosition = TwoWayView.INVALID_POSITION;
438 mSwiping = false;
439 mEnabled = true;
441 ViewConfiguration vc = ViewConfiguration.get(TabsTray.this.getContext());
442 mSwipeThreshold = vc.getScaledTouchSlop();
443 mMinFlingVelocity = (int) (getContext().getResources().getDisplayMetrics().density * MIN_VELOCITY);
444 mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity();
445 }
447 public void setEnabled(boolean enabled) {
448 mEnabled = enabled;
449 }
451 public TwoWayView.OnScrollListener makeScrollListener() {
452 return new TwoWayView.OnScrollListener() {
453 @Override
454 public void onScrollStateChanged(TwoWayView twoWayView, int scrollState) {
455 setEnabled(scrollState != TwoWayView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL);
456 }
458 @Override
459 public void onScroll(TwoWayView twoWayView, int i, int i1, int i2) {
460 }
461 };
462 }
464 @Override
465 public boolean onTouch(View view, MotionEvent e) {
466 if (!mEnabled)
467 return false;
469 if (mListWidth < 2 || mListHeight < 2) {
470 mListWidth = TabsTray.this.getWidth();
471 mListHeight = TabsTray.this.getHeight();
472 }
474 switch (e.getActionMasked()) {
475 case MotionEvent.ACTION_DOWN: {
476 // Check if we should set pressed state on the
477 // touched view after a standard delay.
478 triggerCheckForTap();
480 final float x = e.getRawX();
481 final float y = e.getRawY();
483 // Find out which view is being touched
484 mSwipeView = findViewAt(x, y);
486 if (mSwipeView != null) {
487 mSwipeStartX = e.getRawX();
488 mSwipeStartY = e.getRawY();
489 mSwipeViewPosition = TabsTray.this.getPositionForView(mSwipeView);
491 mVelocityTracker = VelocityTracker.obtain();
492 mVelocityTracker.addMovement(e);
493 }
495 view.onTouchEvent(e);
496 return true;
497 }
499 case MotionEvent.ACTION_UP: {
500 if (mSwipeView == null)
501 break;
503 cancelCheckForTap();
504 mSwipeView.setPressed(false);
506 if (!mSwiping) {
507 TabRow tab = (TabRow) mSwipeView.getTag();
508 Tabs.getInstance().selectTab(tab.id);
509 autoHidePanel();
510 break;
511 }
513 mVelocityTracker.addMovement(e);
514 mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
516 float velocityX = Math.abs(mVelocityTracker.getXVelocity());
517 float velocityY = Math.abs(mVelocityTracker.getYVelocity());
519 boolean dismiss = false;
520 boolean dismissDirection = false;
521 int dismissTranslation = 0;
523 if (isVertical()) {
524 float deltaX = ViewHelper.getTranslationX(mSwipeView);
526 if (Math.abs(deltaX) > mListWidth / 2) {
527 dismiss = true;
528 dismissDirection = (deltaX > 0);
529 } else if (mMinFlingVelocity <= velocityX && velocityX <= mMaxFlingVelocity
530 && velocityY < velocityX) {
531 dismiss = mSwiping && (deltaX * mVelocityTracker.getXVelocity() > 0);
532 dismissDirection = (mVelocityTracker.getXVelocity() > 0);
533 }
535 dismissTranslation = (dismissDirection ? mListWidth : -mListWidth);
536 } else {
537 float deltaY = ViewHelper.getTranslationY(mSwipeView);
539 if (Math.abs(deltaY) > mListHeight / 2) {
540 dismiss = true;
541 dismissDirection = (deltaY > 0);
542 } else if (mMinFlingVelocity <= velocityY && velocityY <= mMaxFlingVelocity
543 && velocityX < velocityY) {
544 dismiss = mSwiping && (deltaY * mVelocityTracker.getYVelocity() > 0);
545 dismissDirection = (mVelocityTracker.getYVelocity() > 0);
546 }
548 dismissTranslation = (dismissDirection ? mListHeight : -mListHeight);
549 }
551 if (dismiss)
552 animateClose(mSwipeView, dismissTranslation);
553 else
554 animateCancel(mSwipeView);
556 mVelocityTracker = null;
557 mSwipeView = null;
558 mSwipeViewPosition = TwoWayView.INVALID_POSITION;
560 mSwipeStartX = 0;
561 mSwipeStartY = 0;
562 mSwiping = false;
564 break;
565 }
567 case MotionEvent.ACTION_MOVE: {
568 if (mSwipeView == null)
569 break;
571 mVelocityTracker.addMovement(e);
573 final boolean isVertical = isVertical();
575 float deltaX = e.getRawX() - mSwipeStartX;
576 float deltaY = e.getRawY() - mSwipeStartY;
577 float delta = (isVertical ? deltaX : deltaY);
579 boolean isScrollingX = Math.abs(deltaX) > mSwipeThreshold;
580 boolean isScrollingY = Math.abs(deltaY) > mSwipeThreshold;
581 boolean isSwipingToClose = (isVertical ? isScrollingX : isScrollingY);
583 // If we're actually swiping, make sure we don't
584 // set pressed state on the swiped view.
585 if (isScrollingX || isScrollingY)
586 cancelCheckForTap();
588 if (isSwipingToClose) {
589 mSwiping = true;
590 TabsTray.this.requestDisallowInterceptTouchEvent(true);
592 TabRow tab = (TabRow) mSwipeView.getTag();
593 tab.close.setVisibility(View.INVISIBLE);
595 // Stops listview from highlighting the touched item
596 // in the list when swiping.
597 MotionEvent cancelEvent = MotionEvent.obtain(e);
598 cancelEvent.setAction(MotionEvent.ACTION_CANCEL |
599 (e.getActionIndex() << MotionEvent.ACTION_POINTER_INDEX_SHIFT));
600 TabsTray.this.onTouchEvent(cancelEvent);
601 }
603 if (mSwiping) {
604 if (isVertical)
605 ViewHelper.setTranslationX(mSwipeView, delta);
606 else
607 ViewHelper.setTranslationY(mSwipeView, delta);
609 ViewHelper.setAlpha(mSwipeView, Math.max(0.1f, Math.min(1f,
610 1f - 2f * Math.abs(delta) / (isVertical ? mListWidth : mListHeight))));
612 return true;
613 }
615 break;
616 }
617 }
619 return false;
620 }
622 private View findViewAt(float rawX, float rawY) {
623 Rect rect = new Rect();
625 int[] listViewCoords = new int[2];
626 TabsTray.this.getLocationOnScreen(listViewCoords);
628 int x = (int) rawX - listViewCoords[0];
629 int y = (int) rawY - listViewCoords[1];
631 for (int i = 0; i < TabsTray.this.getChildCount(); i++) {
632 View child = TabsTray.this.getChildAt(i);
633 child.getHitRect(rect);
635 if (rect.contains(x, y))
636 return child;
637 }
639 return null;
640 }
642 private void triggerCheckForTap() {
643 if (mPendingCheckForTap == null)
644 mPendingCheckForTap = new CheckForTap();
646 TabsTray.this.postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
647 }
649 private void cancelCheckForTap() {
650 if (mPendingCheckForTap == null)
651 return;
653 TabsTray.this.removeCallbacks(mPendingCheckForTap);
654 }
656 private class CheckForTap implements Runnable {
657 @Override
658 public void run() {
659 if (!mSwiping && mSwipeView != null && mEnabled)
660 mSwipeView.setPressed(true);
661 }
662 }
663 }
664 }