/* -*- 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; import org.mozilla.gecko.PropertyAnimator.Property; import android.content.Context; import android.graphics.PointF; import android.graphics.drawable.Drawable; import android.text.TextUtils; import android.util.AttributeSet; import android.view.GestureDetector; import android.view.GestureDetector.SimpleOnGestureListener; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.AbsListView.RecyclerListener; import android.widget.BaseAdapter; import android.widget.Button; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ListView; import android.widget.TextView; import java.util.ArrayList; public class TabsTray extends LinearLayout implements TabsPanel.PanelView { private static final String LOGTAG = "GeckoTabsTray"; private Context mContext; private TabsPanel mTabsPanel; private static ListView mList; private TabsAdapter mTabsAdapter; private boolean mWaitingForClose; private GestureDetector mGestureDetector; private TabSwipeGestureListener mListener; // Minimum velocity swipe that will close a tab, in inches/sec private static final int SWIPE_CLOSE_VELOCITY = 5; // Time to animate non-flicked tabs of screen, in milliseconds private static final int MAX_ANIMATION_TIME = 250; // Extra weight given to detecting vertical swipes over horizontal ones private static final float SWIPE_VERTICAL_WEIGHT = 1.5f; private static enum DragDirection { UNKNOWN, HORIZONTAL, VERTICAL } private static final String ABOUT_HOME = "about:home"; public TabsTray(Context context, AttributeSet attrs) { super(context, attrs); mContext = context; LayoutInflater.from(context).inflate(R.layout.tabs_tray, this); mList = (ListView) findViewById(R.id.list); mList.setItemsCanFocus(true); mTabsAdapter = new TabsAdapter(mContext); mList.setAdapter(mTabsAdapter); mListener = new TabSwipeGestureListener(mList); mGestureDetector = new GestureDetector(context, mListener); mList.setOnTouchListener(new View.OnTouchListener() { public boolean onTouch(View v, MotionEvent event) { boolean result = mGestureDetector.onTouchEvent(event); // if this is an touch end event, we need to reset the state // of the gesture listener switch (event.getAction() & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_UP: mListener.onTouchEnd(event); } // the simple gesture detector doesn't actually call our methods for every touch event // if we're horizontally scrolling we should always return true to prevent scrolling the list if (mListener.getDirection() == DragDirection.HORIZONTAL) result = true; return result; } }); mList.setRecyclerListener(new RecyclerListener() { @Override public void onMovedToScrapHeap(View view) { TabRow row = (TabRow) view.getTag(); row.thumbnail.setImageDrawable(null); } }); } @Override public ViewGroup getLayout() { return this; } @Override public void setTabsPanel(TabsPanel panel) { mTabsPanel = panel; } @Override public void show() { mWaitingForClose = false; Tabs.getInstance().refreshThumbnails(); Tabs.registerOnTabsChangedListener(mTabsAdapter); mTabsAdapter.refreshTabsData(); } @Override public void hide() { Tabs.unregisterOnTabsChangedListener(mTabsAdapter); GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Tab:Screenshot:Cancel","")); mTabsAdapter.clear(); } void autoHidePanel() { mTabsPanel.autoHidePanel(); } // ViewHolder for a row in the list private class TabRow { int id; TextView title; ImageView thumbnail; ImageButton close; LinearLayout info; public TabRow(View view) { info = (LinearLayout) view; title = (TextView) view.findViewById(R.id.title); thumbnail = (ImageView) view.findViewById(R.id.thumbnail); close = (ImageButton) view.findViewById(R.id.close); } } // Adapter to bind tabs into a list private class TabsAdapter extends BaseAdapter implements Tabs.OnTabsChangedListener { private Context mContext; private ArrayList mTabs; private LayoutInflater mInflater; private Button.OnClickListener mOnCloseClickListener; public TabsAdapter(Context context) { mContext = context; mInflater = LayoutInflater.from(mContext); mOnCloseClickListener = new Button.OnClickListener() { public void onClick(View v) { TabRow tab = (TabRow) v.getTag(); animateTo(tab.info, tab.info.getWidth(), MAX_ANIMATION_TIME); } }; } public void onTabChanged(Tab tab, Tabs.TabEvents msg, Object data) { switch (msg) { case ADDED: // Refresh the list to make sure the new tab is added in the right position. refreshTabsData(); break; case CLOSED: mWaitingForClose = false; removeTab(tab); break; case SELECTED: // Update the selected position, then fall through... updateSelectedPosition(); case UNSELECTED: // We just need to update the style for the unselected tab... case THUMBNAIL: case TITLE: View view = mList.getChildAt(getPositionForTab(tab) - mList.getFirstVisiblePosition()); if (view == null) return; TabRow row = (TabRow) view.getTag(); assignValues(row, tab); break; } } private void refreshTabsData() { // Store a different copy of the tabs, so that we don't have to worry about // accidentally updating it on the wrong thread. mTabs = new ArrayList(); Iterable tabs = Tabs.getInstance().getTabsInOrder(); for (Tab tab : tabs) { mTabs.add(tab); } notifyDataSetChanged(); // Be sure to call this whenever mTabs changes. updateSelectedPosition(); } // Updates the selected position in the list so that it will be scrolled to the right place. private void updateSelectedPosition() { int selected = getPositionForTab(Tabs.getInstance().getSelectedTab()); if (selected == -1) return; mList.setSelection(selected); } public void clear() { mTabs = null; notifyDataSetChanged(); // Be sure to call this whenever mTabs changes. } public int getCount() { return (mTabs == null ? 0 : mTabs.size()); } public Tab getItem(int position) { return mTabs.get(position); } public long getItemId(int position) { return position; } private int getPositionForTab(Tab tab) { if (mTabs == null || tab == null) return -1; return mTabs.indexOf(tab); } private void removeTab(Tab tab) { mTabs.remove(tab); notifyDataSetChanged(); // Be sure to call this whenever mTabs changes. } private void assignValues(TabRow row, Tab tab) { if (row == null || tab == null) return; row.id = tab.getId(); Drawable thumbnailImage = tab.getThumbnail(); if (thumbnailImage != null) row.thumbnail.setImageDrawable(thumbnailImage); else if (TextUtils.equals(tab.getURL(), ABOUT_HOME)) row.thumbnail.setImageResource(R.drawable.abouthome_thumbnail); else row.thumbnail.setImageResource(R.drawable.tab_thumbnail_default); if (Tabs.getInstance().isSelectedTab(tab)) row.info.setBackgroundResource(R.drawable.tabs_tray_active_selector); else row.info.setBackgroundResource(R.drawable.tabs_tray_default_selector); // this may be a recycled view that was animated off screen // reset the scroll state here row.info.scrollTo(0,0); row.title.setText(tab.getDisplayTitle()); row.close.setTag(row); row.close.setVisibility(mTabs.size() > 1 ? View.VISIBLE : View.INVISIBLE); } public View getView(int position, View convertView, ViewGroup parent) { TabRow row; if (convertView == null) { convertView = mInflater.inflate(R.layout.tabs_row, null); row = new TabRow(convertView); row.close.setOnClickListener(mOnCloseClickListener); convertView.setTag(row); } else { row = (TabRow) convertView.getTag(); } Tab tab = mTabs.get(position); assignValues(row, tab); return convertView; } } private void animateTo(final View view, int x, int duration) { PropertyAnimator pa = new PropertyAnimator(duration); pa.attach(view, Property.SLIDE_LEFT, x); if (x != 0 && !mWaitingForClose) { mWaitingForClose = true; TabRow tab = (TabRow)view.getTag(); final int tabId = tab.id; pa.setPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() { public void onPropertyAnimationStart() { } public void onPropertyAnimationEnd() { Tabs tabs = Tabs.getInstance(); Tab tab = tabs.getTab(tabId); tabs.closeTab(tab); } }); } else if (x != 0 && mWaitingForClose) { // if this asked us to close, but we were already doing it just bail out return; } pa.start(); } private class TabSwipeGestureListener extends SimpleOnGestureListener { private View mList = null; private View mView = null; private PointF start = null; private DragDirection dir = DragDirection.UNKNOWN; public TabSwipeGestureListener(View v) { mList = v; } public DragDirection getDirection() { return dir; } @Override public boolean onDown(MotionEvent e) { mView = findViewAt((int)e.getX(), (int)e.getY()); if (mView == null) return false; mView.setPressed(true); start = new PointF(e.getX(), e.getY()); return false; } public boolean onTouchEnd(MotionEvent e) { if (mView != null) { // if the user was dragging horizontally, check to see if we should close the tab if (dir == DragDirection.HORIZONTAL) { int finalPos = 0; // if the swipe started on the left and ended in the right 25% of the tray // or vice versa, close the tab if ((start.x > mList.getWidth() / 2 && e.getX() < mList.getWidth() * 0.25 )) { finalPos = -1 * mView.getWidth(); } else if (start.x < mList.getWidth() / 2 && e.getX() > mList.getWidth() * 0.75) { finalPos = mView.getWidth(); } animateTo(mView, finalPos, MAX_ANIMATION_TIME); } else if (mView != null && dir == DragDirection.UNKNOWN) { // the user didn't attempt to scroll the view, so select the row TabRow tab = (TabRow)mView.getTag(); int tabId = tab.id; Tabs.getInstance().selectTab(tabId); autoHidePanel(); } } mView = null; start = null; dir = DragDirection.UNKNOWN; return false; } @Override public boolean onScroll(MotionEvent event1, MotionEvent event2, float distanceX, float distanceY) { if (mView == null) return false; // if there is only one tab left, we want to recognize the scroll and // stop any click/selection events, but not scroll/close the view if (Tabs.getInstance().getCount() == 1) { mView.setPressed(false); mView = null; return false; } if (dir == DragDirection.UNKNOWN) { // check if this scroll is more horizontal than vertical. Weight vertical drags a little higher // by using a multiplier if (Math.abs(distanceX) > Math.abs(distanceY) * SWIPE_VERTICAL_WEIGHT) { dir = DragDirection.HORIZONTAL; } else { dir = DragDirection.VERTICAL; } mView.setPressed(false); } if (dir == DragDirection.HORIZONTAL) { mView.scrollBy((int) distanceX, 0); return true; } return false; } @Override public boolean onFling(MotionEvent event1, MotionEvent event2, float velocityX, float velocityY) { if (mView == null || Tabs.getInstance().getCount() == 1) return false; // velocityX is in pixels/sec. divide by pixels/inch to compare it with swipe velocity // also make sure that the swipe is in a mostly horizontal direction if (Math.abs(velocityX) > Math.abs(velocityY * SWIPE_VERTICAL_WEIGHT) && Math.abs(velocityX)/GeckoAppShell.getDpi() > SWIPE_CLOSE_VELOCITY) { // is this is a swipe, we want to continue the row moving at the swipe velocity float d = (velocityX > 0 ? 1 : -1) * mView.getWidth(); // convert the velocity (px/sec) to ms by taking the distance // multiply by 1000 to convert seconds to milliseconds animateTo(mView, (int)d, (int)((d + mView.getScrollX())*1000/velocityX)); } return false; } private View findViewAt(int x, int y) { if (mList == null) return null; ListView list = (ListView)mList; x += list.getScrollX(); y += list.getScrollY(); final int count = list.getChildCount(); for (int i = count - 1; i >= 0; i--) { View child = list.getChildAt(i); if (child.getVisibility() == View.VISIBLE) { if ((x >= child.getLeft()) && (x < child.getRight()) && (y >= child.getTop()) && (y < child.getBottom())) { return child; } } } return null; } } }