diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /mobile/android/base/java/org/mozilla/gecko/tabs | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'mobile/android/base/java/org/mozilla/gecko/tabs')
21 files changed, 3631 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/PrivateTabsPanel.java b/mobile/android/base/java/org/mozilla/gecko/tabs/PrivateTabsPanel.java new file mode 100644 index 000000000..b7bd83376 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/PrivateTabsPanel.java @@ -0,0 +1,63 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.tabs.TabsPanel.CloseAllPanelView; +import org.mozilla.gecko.tabs.TabsPanel.TabsLayout; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; + +/** + * A container that wraps the private tabs {@link android.widget.AdapterView} and empty + * {@link android.view.View} to manage both of their visibility states by changing the visibility of + * this container as calling {@link android.widget.AdapterView#setVisibility} does not affect the + * empty View's visibility. + */ +class PrivateTabsPanel extends FrameLayout implements CloseAllPanelView { + private final TabsLayout tabsLayout; + + public PrivateTabsPanel(final Context context, final AttributeSet attrs) { + super(context, attrs); + + LayoutInflater.from(context).inflate(R.layout.private_tabs_panel, this); + tabsLayout = (TabsLayout) findViewById(R.id.private_tabs_layout); + + final View emptyTabsFrame = findViewById(R.id.private_tabs_empty); + tabsLayout.setEmptyView(emptyTabsFrame); + } + + @Override + public void setTabsPanel(final TabsPanel panel) { + tabsLayout.setTabsPanel(panel); + } + + @Override + public void show() { + tabsLayout.show(); + setVisibility(View.VISIBLE); + } + + @Override + public void hide() { + setVisibility(View.GONE); + tabsLayout.hide(); + } + + @Override + public boolean shouldExpand() { + return tabsLayout.shouldExpand(); + } + + @Override + public void closeAll() { + tabsLayout.closeAll(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabCurve.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabCurve.java new file mode 100644 index 000000000..0b6a30d7a --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabCurve.java @@ -0,0 +1,70 @@ +/* -*- 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.tabs; + +import android.graphics.Path; + +/** + * Utility methods to draws Firefox's tab curve shape. + */ +public class TabCurve { + + public enum Direction { + LEFT(-1), + RIGHT(1); + + private final int value; + + private Direction(int value) { + this.value = value; + } + } + + // Curve's aspect ratio + private static final float ASPECT_RATIO = 0.729f; + + // Width multipliers + private static final float W_M1 = 0.343f; + private static final float W_M2 = 0.514f; + private static final float W_M3 = 0.49f; + private static final float W_M4 = 0.545f; + private static final float W_M5 = 0.723f; + + // Height multipliers + private static final float H_M1 = 0.25f; + private static final float H_M2 = 0.5f; + private static final float H_M3 = 0.72f; + private static final float H_M4 = 0.961f; + + private TabCurve() { + } + + public static float getWidthForHeight(float height) { + return (int) (height * ASPECT_RATIO); + } + + public static void drawFromTop(Path path, float from, float height, Direction dir) { + final float width = getWidthForHeight(height); + + path.cubicTo(from + width * W_M1 * dir.value, 0.0f, + from + width * W_M3 * dir.value, height * H_M1, + from + width * W_M2 * dir.value, height * H_M2); + path.cubicTo(from + width * W_M4 * dir.value, height * H_M3, + from + width * W_M5 * dir.value, height * H_M4, + from + width * dir.value, height); + } + + public static void drawFromBottom(Path path, float from, float height, Direction dir) { + final float width = getWidthForHeight(height); + + path.cubicTo(from + width * (1f - W_M5) * dir.value, height * H_M4, + from + width * (1f - W_M4) * dir.value, height * H_M3, + from + width * (1f - W_M2) * dir.value, height * H_M2); + path.cubicTo(from + width * (1f - W_M3) * dir.value, height * H_M1, + from + width * (1f - W_M1) * dir.value, 0.0f, + from + width * dir.value, 0.0f); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryController.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryController.java new file mode 100644 index 000000000..7b06c994c --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryController.java @@ -0,0 +1,87 @@ +/* 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.tabs; + +import java.util.ArrayList; +import java.util.List; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.util.GeckoRequest; +import org.mozilla.gecko.util.NativeJSObject; + +import android.util.Log; + +public class TabHistoryController { + private static final String LOGTAG = "TabHistoryController"; + private final OnShowTabHistory showTabHistoryListener; + + public static enum HistoryAction { + ALL, + BACK, + FORWARD + }; + + public interface OnShowTabHistory { + void onShowHistory(List<TabHistoryPage> historyPageList, int toIndex); + } + + public TabHistoryController(OnShowTabHistory showTabHistoryListener) { + this.showTabHistoryListener = showTabHistoryListener; + } + + /** + * This method will show the history for the current tab. + */ + public boolean showTabHistory(final Tab tab, final HistoryAction action) { + JSONObject json = new JSONObject(); + try { + json.put("action", action.name()); + json.put("tabId", tab.getId()); + } catch (JSONException e) { + Log.e(LOGTAG, "JSON error", e); + } + + GeckoAppShell.sendRequestToGecko(new GeckoRequest("Session:GetHistory", json) { + @Override + public void onResponse(NativeJSObject nativeJSObject) { + /* + * The response from gecko request is of the form + * { + * "historyItems" : [ + * { + * "title": "google", + * "url": "google.com", + * "selected": false + * } + * ], + * toIndex = 1 + * } + */ + + final NativeJSObject[] historyItems = nativeJSObject.getObjectArray("historyItems"); + if (historyItems.length == 0) { + // Empty history, return without showing the popup. + return; + } + + final List<TabHistoryPage> historyPageList = new ArrayList<>(historyItems.length); + final int toIndex = nativeJSObject.getInt("toIndex"); + + for (NativeJSObject obj : historyItems) { + final String title = obj.getString("title"); + final String url = obj.getString("url"); + final boolean selected = obj.getBoolean("selected"); + historyPageList.add(new TabHistoryPage(title, url, selected)); + } + + showTabHistoryListener.onShowHistory(historyPageList, toIndex); + } + }); + return true; + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryFragment.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryFragment.java new file mode 100644 index 000000000..e6deabdcf --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryFragment.java @@ -0,0 +1,172 @@ +/* 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.tabs; + +import java.util.ArrayList; +import java.util.List; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.R; + +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentTransaction; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ArrayAdapter; +import android.widget.ListView; + +public class TabHistoryFragment extends Fragment implements OnItemClickListener, OnClickListener { + private static final String ARG_LIST = "historyPageList"; + private static final String ARG_INDEX = "index"; + private static final String BACK_STACK_ID = "backStateId"; + + private List<TabHistoryPage> historyPageList; + private int toIndex; + private ListView dialogList; + private int backStackId = -1; + private ViewGroup parent; + private boolean dismissed; + + public TabHistoryFragment() { + + } + + public static TabHistoryFragment newInstance(List<TabHistoryPage> historyPageList, int toIndex) { + final TabHistoryFragment fragment = new TabHistoryFragment(); + final Bundle args = new Bundle(); + args.putParcelableArrayList(ARG_LIST, (ArrayList<? extends Parcelable>) historyPageList); + args.putInt(ARG_INDEX, toIndex); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState != null) { + backStackId = savedInstanceState.getInt(BACK_STACK_ID, -1); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + this.parent = container; + parent.setVisibility(View.VISIBLE); + View view = inflater.inflate(R.layout.tab_history_layout, container, false); + view.setOnClickListener(this); + dialogList = (ListView) view.findViewById(R.id.tab_history_list); + dialogList.setDivider(null); + return view; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + Bundle bundle = getArguments(); + historyPageList = bundle.getParcelableArrayList(ARG_LIST); + toIndex = bundle.getInt(ARG_INDEX); + final ArrayAdapter<TabHistoryPage> urlAdapter = new TabHistoryAdapter(getActivity(), historyPageList); + dialogList.setAdapter(urlAdapter); + dialogList.setOnItemClickListener(this); + } + + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + String index = String.valueOf(toIndex - position); + GeckoAppShell.notifyObservers("Session:Navigate", index); + dismiss(); + } + + @Override + public void onClick(View v) { + // Since the fragment view fills the entire screen, any clicks outside of the history + // ListView will end up here. + dismiss(); + } + + @Override + public void onPause() { + super.onPause(); + dismiss(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + dismiss(); + + GeckoApplication.watchReference(getActivity(), this); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + if (backStackId >= 0) { + outState.putInt(BACK_STACK_ID, backStackId); + } + } + + // Function to add this fragment to activity state with containerViewId as parent. + // This similar in functionality to DialogFragment.show() except that containerId is provided here. + public void show(final int containerViewId, final FragmentTransaction transaction, final String tag) { + dismissed = false; + transaction.add(containerViewId, this, tag); + transaction.addToBackStack(tag); + // Populating the tab history requires a gecko call (which can be slow) - therefore the app + // state by the time we try to show this fragment is unknown, and we could be in the + // middle of shutting down: + backStackId = transaction.commitAllowingStateLoss(); + } + + // Pop the fragment from backstack if it exists. + public void dismiss() { + if (dismissed) { + return; + } + + dismissed = true; + + if (backStackId >= 0) { + getFragmentManager().popBackStackImmediate(backStackId, FragmentManager.POP_BACK_STACK_INCLUSIVE); + backStackId = -1; + } + + if (parent != null) { + parent.setVisibility(View.GONE); + } + } + + private static class TabHistoryAdapter extends ArrayAdapter<TabHistoryPage> { + private final List<TabHistoryPage> pages; + private final Context context; + + public TabHistoryAdapter(Context context, List<TabHistoryPage> pages) { + super(context, R.layout.tab_history_item_row, pages); + this.context = context; + this.pages = pages; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + TabHistoryItemRow row = (TabHistoryItemRow) convertView; + if (row == null) { + row = new TabHistoryItemRow(context, null); + } + + row.update(pages.get(position), position == 0, position == pages.size() - 1); + return row; + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryItemRow.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryItemRow.java new file mode 100644 index 000000000..112dbc07d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryItemRow.java @@ -0,0 +1,69 @@ +/* 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.tabs; + +import android.content.Context; +import android.graphics.Typeface; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.icons.IconResponse; +import org.mozilla.gecko.icons.Icons; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.widget.FaviconView; + +import java.util.concurrent.Future; + +public class TabHistoryItemRow extends RelativeLayout { + private final FaviconView favicon; + private final TextView title; + private final ImageView timeLineTop; + private final ImageView timeLineBottom; + private Future<IconResponse> ongoingIconLoad; + + public TabHistoryItemRow(Context context, AttributeSet attrs) { + super(context, attrs); + LayoutInflater.from(context).inflate(R.layout.tab_history_item_row, this); + favicon = (FaviconView) findViewById(R.id.tab_history_icon); + title = (TextView) findViewById(R.id.tab_history_title); + timeLineTop = (ImageView) findViewById(R.id.tab_history_timeline_top); + timeLineBottom = (ImageView) findViewById(R.id.tab_history_timeline_bottom); + } + + // Update the views with historic page detail. + public void update(final TabHistoryPage historyPage, boolean isFirstElement, boolean isLastElement) { + ThreadUtils.assertOnUiThread(); + + timeLineTop.setVisibility(isFirstElement ? View.INVISIBLE : View.VISIBLE); + timeLineBottom.setVisibility(isLastElement ? View.INVISIBLE : View.VISIBLE); + title.setText(historyPage.getTitle()); + + if (historyPage.isSelected()) { + // Highlight title with bold font. + title.setTypeface(null, Typeface.BOLD); + } else { + // Clear previously set bold font. + title.setTypeface(null, Typeface.NORMAL); + } + + favicon.setEnabled(historyPage.isSelected()); + favicon.clearImage(); + + if (ongoingIconLoad != null) { + ongoingIconLoad.cancel(true); + } + + ongoingIconLoad = Icons.with(getContext()) + .pageUrl(historyPage.getUrl()) + .skipNetwork() + .build() + .execute(favicon.createIconCallback()); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryPage.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryPage.java new file mode 100644 index 000000000..6c608b2ac --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabHistoryPage.java @@ -0,0 +1,60 @@ +/* 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.tabs; + +import android.os.Parcel; +import android.os.Parcelable; + +public class TabHistoryPage implements Parcelable { + private final String title; + private final String url; + private final boolean selected; + + public TabHistoryPage(String title, String url, boolean selected) { + this.title = title; + this.url = url; + this.selected = selected; + } + + public String getTitle() { + return title; + } + + public String getUrl() { + return url; + } + + public boolean isSelected() { + return selected; + } + + @Override + public int describeContents() { + return 0; + } + + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(title); + dest.writeString(url); + dest.writeInt(selected ? 1 : 0); + } + + public static final Parcelable.Creator<TabHistoryPage> CREATOR = new Parcelable.Creator<TabHistoryPage>() { + @Override + public TabHistoryPage createFromParcel(final Parcel source) { + final String title = source.readString(); + final String url = source.readString(); + final boolean selected = source.readByte() != 0; + + final TabHistoryPage page = new TabHistoryPage(title, url, selected); + return page; + } + + @Override + public TabHistoryPage[] newArray(int size) { + return new TabHistoryPage[size]; + } + }; +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabPanelBackButton.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabPanelBackButton.java new file mode 100644 index 000000000..7ea02407e --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabPanelBackButton.java @@ -0,0 +1,55 @@ +/* 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.tabs; + +import org.mozilla.gecko.R; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.ViewGroup; +import android.widget.ImageButton; + +public class TabPanelBackButton extends ImageButton { + + private int dividerWidth = 0; + + private final Drawable divider; + private final int dividerPadding; + + public TabPanelBackButton(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabPanelBackButton); + divider = a.getDrawable(R.styleable.TabPanelBackButton_rightDivider); + dividerPadding = (int) a.getDimension(R.styleable.TabPanelBackButton_dividerVerticalPadding, 0); + a.recycle(); + + if (divider != null) { + dividerWidth = divider.getIntrinsicWidth(); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + setMeasuredDimension(getMeasuredWidth() + dividerWidth, getMeasuredHeight()); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (divider != null) { + final ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) getLayoutParams(); + final int left = getRight() - lp.rightMargin - dividerWidth; + + divider.setBounds(left, getPaddingTop() + dividerPadding, + left + dividerWidth, getHeight() - getPaddingBottom() - dividerPadding); + divider.draw(canvas); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabStrip.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStrip.java new file mode 100644 index 000000000..5d3719343 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStrip.java @@ -0,0 +1,170 @@ +/* -*- 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.tabs; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.StateListDrawable; +import android.graphics.Rect; +import android.support.v4.content.ContextCompat; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.TouchDelegate; +import android.view.View; +import android.view.ViewTreeObserver; + +import org.mozilla.gecko.BrowserApp.TabStripInterface; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.widget.themed.ThemedImageButton; +import org.mozilla.gecko.widget.themed.ThemedLinearLayout; + +public class TabStrip extends ThemedLinearLayout + implements TabStripInterface { + private static final String LOGTAG = "GeckoTabStrip"; + + private final TabStripView tabStripView; + private final ThemedImageButton addTabButton; + + private final TabsListener tabsListener; + private OnTabAddedOrRemovedListener tabChangedListener; + + public TabStrip(Context context) { + this(context, null); + } + + public TabStrip(Context context, AttributeSet attrs) { + super(context, attrs); + setOrientation(HORIZONTAL); + + LayoutInflater.from(context).inflate(R.layout.tab_strip_inner, this); + tabStripView = (TabStripView) findViewById(R.id.tab_strip); + + addTabButton = (ThemedImageButton) findViewById(R.id.add_tab); + addTabButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + final Tabs tabs = Tabs.getInstance(); + if (isPrivateMode()) { + tabs.addPrivateTab(); + } else { + tabs.addTab(); + } + } + }); + + getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + + final Rect r = new Rect(); + r.left = addTabButton.getRight(); + r.right = getWidth(); + r.top = 0; + r.bottom = getHeight(); + + // Redirect touch events between the 'new tab' button and the edge + // of the screen to the 'new tab' button. + setTouchDelegate(new TouchDelegate(r, addTabButton)); + + return true; + } + }); + + tabsListener = new TabsListener(); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + + Tabs.registerOnTabsChangedListener(tabsListener); + tabStripView.refreshTabs(); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + Tabs.unregisterOnTabsChangedListener(tabsListener); + tabStripView.clearTabs(); + } + + @Override + public void setPrivateMode(boolean isPrivate) { + super.setPrivateMode(isPrivate); + addTabButton.setPrivateMode(isPrivate); + } + + public void setOnTabChangedListener(OnTabAddedOrRemovedListener listener) { + tabChangedListener = listener; + } + + private class TabsListener implements Tabs.OnTabsChangedListener { + @Override + public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) { + switch (msg) { + case RESTORED: + tabStripView.restoreTabs(); + break; + + case ADDED: + tabStripView.addTab(tab); + if (tabChangedListener != null) { + tabChangedListener.onTabChanged(); + } + break; + + case CLOSED: + tabStripView.removeTab(tab); + if (tabChangedListener != null) { + tabChangedListener.onTabChanged(); + } + break; + + case SELECTED: + // Update the selected position, then fall through... + tabStripView.selectTab(tab); + setPrivateMode(tab.isPrivate()); + case UNSELECTED: + // We just need to update the style for the unselected tab... + case TITLE: + case FAVICON: + case RECORDING_CHANGE: + case AUDIO_PLAYING_CHANGE: + tabStripView.updateTab(tab); + break; + } + } + } + + @Override + public void refresh() { + tabStripView.refresh(); + } + + @Override + public void onLightweightThemeChanged() { + final Drawable drawable = getTheme().getDrawable(this); + if (drawable == null) { + return; + } + + final StateListDrawable stateList = new StateListDrawable(); + stateList.addState(PRIVATE_STATE_SET, getColorDrawable(R.color.text_and_tabs_tray_grey)); + stateList.addState(EMPTY_STATE_SET, drawable); + + setBackgroundDrawable(stateList); + } + + @Override + public void onLightweightThemeReset() { + final int defaultBackgroundColor = ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey); + setBackgroundColor(defaultBackgroundColor); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripAdapter.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripAdapter.java new file mode 100644 index 000000000..8778aac31 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripAdapter.java @@ -0,0 +1,98 @@ +/* -*- 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.tabs; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; + +import java.util.ArrayList; +import java.util.List; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; + +class TabStripAdapter extends BaseAdapter { + private static final String LOGTAG = "GeckoTabStripAdapter"; + + private final Context context; + private List<Tab> tabs; + + public TabStripAdapter(Context context) { + this.context = context; + } + + @Override + public Tab getItem(int position) { + return (tabs != null && + position >= 0 && + position < tabs.size() ? tabs.get(position) : null); + } + + @Override + public long getItemId(int position) { + final Tab tab = getItem(position); + return (tab != null ? tab.getId() : -1); + } + + @Override + public boolean hasStableIds() { + return true; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + final TabStripItemView item; + if (convertView == null) { + item = (TabStripItemView) + LayoutInflater.from(context).inflate(R.layout.tab_strip_item, parent, false); + } else { + item = (TabStripItemView) convertView; + } + + final Tab tab = tabs.get(position); + item.updateFromTab(tab); + + return item; + } + + @Override + public int getCount() { + return (tabs != null ? tabs.size() : 0); + } + + int getPositionForTab(Tab tab) { + if (tabs == null || tab == null) { + return -1; + } + + return tabs.indexOf(tab); + } + + void removeTab(Tab tab) { + if (tabs == null) { + return; + } + + tabs.remove(tab); + notifyDataSetChanged(); + } + + void refresh(List<Tab> tabs) { + // The list of tabs is guaranteed to be non-null. + // See TabStripView.refreshTabs(). + this.tabs = tabs; + notifyDataSetChanged(); + } + + void clear() { + tabs = null; + notifyDataSetInvalidated(); + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripItemView.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripItemView.java new file mode 100644 index 000000000..27eaed125 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripItemView.java @@ -0,0 +1,254 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.AboutPages; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.widget.ResizablePathDrawable; +import org.mozilla.gecko.widget.ResizablePathDrawable.NonScaledPathShape; +import org.mozilla.gecko.widget.themed.ThemedImageButton; +import org.mozilla.gecko.widget.themed.ThemedLinearLayout; +import org.mozilla.gecko.widget.themed.ThemedTextView; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Path; +import android.graphics.Region; +import android.util.AttributeSet; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.Checkable; +import android.widget.ImageView; + +public class TabStripItemView extends ThemedLinearLayout + implements Checkable { + private static final String LOGTAG = "GeckoTabStripItem"; + + private static final int[] STATE_CHECKED = { + android.R.attr.state_checked + }; + + private int id = -1; + private boolean checked; + + private final ImageView faviconView; + private final ThemedTextView titleView; + private final ThemedImageButton closeView; + + private final ResizablePathDrawable backgroundDrawable; + private final Region tabRegion; + private final Region tabClipRegion; + private boolean tabRegionNeedsUpdate; + + private final int faviconSize; + private Bitmap lastFavicon; + + public TabStripItemView(Context context) { + this(context, null); + } + + public TabStripItemView(Context context, AttributeSet attrs) { + super(context, attrs); + setOrientation(HORIZONTAL); + + tabRegion = new Region(); + tabClipRegion = new Region(); + + final Resources res = context.getResources(); + + final ColorStateList tabColors = + res.getColorStateList(R.color.tab_strip_item_bg); + backgroundDrawable = new ResizablePathDrawable(new TabCurveShape(), tabColors); + setBackgroundDrawable(backgroundDrawable); + + faviconSize = res.getDimensionPixelSize(R.dimen.browser_toolbar_favicon_size); + + LayoutInflater.from(context).inflate(R.layout.tab_strip_item_view, this); + setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (id < 0) { + throw new IllegalStateException("Invalid tab id:" + id); + } + + Tabs.getInstance().selectTab(id); + } + }); + + faviconView = (ImageView) findViewById(R.id.favicon); + titleView = (ThemedTextView) findViewById(R.id.title); + + closeView = (ThemedImageButton) findViewById(R.id.close); + closeView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (id < 0) { + throw new IllegalStateException("Invalid tab id:" + id); + } + + final Tabs tabs = Tabs.getInstance(); + tabs.closeTab(tabs.getTab(id), true); + } + }); + } + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + + // Queue a tab region update in the next draw() call. We don't + // update it immediately here because we need the new path from + // the background drawable to be updated first. + tabRegionNeedsUpdate = true; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + final int action = event.getActionMasked(); + final int x = (int) event.getX(); + final int y = (int) event.getY(); + + // Let motion events through if they're off the tab shape bounds. + if (action == MotionEvent.ACTION_DOWN && !tabRegion.contains(x, y)) { + return false; + } + + return super.onTouchEvent(event); + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + + if (tabRegionNeedsUpdate) { + final Path path = backgroundDrawable.getPath(); + tabClipRegion.set(0, 0, getWidth(), getHeight()); + tabRegion.setPath(path, tabClipRegion); + tabRegionNeedsUpdate = false; + } + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (checked) { + mergeDrawableStates(drawableState, STATE_CHECKED); + } + + return drawableState; + } + + @Override + public boolean isChecked() { + return checked; + } + + @Override + public void setChecked(boolean checked) { + if (this.checked == checked) { + return; + } + + this.checked = checked; + refreshDrawableState(); + } + + @Override + public void toggle() { + setChecked(!checked); + } + + @Override + public void setPressed(boolean pressed) { + super.setPressed(pressed); + + // The surrounding tab strip dividers need to be hidden + // when a tab item enters pressed state. + View parent = (View) getParent(); + if (parent != null) { + parent.invalidate(); + } + } + + void updateFromTab(Tab tab) { + if (tab == null) { + return; + } + + id = tab.getId(); + + updateTitle(tab); + updateFavicon(tab.getFavicon()); + setPrivateMode(tab.isPrivate()); + } + + private void updateTitle(Tab tab) { + final String title; + + // Avoid flickering the about:home URL on every load given how often + // this page is used in the UI. + if (AboutPages.isAboutHome(tab.getURL())) { + titleView.setText(R.string.home_title); + } else { + titleView.setText(tab.getDisplayTitle()); + } + + // TODO: Set content description to indicate audio is playing. + if (tab.isAudioPlaying()) { + titleView.setCompoundDrawablesWithIntrinsicBounds(R.drawable.tab_audio_playing, 0, 0, 0); + } else { + titleView.setCompoundDrawables(null, null, null, null); + } + } + + private void updateFavicon(final Bitmap favicon) { + if (favicon == null) { + lastFavicon = null; + faviconView.setImageResource(R.drawable.toolbar_favicon_default); + return; + } + if (favicon == lastFavicon) { + return; + } + + // Cache the original so we can debounce without scaling. + lastFavicon = favicon; + + final Bitmap scaledFavicon = + Bitmap.createScaledBitmap(favicon, faviconSize, faviconSize, false); + faviconView.setImageBitmap(scaledFavicon); + } + + private static class TabCurveShape extends NonScaledPathShape { + @Override + protected void onResize(float width, float height) { + final Path path = getPath(); + + path.reset(); + + final float curveWidth = TabCurve.getWidthForHeight(height); + + path.moveTo(0, height); + TabCurve.drawFromBottom(path, 0, height, TabCurve.Direction.RIGHT); + path.lineTo(width - curveWidth, 0); + + TabCurve.drawFromTop(path, width - curveWidth, height, TabCurve.Direction.RIGHT); + path.lineTo(0, height); + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripView.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripView.java new file mode 100644 index 000000000..f3ec19cef --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabStripView.java @@ -0,0 +1,449 @@ +/* -*- 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.tabs; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.animation.DecelerateInterpolator; +import android.view.View; +import android.view.ViewTreeObserver.OnPreDrawListener; + +import android.animation.Animator; +import android.animation.Animator.AnimatorListener; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; + +import java.util.ArrayList; +import java.util.List; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.widget.TwoWayView; + +public class TabStripView extends TwoWayView { + private static final String LOGTAG = "GeckoTabStrip"; + + private static final int ANIM_TIME_MS = 200; + private static final DecelerateInterpolator ANIM_INTERPOLATOR = + new DecelerateInterpolator(); + + private final TabStripAdapter adapter; + private final Drawable divider; + + private final TabAnimatorListener animatorListener; + + private boolean isRestoringTabs; + + // Filled by calls to ShapeDrawable.getPadding(); + // saved to prevent allocation in draw(). + private final Rect dividerPadding = new Rect(); + + private boolean isPrivate; + + private final Paint fadingEdgePaint; + private final int fadingEdgeSize; + + public TabStripView(Context context, AttributeSet attrs) { + super(context, attrs); + + setOrientation(Orientation.HORIZONTAL); + setChoiceMode(ChoiceMode.SINGLE); + setItemsCanFocus(true); + setChildrenDrawingOrderEnabled(true); + setWillNotDraw(false); + + final Resources resources = getResources(); + + divider = resources.getDrawable(R.drawable.tab_strip_divider); + divider.getPadding(dividerPadding); + + final int itemMargin = + resources.getDimensionPixelSize(R.dimen.tablet_tab_strip_item_margin); + setItemMargin(itemMargin); + + animatorListener = new TabAnimatorListener(); + + fadingEdgePaint = new Paint(); + fadingEdgeSize = + resources.getDimensionPixelOffset(R.dimen.tablet_tab_strip_fading_edge_size); + + adapter = new TabStripAdapter(context); + setAdapter(adapter); + } + + private View getViewForTab(Tab tab) { + final int position = adapter.getPositionForTab(tab); + return getChildAt(position - getFirstVisiblePosition()); + } + + private int getPositionForSelectedTab() { + return adapter.getPositionForTab(Tabs.getInstance().getSelectedTab()); + } + + private void updateSelectedStyle(int selected) { + setItemChecked(selected, true); + } + + private void updateSelectedPosition(boolean ensureVisible) { + final int selected = getPositionForSelectedTab(); + if (selected != -1) { + updateSelectedStyle(selected); + + if (ensureVisible) { + ensurePositionIsVisible(selected, true); + } + } + } + + private void animateRemoveTab(Tab removedTab) { + final int removedPosition = adapter.getPositionForTab(removedTab); + + final View removedView = getViewForTab(removedTab); + + // The removed position might not have a matching child view + // when it's not within the visible range of positions in the strip. + if (removedView == null) { + return; + } + + // We don't animate the removed child view (it just disappears) + // but we still need its size of animate all affected children + // within the visible viewport. + final int removedSize = removedView.getWidth() + getItemMargin(); + + getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + + final int firstPosition = getFirstVisiblePosition(); + final List<Animator> childAnimators = new ArrayList<Animator>(); + + final int childCount = getChildCount(); + for (int i = removedPosition - firstPosition; i < childCount; i++) { + final View child = getChildAt(i); + + final ObjectAnimator animator = + ObjectAnimator.ofFloat(child, "translationX", removedSize, 0); + childAnimators.add(animator); + } + + final AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(childAnimators); + animatorSet.setDuration(ANIM_TIME_MS); + animatorSet.setInterpolator(ANIM_INTERPOLATOR); + animatorSet.addListener(animatorListener); + + animatorSet.start(); + + return true; + } + }); + } + + private void animateNewTab(Tab newTab) { + final int newPosition = adapter.getPositionForTab(newTab); + if (newPosition < 0) { + return; + } + + getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + + final int firstPosition = getFirstVisiblePosition(); + + final View newChild = getChildAt(newPosition - firstPosition); + if (newChild == null) { + return true; + } + + final List<Animator> childAnimators = new ArrayList<Animator>(); + childAnimators.add( + ObjectAnimator.ofFloat(newChild, "translationY", newChild.getHeight(), 0)); + + // This will momentaneously add a gap on the right side + // because TwoWayView doesn't provide APIs to control + // view recycling programatically to handle these transitory + // states in the container during animations. + + final int tabSize = newChild.getWidth(); + final int newIndex = newPosition - firstPosition; + final int childCount = getChildCount(); + for (int i = newIndex + 1; i < childCount; i++) { + final View child = getChildAt(i); + + childAnimators.add( + ObjectAnimator.ofFloat(child, "translationX", -tabSize, 0)); + } + + final AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(childAnimators); + animatorSet.setDuration(ANIM_TIME_MS); + animatorSet.setInterpolator(ANIM_INTERPOLATOR); + animatorSet.addListener(animatorListener); + + animatorSet.start(); + + return true; + } + }); + } + + private void animateRestoredTabs() { + getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + + final List<Animator> childAnimators = new ArrayList<Animator>(); + + final int tabHeight = getHeight() - getPaddingTop() - getPaddingBottom(); + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = getChildAt(i); + + childAnimators.add( + ObjectAnimator.ofFloat(child, "translationY", tabHeight, 0)); + } + + final AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(childAnimators); + animatorSet.setDuration(ANIM_TIME_MS); + animatorSet.setInterpolator(ANIM_INTERPOLATOR); + animatorSet.addListener(animatorListener); + + animatorSet.start(); + + return true; + } + }); + } + + /** + * Ensures the tab at the given position is visible. If we are not restoring tabs and + * shouldAnimate == true, the tab will animate to be visible, if it is not already visible. + */ + private void ensurePositionIsVisible(final int position, final boolean shouldAnimate) { + // We just want to move the strip to the right position + // when restoring tabs on startup. + if (isRestoringTabs || !shouldAnimate) { + setSelection(position); + return; + } + + getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + smoothScrollToPosition(position); + return true; + } + }); + } + + private int getCheckedIndex(int childCount) { + final int checkedIndex = getCheckedItemPosition() - getFirstVisiblePosition(); + if (checkedIndex < 0 || checkedIndex > childCount - 1) { + return INVALID_POSITION; + } + + return checkedIndex; + } + + void refreshTabs() { + // Store a different copy of the tabs, so that we don't have + // to worry about accidentally updating it on the wrong thread. + final List<Tab> tabs = new ArrayList<Tab>(); + + for (Tab tab : Tabs.getInstance().getTabsInOrder()) { + if (tab.isPrivate() == isPrivate) { + tabs.add(tab); + } + } + + adapter.refresh(tabs); + updateSelectedPosition(true); + } + + void clearTabs() { + adapter.clear(); + } + + void restoreTabs() { + isRestoringTabs = true; + refreshTabs(); + animateRestoredTabs(); + isRestoringTabs = false; + } + + void addTab(Tab tab) { + // Refresh the list to make sure the new tab is + // added in the right position. + refreshTabs(); + animateNewTab(tab); + } + + void removeTab(Tab tab) { + animateRemoveTab(tab); + adapter.removeTab(tab); + updateSelectedPosition(false); + } + + void selectTab(Tab tab) { + if (tab.isPrivate() != isPrivate) { + isPrivate = tab.isPrivate(); + refreshTabs(); + } else { + updateSelectedPosition(true); + } + } + + void updateTab(Tab tab) { + final TabStripItemView item = (TabStripItemView) getViewForTab(tab); + if (item != null) { + item.updateFromTab(tab); + } + } + + private float getFadingEdgeStrength() { + final int childCount = getChildCount(); + if (childCount == 0) { + return 0.0f; + } else { + if (getFirstVisiblePosition() + childCount - 1 < adapter.getCount() - 1) { + return 1.0f; + } + + final int right = getChildAt(childCount - 1).getRight(); + final int paddingRight = getPaddingRight(); + final int width = getWidth(); + + final float strength = (right > width - paddingRight ? + (float) (right - width + paddingRight) / fadingEdgeSize : 0.0f); + + return Math.max(0.0f, Math.min(strength, 1.0f)); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + fadingEdgePaint.setShader(new LinearGradient(w - fadingEdgeSize, 0, w, 0, + new int[] { 0x0, 0x11292C29, 0xDD292C29 }, + new float[] { 0, 0.4f, 1.0f }, Shader.TileMode.CLAMP)); + } + + @Override + protected int getChildDrawingOrder(int childCount, int i) { + final int checkedIndex = getCheckedIndex(childCount); + if (checkedIndex == INVALID_POSITION) { + return i; + } + + // Always draw the currently selected tab on top of all + // other child views so that its curve is fully visible. + if (i == childCount - 1) { + return checkedIndex; + } else if (checkedIndex <= i) { + return i + 1; + } else { + return i; + } + } + + private void drawDividers(Canvas canvas) { + final int bottom = getHeight() - getPaddingBottom() - dividerPadding.bottom; + final int top = bottom - divider.getIntrinsicHeight(); + + final int dividerWidth = divider.getIntrinsicWidth(); + final int itemMargin = getItemMargin(); + + final int childCount = getChildCount(); + final int checkedIndex = getCheckedIndex(childCount); + + for (int i = 1; i < childCount; i++) { + final View child = getChildAt(i); + + final boolean pressed = (child.isPressed() || getChildAt(i - 1).isPressed()); + final boolean checked = (i == checkedIndex || i == checkedIndex + 1); + + // Don't draw dividers for around checked or pressed items + // so that they are not drawn on top of the tab curves. + if (pressed || checked) { + continue; + } + + final int left = child.getLeft() - (itemMargin / 2) - dividerWidth; + final int right = left + dividerWidth; + + divider.setBounds(left, top, right, bottom); + divider.draw(canvas); + } + } + + private void drawFadingEdge(Canvas canvas) { + final float strength = getFadingEdgeStrength(); + if (strength > 0.0f) { + final int r = getRight(); + canvas.drawRect(r - fadingEdgeSize, getTop(), r, getBottom(), fadingEdgePaint); + fadingEdgePaint.setAlpha((int) (strength * 255)); + } + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + drawDividers(canvas); + drawFadingEdge(canvas); + } + + public void refresh() { + final int selectedPosition = getPositionForSelectedTab(); + if (selectedPosition != -1) { + ensurePositionIsVisible(selectedPosition, false); + } + } + + private class TabAnimatorListener implements AnimatorListener { + private void setLayerType(int layerType) { + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + getChildAt(i).setLayerType(layerType, null); + } + } + + @Override + public void onAnimationStart(Animator animation) { + setLayerType(View.LAYER_TYPE_HARDWARE); + } + + @Override + public void onAnimationEnd(Animator animation) { + // This method is called even if the animator is canceled. + setLayerType(View.LAYER_TYPE_NONE); + } + + @Override + public void onAnimationRepeat(Animator animation) { + } + + @Override + public void onAnimationCancel(Animator animation) { + } + + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsGridLayout.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsGridLayout.java new file mode 100644 index 000000000..ead7db9fe --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsGridLayout.java @@ -0,0 +1,712 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.tabs.TabsPanel.TabsLayout; +import org.mozilla.gecko.widget.themed.ThemedRelativeLayout; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.PointF; +import android.graphics.Rect; +import android.os.Build; +import android.util.AttributeSet; +import android.util.SparseArray; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.animation.DecelerateInterpolator; +import android.widget.AbsListView; +import android.widget.AdapterView; +import android.widget.Button; +import android.widget.GridView; +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.animation.ValueAnimator; + +import java.util.ArrayList; +import java.util.List; + +/** + * A tabs layout implementation for the tablet redesign (bug 1014156) and later ported to mobile (bug 1193745). + */ + +class TabsGridLayout extends GridView + implements TabsLayout, + Tabs.OnTabsChangedListener { + + private static final String LOGTAG = "Gecko" + TabsGridLayout.class.getSimpleName(); + + public static final int ANIM_DELAY_MULTIPLE_MS = 20; + private static final int ANIM_TIME_MS = 200; + private static final DecelerateInterpolator ANIM_INTERPOLATOR = new DecelerateInterpolator(); + + private final SparseArray<PointF> tabLocations = new SparseArray<PointF>(); + private final boolean isPrivate; + private final TabsLayoutAdapter tabsAdapter; + private final int columnWidth; + private TabsPanel tabsPanel; + private int lastSelectedTabId; + + public TabsGridLayout(final Context context, final AttributeSet attrs) { + super(context, attrs, R.attr.tabGridLayoutViewStyle); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabsLayout); + isPrivate = (a.getInt(R.styleable.TabsLayout_tabs, 0x0) == 1); + a.recycle(); + + tabsAdapter = new TabsGridLayoutAdapter(context); + setAdapter(tabsAdapter); + + setRecyclerListener(new RecyclerListener() { + @Override + public void onMovedToScrapHeap(View view) { + TabsLayoutItemView item = (TabsLayoutItemView) view; + item.setThumbnail(null); + } + }); + + // The clipToPadding setting in the styles.xml doesn't seem to be working (bug 1101784) + // so lets set it manually in code for the moment as it's needed for the padding animation + setClipToPadding(false); + + setVerticalFadingEdgeEnabled(false); + + final Resources resources = getResources(); + columnWidth = resources.getDimensionPixelSize(R.dimen.tab_panel_column_width); + + final int padding = resources.getDimensionPixelSize(R.dimen.tab_panel_grid_padding); + final int paddingTop = resources.getDimensionPixelSize(R.dimen.tab_panel_grid_padding_top); + + // Lets set double the top padding on the bottom so that the last row shows up properly! + // Your demise, GridView, cannot come fast enough. + final int paddingBottom = paddingTop * 2; + + setPadding(padding, paddingTop, padding, paddingBottom); + + setOnItemClickListener(new OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + final TabsLayoutItemView tabView = (TabsLayoutItemView) view; + final int tabId = tabView.getTabId(); + final Tab tab = Tabs.getInstance().selectTab(tabId); + if (tab == null) { + return; + } + autoHidePanel(); + Tabs.getInstance().notifyListeners(tab, Tabs.TabEvents.OPENED_FROM_TABS_TRAY); + } + }); + + TabSwipeGestureListener mSwipeListener = new TabSwipeGestureListener(); + setOnTouchListener(mSwipeListener); + setOnScrollListener(mSwipeListener.makeScrollListener()); + } + + private void populateTabLocations(final Tab removedTab) { + tabLocations.clear(); + + final int firstPosition = getFirstVisiblePosition(); + final int lastPosition = getLastVisiblePosition(); + final int numberOfColumns = getNumColumns(); + final int childCount = getChildCount(); + final int removedPosition = tabsAdapter.getPositionForTab(removedTab); + + for (int x = 1, i = (removedPosition - firstPosition) + 1; i < childCount; i++, x++) { + final View child = getChildAt(i); + if (child != null) { + // Reset the transformations here in case the user is swiping tabs away fast and they swipe a tab + // before the last animation has finished (bug 1179195). + resetTransforms(child); + + tabLocations.append(x, new PointF(child.getX(), child.getY())); + } + } + + final boolean firstChildOffScreen = ((firstPosition > 0) || getChildAt(0).getY() < 0); + final boolean lastChildVisible = (lastPosition - childCount == firstPosition - 1); + final boolean oneItemOnLastRow = (lastPosition % numberOfColumns == 0); + if (firstChildOffScreen && lastChildVisible && oneItemOnLastRow) { + // We need to set the view's bottom padding to prevent a sudden jump as the + // last item in the row is being removed. We then need to remove the padding + // via a sweet animation + + final int removedHeight = getChildAt(0).getMeasuredHeight(); + final int verticalSpacing = + getResources().getDimensionPixelOffset(R.dimen.tab_panel_grid_vspacing); + + ValueAnimator paddingAnimator = ValueAnimator.ofInt(getPaddingBottom() + removedHeight + verticalSpacing, getPaddingBottom()); + paddingAnimator.setDuration(ANIM_TIME_MS * 2); + + paddingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + + @Override + public void onAnimationUpdate(ValueAnimator animation) { + setPadding(getPaddingLeft(), getPaddingTop(), getPaddingRight(), (Integer) animation.getAnimatedValue()); + } + }); + paddingAnimator.start(); + } + } + + @Override + public void setTabsPanel(TabsPanel panel) { + tabsPanel = panel; + } + + @Override + public void show() { + setVisibility(View.VISIBLE); + Tabs.getInstance().refreshThumbnails(); + Tabs.registerOnTabsChangedListener(this); + refreshTabsData(); + + final Tab currentlySelectedTab = Tabs.getInstance().getSelectedTab(); + final int position = currentlySelectedTab != null ? tabsAdapter.getPositionForTab(currentlySelectedTab) : -1; + if (position != -1) { + final boolean selectionChanged = lastSelectedTabId != currentlySelectedTab.getId(); + final boolean positionIsVisible = position >= getFirstVisiblePosition() && position <= getLastVisiblePosition(); + + if (selectionChanged || !positionIsVisible) { + smoothScrollToPosition(position); + } + } + } + + @Override + public void hide() { + lastSelectedTabId = Tabs.getInstance().getSelectedTab().getId(); + setVisibility(View.GONE); + Tabs.unregisterOnTabsChangedListener(this); + GeckoAppShell.notifyObservers("Tab:Screenshot:Cancel", ""); + tabsAdapter.clear(); + } + + @Override + public boolean shouldExpand() { + return true; + } + + private void autoHidePanel() { + tabsPanel.autoHidePanel(); + } + + @Override + public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) { + switch (msg) { + case ADDED: + // Refresh only if panel is shown. show() will call refreshTabsData() later again. + if (tabsPanel.isShown()) { + // Refresh the list to make sure the new tab is added in the right position. + refreshTabsData(); + } + break; + + case CLOSED: + + // This is limited to >= ICS as animations on GB devices are generally pants + if (Build.VERSION.SDK_INT >= 11 && tabsAdapter.getCount() > 0) { + animateRemoveTab(tab); + } + + final Tabs tabsInstance = Tabs.getInstance(); + + if (tabsAdapter.removeTab(tab)) { + if (tab.isPrivate() == isPrivate && tabsAdapter.getCount() > 0) { + int selected = tabsAdapter.getPositionForTab(tabsInstance.getSelectedTab()); + updateSelectedStyle(selected); + } + if (!tab.isPrivate()) { + // Make sure we always have at least one normal tab + final Iterable<Tab> tabs = tabsInstance.getTabsInOrder(); + boolean removedTabIsLastNormalTab = true; + for (Tab singleTab : tabs) { + if (!singleTab.isPrivate()) { + removedTabIsLastNormalTab = false; + break; + } + } + if (removedTabIsLastNormalTab) { + tabsInstance.addTab(); + } + } + } + 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: + case RECORDING_CHANGE: + case AUDIO_PLAYING_CHANGE: + View view = getChildAt(tabsAdapter.getPositionForTab(tab) - getFirstVisiblePosition()); + if (view == null) + return; + + ((TabsLayoutItemView) view).assignValues(tab); + break; + } + } + + // Updates the selected position in the list so that it will be scrolled to the right place. + private void updateSelectedPosition() { + int selected = tabsAdapter.getPositionForTab(Tabs.getInstance().getSelectedTab()); + updateSelectedStyle(selected); + + if (selected != -1) { + setSelection(selected); + } + } + + /** + * Updates the selected/unselected style for the tabs. + * + * @param selected position of the selected tab + */ + private void updateSelectedStyle(final int selected) { + post(new Runnable() { + @Override + public void run() { + final int displayCount = tabsAdapter.getCount(); + + for (int i = 0; i < displayCount; i++) { + final Tab tab = tabsAdapter.getItem(i); + final boolean checked = displayCount == 1 || i == selected; + final View tabView = getViewForTab(tab); + if (tabView != null) { + ((TabsLayoutItemView) tabView).setChecked(checked); + } + // setItemChecked doesn't exist until API 11, despite what the API docs say! + setItemChecked(i, checked); + } + } + }); + } + + 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. + ArrayList<Tab> tabData = new ArrayList<>(); + + Iterable<Tab> allTabs = Tabs.getInstance().getTabsInOrder(); + for (Tab tab : allTabs) { + if (tab.isPrivate() == isPrivate) + tabData.add(tab); + } + + tabsAdapter.setTabs(tabData); + updateSelectedPosition(); + } + + private void resetTransforms(View view) { + view.setAlpha(1); + view.setTranslationX(0); + view.setTranslationY(0); + + ((TabsLayoutItemView) view).setCloseVisible(true); + } + + @Override + public void closeAll() { + + autoHidePanel(); + + if (getChildCount() == 0) { + return; + } + + final Iterable<Tab> tabs = Tabs.getInstance().getTabsInOrder(); + for (Tab tab : tabs) { + // In the normal panel we want to close all tabs (both private and normal), + // but in the private panel we only want to close private tabs. + if (!isPrivate || tab.isPrivate()) { + Tabs.getInstance().closeTab(tab, false); + } + } + } + + private View getViewForTab(Tab tab) { + final int position = tabsAdapter.getPositionForTab(tab); + return getChildAt(position - getFirstVisiblePosition()); + } + + void closeTab(View v) { + if (tabsAdapter.getCount() == 1) { + autoHidePanel(); + } + + TabsLayoutItemView itemView = (TabsLayoutItemView) v.getTag(); + Tab tab = Tabs.getInstance().getTab(itemView.getTabId()); + + Tabs.getInstance().closeTab(tab, true); + } + + private void animateRemoveTab(final Tab removedTab) { + final int removedPosition = tabsAdapter.getPositionForTab(removedTab); + + final View removedView = getViewForTab(removedTab); + + // The removed position might not have a matching child view + // when it's not within the visible range of positions in the strip. + if (removedView == null) { + return; + } + final int removedHeight = removedView.getMeasuredHeight(); + + populateTabLocations(removedTab); + + getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + // We don't animate the removed child view (it just disappears) + // but we still need its size to animate all affected children + // within the visible viewport. + final int childCount = getChildCount(); + final int firstPosition = getFirstVisiblePosition(); + final int numberOfColumns = getNumColumns(); + + final List<Animator> childAnimators = new ArrayList<>(); + + PropertyValuesHolder translateX, translateY; + for (int x = 0, i = removedPosition - firstPosition; i < childCount; i++, x++) { + final View child = getChildAt(i); + ObjectAnimator animator; + + if (i % numberOfColumns == numberOfColumns - 1) { + // Animate X & Y + translateX = PropertyValuesHolder.ofFloat("translationX", -(columnWidth * numberOfColumns), 0); + translateY = PropertyValuesHolder.ofFloat("translationY", removedHeight, 0); + animator = ObjectAnimator.ofPropertyValuesHolder(child, translateX, translateY); + } else { + // Just animate X + translateX = PropertyValuesHolder.ofFloat("translationX", columnWidth, 0); + animator = ObjectAnimator.ofPropertyValuesHolder(child, translateX); + } + animator.setStartDelay(x * ANIM_DELAY_MULTIPLE_MS); + childAnimators.add(animator); + } + + final AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(childAnimators); + animatorSet.setDuration(ANIM_TIME_MS); + animatorSet.setInterpolator(ANIM_INTERPOLATOR); + animatorSet.start(); + + // Set the starting position of the child views - because we are delaying the start + // of the animation, we need to prevent the items being drawn in their final position + // prior to the animation starting + for (int x = 1, i = (removedPosition - firstPosition) + 1; i < childCount; i++, x++) { + final View child = getChildAt(i); + + final PointF targetLocation = tabLocations.get(x + 1); + if (targetLocation == null) { + continue; + } + + child.setX(targetLocation.x); + child.setY(targetLocation.y); + } + + return true; + } + }); + } + + + private void animateCancel(final View view) { + PropertyAnimator animator = new PropertyAnimator(ANIM_TIME_MS); + animator.attach(view, PropertyAnimator.Property.ALPHA, 1); + animator.attach(view, PropertyAnimator.Property.TRANSLATION_X, 0); + + animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { + } + + @Override + public void onPropertyAnimationEnd() { + TabsLayoutItemView tab = (TabsLayoutItemView) view; + tab.setCloseVisible(true); + } + }); + + animator.start(); + } + + private class TabsGridLayoutAdapter extends TabsLayoutAdapter { + + final private Button.OnClickListener mCloseClickListener; + + public TabsGridLayoutAdapter(Context context) { + super(context, R.layout.tabs_layout_item_view); + + mCloseClickListener = new Button.OnClickListener() { + @Override + public void onClick(View v) { + closeTab(v); + } + }; + } + + @Override + TabsLayoutItemView newView(int position, ViewGroup parent) { + final TabsLayoutItemView item = super.newView(position, parent); + + item.setCloseOnClickListener(mCloseClickListener); + ((ThemedRelativeLayout) item.findViewById(R.id.wrapper)).setPrivateMode(isPrivate); + + return item; + } + + @Override + public void bindView(TabsLayoutItemView view, Tab tab) { + super.bindView(view, tab); + + // If we're recycling this view, there's a chance it was transformed during + // the close animation. Remove any of those properties. + resetTransforms(view); + } + } + + private class TabSwipeGestureListener implements View.OnTouchListener { + // same value the stock browser uses for after drag animation velocity in pixels/sec + // http://androidxref.com/4.0.4/xref/packages/apps/Browser/src/com/android/browser/NavTabScroller.java#61 + private static final float MIN_VELOCITY = 750; + + private final int mSwipeThreshold; + private final int mMinFlingVelocity; + + private final int mMaxFlingVelocity; + private VelocityTracker mVelocityTracker; + + private int mTabWidth = 1; + + private View mSwipeView; + private Runnable mPendingCheckForTap; + + private float mSwipeStartX; + private boolean mSwiping; + private boolean mEnabled; + + public TabSwipeGestureListener() { + mEnabled = true; + + ViewConfiguration vc = ViewConfiguration.get(TabsGridLayout.this.getContext()); + mSwipeThreshold = vc.getScaledTouchSlop(); + mMinFlingVelocity = (int) (TabsGridLayout.this.getContext().getResources().getDisplayMetrics().density * MIN_VELOCITY); + mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity(); + } + + public void setEnabled(boolean enabled) { + mEnabled = enabled; + } + + public OnScrollListener makeScrollListener() { + return new OnScrollListener() { + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + setEnabled(scrollState != GridView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL); + } + + @Override + public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { + + } + }; + } + + @Override + public boolean onTouch(View view, MotionEvent e) { + if (!mEnabled) { + return false; + } + + switch (e.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + // Check if we should set pressed state on the + // touched view after a standard delay. + triggerCheckForTap(); + + final float x = e.getRawX(); + final float y = e.getRawY(); + + // Find out which view is being touched + mSwipeView = findViewAt(x, y); + + if (mSwipeView != null) { + if (mTabWidth < 2) { + mTabWidth = mSwipeView.getWidth(); + } + + mSwipeStartX = e.getRawX(); + + mVelocityTracker = VelocityTracker.obtain(); + mVelocityTracker.addMovement(e); + } + + view.onTouchEvent(e); + return true; + } + + case MotionEvent.ACTION_UP: { + if (mSwipeView == null) { + break; + } + + cancelCheckForTap(); + mSwipeView.setPressed(false); + + if (!mSwiping) { + final TabsLayoutItemView item = (TabsLayoutItemView) mSwipeView; + final int tabId = item.getTabId(); + final Tab tab = Tabs.getInstance().selectTab(tabId); + if (tab != null) { + autoHidePanel(); + Tabs.getInstance().notifyListeners(tab, Tabs.TabEvents.OPENED_FROM_TABS_TRAY); + } + + mVelocityTracker.recycle(); + mVelocityTracker = null; + break; + } + + mVelocityTracker.addMovement(e); + mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity); + + float velocityX = Math.abs(mVelocityTracker.getXVelocity()); + + boolean dismiss = false; + + float deltaX = mSwipeView.getTranslationX(); + + if (Math.abs(deltaX) > mTabWidth / 2) { + dismiss = true; + } else if (mMinFlingVelocity <= velocityX && velocityX <= mMaxFlingVelocity) { + dismiss = mSwiping && (deltaX * mVelocityTracker.getYVelocity() > 0); + } + if (dismiss) { + closeTab(mSwipeView.findViewById(R.id.close)); + } else { + animateCancel(mSwipeView); + } + mVelocityTracker.recycle(); + mVelocityTracker = null; + mSwipeView = null; + + mSwipeStartX = 0; + mSwiping = false; + } + + case MotionEvent.ACTION_MOVE: { + if (mSwipeView == null || mVelocityTracker == null) { + break; + } + + mVelocityTracker.addMovement(e); + + float delta = e.getRawX() - mSwipeStartX; + + boolean isScrollingX = Math.abs(delta) > mSwipeThreshold; + boolean isSwipingToClose = isScrollingX; + + // If we're actually swiping, make sure we don't + // set pressed state on the swiped view. + if (isScrollingX) { + cancelCheckForTap(); + } + + if (isSwipingToClose) { + mSwiping = true; + TabsGridLayout.this.requestDisallowInterceptTouchEvent(true); + + ((TabsLayoutItemView) mSwipeView).setCloseVisible(false); + + // Stops listview from highlighting the touched item + // in the list when swiping. + MotionEvent cancelEvent = MotionEvent.obtain(e); + cancelEvent.setAction(MotionEvent.ACTION_CANCEL | + (e.getActionIndex() << MotionEvent.ACTION_POINTER_INDEX_SHIFT)); + TabsGridLayout.this.onTouchEvent(cancelEvent); + cancelEvent.recycle(); + } + + if (mSwiping) { + mSwipeView.setTranslationX(delta); + + mSwipeView.setAlpha(Math.min(1f, 1f - 2f * Math.abs(delta) / mTabWidth)); + + return true; + } + + break; + } + } + return false; + } + + private View findViewAt(float rawX, float rawY) { + Rect rect = new Rect(); + + int[] listViewCoords = new int[2]; + TabsGridLayout.this.getLocationOnScreen(listViewCoords); + + int x = (int) rawX - listViewCoords[0]; + int y = (int) rawY - listViewCoords[1]; + + for (int i = 0; i < TabsGridLayout.this.getChildCount(); i++) { + View child = TabsGridLayout.this.getChildAt(i); + child.getHitRect(rect); + + if (rect.contains(x, y)) { + return child; + } + } + + return null; + } + + private void triggerCheckForTap() { + if (mPendingCheckForTap == null) { + mPendingCheckForTap = new CheckForTap(); + } + + TabsGridLayout.this.postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout()); + } + + private void cancelCheckForTap() { + if (mPendingCheckForTap == null) { + return; + } + + TabsGridLayout.this.removeCallbacks(mPendingCheckForTap); + } + + private class CheckForTap implements Runnable { + @Override + public void run() { + if (!mSwiping && mSwipeView != null && mEnabled) { + mSwipeView.setPressed(true); + } + } + } + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayout.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayout.java new file mode 100644 index 000000000..d5362f1f1 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayout.java @@ -0,0 +1,216 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.widget.RecyclerViewClickSupport; + +import android.content.Context; +import android.content.res.TypedArray; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.view.View; +import android.widget.Button; + +import java.util.ArrayList; + +public abstract class TabsLayout extends RecyclerView + implements TabsPanel.TabsLayout, + Tabs.OnTabsChangedListener, + RecyclerViewClickSupport.OnItemClickListener, + TabsTouchHelperCallback.DismissListener { + + private static final String LOGTAG = "Gecko" + TabsLayout.class.getSimpleName(); + + private final boolean isPrivate; + private TabsPanel tabsPanel; + private final TabsLayoutRecyclerAdapter tabsAdapter; + + public TabsLayout(Context context, AttributeSet attrs, int itemViewLayoutResId) { + super(context, attrs); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabsLayout); + isPrivate = (a.getInt(R.styleable.TabsLayout_tabs, 0x0) == 1); + a.recycle(); + + tabsAdapter = new TabsLayoutRecyclerAdapter(context, itemViewLayoutResId, isPrivate, + /* close on click listener */ + new Button.OnClickListener() { + @Override + public void onClick(View v) { + // The view here is the close button, which has a reference + // to the parent TabsLayoutItemView in its tag, hence the getTag() call. + TabsLayoutItemView itemView = (TabsLayoutItemView) v.getTag(); + closeTab(itemView); + } + }); + setAdapter(tabsAdapter); + + RecyclerViewClickSupport.addTo(this).setOnItemClickListener(this); + + setRecyclerListener(new RecyclerListener() { + @Override + public void onViewRecycled(RecyclerView.ViewHolder holder) { + final TabsLayoutItemView itemView = (TabsLayoutItemView) holder.itemView; + itemView.setThumbnail(null); + itemView.setCloseVisible(true); + } + }); + } + + @Override + public void setTabsPanel(TabsPanel panel) { + tabsPanel = panel; + } + + @Override + public void show() { + setVisibility(View.VISIBLE); + Tabs.getInstance().refreshThumbnails(); + Tabs.registerOnTabsChangedListener(this); + refreshTabsData(); + } + + @Override + public void hide() { + setVisibility(View.GONE); + Tabs.unregisterOnTabsChangedListener(this); + GeckoAppShell.notifyObservers("Tab:Screenshot:Cancel", ""); + tabsAdapter.clear(); + } + + @Override + public boolean shouldExpand() { + return true; + } + + protected void autoHidePanel() { + tabsPanel.autoHidePanel(); + } + + @Override + public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) { + switch (msg) { + case ADDED: + final int tabIndex = Integer.parseInt(data); + tabsAdapter.notifyTabInserted(tab, tabIndex); + if (addAtIndexRequiresScroll(tabIndex)) { + // (The current Tabs implementation updates the SELECTED tab *after* this + // call to ADDED, so don't just call updateSelectedPosition().) + scrollToPosition(tabIndex); + } + break; + + case CLOSED: + if (tab.isPrivate() == isPrivate && tabsAdapter.getItemCount() > 0) { + tabsAdapter.removeTab(tab); + } + break; + + case SELECTED: + case UNSELECTED: + case THUMBNAIL: + case TITLE: + case RECORDING_CHANGE: + case AUDIO_PLAYING_CHANGE: + tabsAdapter.notifyTabChanged(tab); + break; + } + } + + // Addition of a tab at selected positions (dependent on LayoutManager) will result in a tab + // being added out of view - return true if index is such a position. + abstract protected boolean addAtIndexRequiresScroll(int index); + + @Override + public void onItemClicked(RecyclerView recyclerView, int position, View v) { + final TabsLayoutItemView item = (TabsLayoutItemView) v; + final int tabId = item.getTabId(); + final Tab tab = Tabs.getInstance().selectTab(tabId); + if (tab == null) { + // The tab that was clicked no longer exists in the tabs list (which can happen if you + // tap on a tab while its remove animation is running), so ignore the click. + return; + } + + autoHidePanel(); + Tabs.getInstance().notifyListeners(tab, Tabs.TabEvents.OPENED_FROM_TABS_TRAY); + } + + // Updates the selected position in the list so that it will be scrolled to the right place. + private void updateSelectedPosition() { + final int selected = tabsAdapter.getPositionForTab(Tabs.getInstance().getSelectedTab()); + if (selected != NO_POSITION) { + scrollToPosition(selected); + } + } + + 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. + final ArrayList<Tab> tabData = new ArrayList<>(); + final Iterable<Tab> allTabs = Tabs.getInstance().getTabsInOrder(); + + for (final Tab tab : allTabs) { + if (tab.isPrivate() == isPrivate) { + tabData.add(tab); + } + } + + tabsAdapter.setTabs(tabData); + updateSelectedPosition(); + } + + private void closeTab(View view) { + final TabsLayoutItemView itemView = (TabsLayoutItemView) view; + final Tab tab = getTabForView(itemView); + if (tab == null) { + // We can be null here if this is the second closeTab call resulting from a sufficiently + // fast double tap on the close tab button. + return; + } + + final boolean closingLastTab = tabsAdapter.getItemCount() == 1; + Tabs.getInstance().closeTab(tab, true); + if (closingLastTab) { + autoHidePanel(); + } + } + + protected void closeAllTabs() { + final Iterable<Tab> tabs = Tabs.getInstance().getTabsInOrder(); + for (final Tab tab : tabs) { + // In the normal panel we want to close all tabs (both private and normal), + // but in the private panel we only want to close private tabs. + if (!isPrivate || tab.isPrivate()) { + Tabs.getInstance().closeTab(tab, false); + } + } + } + + @Override + public void onItemDismiss(View view) { + closeTab(view); + } + + private Tab getTabForView(View view) { + if (view == null) { + return null; + } + return Tabs.getInstance().getTab(((TabsLayoutItemView) view).getTabId()); + } + + @Override + public void setEmptyView(View emptyView) { + // We never display an empty view. + } + + @Override + abstract public void closeAll(); +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutAdapter.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutAdapter.java new file mode 100644 index 000000000..367da640f --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutAdapter.java @@ -0,0 +1,100 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; + +import java.util.ArrayList; + +// Adapter to bind tabs into a list +public class TabsLayoutAdapter extends BaseAdapter { + public static final String LOGTAG = "Gecko" + TabsLayoutAdapter.class.getSimpleName(); + + private final Context mContext; + private final int mTabLayoutId; + private ArrayList<Tab> mTabs; + private final LayoutInflater mInflater; + + public TabsLayoutAdapter (Context context, int tabLayoutId) { + mContext = context; + mInflater = LayoutInflater.from(mContext); + mTabLayoutId = tabLayoutId; + } + + final void setTabs (ArrayList<Tab> tabs) { + mTabs = tabs; + notifyDataSetChanged(); // Be sure to call this whenever mTabs changes. + } + + final boolean removeTab (Tab tab) { + boolean tabRemoved = mTabs.remove(tab); + if (tabRemoved) { + notifyDataSetChanged(); // Be sure to call this whenever mTabs changes. + } + return tabRemoved; + } + + final void clear() { + mTabs = null; + + notifyDataSetChanged(); // Be sure to call this whenever mTabs changes. + } + + @Override + public int getCount() { + return (mTabs == null ? 0 : mTabs.size()); + } + + @Override + public Tab getItem(int position) { + return mTabs.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + final int getPositionForTab(Tab tab) { + if (mTabs == null || tab == null) + return -1; + + return mTabs.indexOf(tab); + } + + @Override + public boolean isEnabled(int position) { + return true; + } + + @Override + final public TabsLayoutItemView getView(int position, View convertView, ViewGroup parent) { + final TabsLayoutItemView view; + if (convertView == null) { + view = newView(position, parent); + } else { + view = (TabsLayoutItemView) convertView; + } + final Tab tab = mTabs.get(position); + bindView(view, tab); + return view; + } + + TabsLayoutItemView newView(int position, ViewGroup parent) { + return (TabsLayoutItemView) mInflater.inflate(mTabLayoutId, parent, false); + } + + void bindView(TabsLayoutItemView view, Tab tab) { + view.assignValues(tab); + } +}
\ No newline at end of file diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutItemView.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutItemView.java new file mode 100644 index 000000000..975e779d6 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutItemView.java @@ -0,0 +1,172 @@ +/* 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.tabs; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.Tab; +import org.mozilla.gecko.Tabs; +import org.mozilla.gecko.widget.TabThumbnailWrapper; +import org.mozilla.gecko.widget.TouchDelegateWithReset; +import org.mozilla.gecko.widget.themed.ThemedRelativeLayout; + +import android.content.Context; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.View; +import android.view.ViewTreeObserver; +import android.widget.Checkable; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +public class TabsLayoutItemView extends LinearLayout + implements Checkable { + private static final String LOGTAG = "Gecko" + TabsLayoutItemView.class.getSimpleName(); + private static final int[] STATE_CHECKED = { android.R.attr.state_checked }; + private boolean mChecked; + + private int mTabId; + private TextView mTitle; + private TabsPanelThumbnailView mThumbnail; + private ImageView mCloseButton; + private TabThumbnailWrapper mThumbnailWrapper; + + public TabsLayoutItemView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + if (mChecked) { + mergeDrawableStates(drawableState, STATE_CHECKED); + } + + return drawableState; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public boolean isChecked() { + return mChecked; + } + + @Override + public void setChecked(boolean checked) { + if (mChecked == checked) { + return; + } + + mChecked = checked; + refreshDrawableState(); + + int count = getChildCount(); + for (int i = 0; i < count; i++) { + final View child = getChildAt(i); + if (child instanceof Checkable) { + ((Checkable) child).setChecked(checked); + } + } + } + + @Override + public void toggle() { + mChecked = !mChecked; + } + + public void setCloseOnClickListener(OnClickListener mOnClickListener) { + mCloseButton.setOnClickListener(mOnClickListener); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mTitle = (TextView) findViewById(R.id.title); + mThumbnail = (TabsPanelThumbnailView) findViewById(R.id.thumbnail); + mCloseButton = (ImageView) findViewById(R.id.close); + mThumbnailWrapper = (TabThumbnailWrapper) findViewById(R.id.wrapper); + + growCloseButtonHitArea(); + } + + private void growCloseButtonHitArea() { + getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + getViewTreeObserver().removeOnPreDrawListener(this); + + // Ideally we want the close button hit area to be 40x40dp but we are constrained by the height of the parent, so + // we make it as tall as the parent view and 40dp across. + final int targetHitArea = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 40, getResources().getDisplayMetrics());; + + final Rect hitRect = new Rect(); + hitRect.top = 0; + hitRect.right = getWidth(); + hitRect.left = getWidth() - targetHitArea; + hitRect.bottom = targetHitArea; + + setTouchDelegate(new TouchDelegateWithReset(hitRect, mCloseButton)); + + return true; + } + }); + } + + protected void assignValues(Tab tab) { + if (tab == null) { + return; + } + + mTabId = tab.getId(); + + setChecked(Tabs.getInstance().isSelectedTab(tab)); + + Drawable thumbnailImage = tab.getThumbnail(); + mThumbnail.setImageDrawable(thumbnailImage); + + mThumbnail.setPrivateMode(tab.isPrivate()); + + if (mThumbnailWrapper != null) { + mThumbnailWrapper.setRecording(tab.isRecording()); + } + + final String tabTitle = tab.getDisplayTitle(); + mTitle.setText(tabTitle); + mCloseButton.setTag(this); + + if (tab.isAudioPlaying()) { + mTitle.setCompoundDrawablesWithIntrinsicBounds(R.drawable.tab_audio_playing, 0, 0, 0); + final String tabTitleWithAudio = + getResources().getString(R.string.tab_title_prefix_is_playing_audio, tabTitle); + mTitle.setContentDescription(tabTitleWithAudio); + } else { + mTitle.setCompoundDrawables(null, null, null, null); + mTitle.setContentDescription(tabTitle); + } + } + + public int getTabId() { + return mTabId; + } + + public void setThumbnail(Drawable thumbnail) { + mThumbnail.setImageDrawable(thumbnail); + } + + public void setCloseVisible(boolean visible) { + mCloseButton.setVisibility(visible ? View.VISIBLE : View.INVISIBLE); + } + + public void setPrivateMode(boolean isPrivate) { + ((ThemedRelativeLayout) findViewById(R.id.wrapper)).setPrivateMode(isPrivate); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutRecyclerAdapter.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutRecyclerAdapter.java new file mode 100644 index 000000000..090d74f9d --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsLayoutRecyclerAdapter.java @@ -0,0 +1,124 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.Tab; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; + +import java.util.ArrayList; + +public class TabsLayoutRecyclerAdapter + extends RecyclerView.Adapter<TabsLayoutRecyclerAdapter.TabsListViewHolder> { + + private static final String LOGTAG = "Gecko" + TabsLayoutRecyclerAdapter.class.getSimpleName(); + + private final int tabLayoutId; + private @NonNull ArrayList<Tab> tabs; + private final LayoutInflater inflater; + private final boolean isPrivate; + // Click listener for the close button on itemViews. + private final Button.OnClickListener closeOnClickListener; + + // The TabsLayoutItemView takes care of caching its own Views, so we don't need to do anything + // here except not be abstract. + public static class TabsListViewHolder extends RecyclerView.ViewHolder { + public TabsListViewHolder(View itemView) { + super(itemView); + } + } + + public TabsLayoutRecyclerAdapter(Context context, int tabLayoutId, boolean isPrivate, + Button.OnClickListener closeOnClickListener) { + inflater = LayoutInflater.from(context); + this.tabLayoutId = tabLayoutId; + this.isPrivate = isPrivate; + this.closeOnClickListener = closeOnClickListener; + tabs = new ArrayList<>(0); + } + + /* package */ final void setTabs(@NonNull ArrayList<Tab> tabs) { + this.tabs = tabs; + notifyDataSetChanged(); + } + + /* package */ final void clear() { + tabs = new ArrayList<>(0); + notifyDataSetChanged(); + } + + /* package */ final boolean removeTab(Tab tab) { + final int position = getPositionForTab(tab); + if (position == -1) { + return false; + } + tabs.remove(position); + notifyItemRemoved(position); + return true; + } + + /* package */ final int getPositionForTab(Tab tab) { + if (tab == null) { + return -1; + } + + return tabs.indexOf(tab); + } + + /* package */ void notifyTabChanged(Tab tab) { + notifyItemChanged(getPositionForTab(tab)); + } + + /* package */ void notifyTabInserted(Tab tab, int index) { + if (index >= 0 && index <= tabs.size()) { + tabs.add(index, tab); + notifyItemInserted(index); + } else { + // Add to the end. + tabs.add(tab); + notifyItemInserted(tabs.size() - 1); + // index == -1 is a valid way to add to the end, the other cases are errors. + if (index != -1) { + Log.e(LOGTAG, "Tab was inserted at an invalid position: " + Integer.toString(index)); + } + } + } + + @Override + public int getItemCount() { + return tabs.size(); + } + + private Tab getItem(int position) { + return tabs.get(position); + } + + @Override + public void onBindViewHolder(TabsListViewHolder viewHolder, int position) { + final Tab tab = getItem(position); + final TabsLayoutItemView itemView = (TabsLayoutItemView) viewHolder.itemView; + itemView.assignValues(tab); + // Be careful (re)setting position values here: bind is called on each notifyItemChanged, + // so you could be stomping on values that have been set in support of other animations + // that are already underway. + } + + @Override + public TabsListViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + final TabsLayoutItemView viewItem = (TabsLayoutItemView) inflater.inflate(tabLayoutId, parent, false); + viewItem.setPrivateMode(isPrivate); + viewItem.setCloseOnClickListener(closeOnClickListener); + + return new TabsListViewHolder(viewItem); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayout.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayout.java new file mode 100644 index 000000000..8cf2f8ede --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayout.java @@ -0,0 +1,118 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.Context; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.helper.ItemTouchHelper; +import android.util.AttributeSet; +import android.view.View; + +public class TabsListLayout extends TabsLayout { + // Time to animate non-flinged tabs of screen, in milliseconds + private static final int ANIMATION_DURATION = 250; + + // Time between starting successive tab animations in closeAllTabs. + private static final int ANIMATION_CASCADE_DELAY = 75; + + private int closeAllAnimationCount; + + public TabsListLayout(Context context, AttributeSet attrs) { + super(context, attrs, R.layout.tabs_list_item_view); + + setHasFixedSize(true); + + setLayoutManager(new LinearLayoutManager(context)); + + // A TouchHelper handler for swipe to close. + final TabsTouchHelperCallback callback = new TabsTouchHelperCallback(this); + final ItemTouchHelper touchHelper = new ItemTouchHelper(callback); + touchHelper.attachToRecyclerView(this); + + setItemAnimator(new TabsListLayoutAnimator(ANIMATION_DURATION)); + } + + @Override + public void closeAll() { + final int childCount = getChildCount(); + + // Just close the panel if there are no tabs to close. + if (childCount == 0) { + autoHidePanel(); + return; + } + + // Disable the view so that gestures won't interfere wth the tab close animation. + setEnabled(false); + + // Delay starting each successive animation to create a cascade effect. + int cascadeDelay = 0; + closeAllAnimationCount = 0; + for (int i = childCount - 1; i >= 0; i--) { + final View view = getChildAt(i); + if (view == null) { + continue; + } + + final PropertyAnimator animator = new PropertyAnimator(ANIMATION_DURATION); + animator.attach(view, PropertyAnimator.Property.ALPHA, 0); + + animator.attach(view, PropertyAnimator.Property.TRANSLATION_X, view.getWidth()); + + closeAllAnimationCount++; + + animator.addPropertyAnimationListener(new PropertyAnimator.PropertyAnimationListener() { + @Override + public void onPropertyAnimationStart() { + } + + @Override + public void onPropertyAnimationEnd() { + closeAllAnimationCount--; + if (closeAllAnimationCount > 0) { + return; + } + + // Hide the panel after the animation is done. + autoHidePanel(); + + // Re-enable the view after the animation is done. + TabsListLayout.this.setEnabled(true); + + // Then actually close all the tabs. + closeAllTabs(); + } + }); + + ThreadUtils.postDelayedToUiThread(new Runnable() { + @Override + public void run() { + animator.start(); + } + }, cascadeDelay); + + cascadeDelay += ANIMATION_CASCADE_DELAY; + } + } + + @Override + protected boolean addAtIndexRequiresScroll(int index) { + return index == 0 || index == getAdapter().getItemCount() - 1; + } + + @Override + public void onChildAttachedToWindow(View child) { + // Make sure we reset any attributes that may have been animated in this child's previous + // incarnation. + child.setTranslationX(0); + child.setTranslationY(0); + child.setAlpha(1); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayoutAnimator.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayoutAnimator.java new file mode 100644 index 000000000..471abf883 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsListLayoutAnimator.java @@ -0,0 +1,65 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.widget.DefaultItemAnimatorBase; + +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.RecyclerView; +import android.view.View; + +class TabsListLayoutAnimator extends DefaultItemAnimatorBase { + public TabsListLayoutAnimator(int animationDuration) { + setRemoveDuration(animationDuration); + setAddDuration(animationDuration); + // A fade in/out each time the title/thumbnail/etc. gets updated isn't helpful, so disable + // the change animation. + setSupportsChangeAnimations(false); + } + + @Override + protected boolean preAnimateRemoveImpl(final RecyclerView.ViewHolder holder) { + // If the view isn't at full alpha then we were closed by a swipe which an + // ItemTouchHelper is animating for us, so just return without animating the remove and + // let runPendingAnimations pick up the rest. + if (holder.itemView.getAlpha() < 1) { + return false; + } + resetAnimation(holder); + return true; + } + + @Override + protected void animateRemoveImpl(final RecyclerView.ViewHolder holder) { + final View itemView = holder.itemView; + ViewCompat.animate(itemView) + .setDuration(getRemoveDuration()) + .translationX(itemView.getWidth()) + .alpha(0) + .setListener(new DefaultRemoveVpaListener(holder)) + .start(); + } + + @Override + protected boolean preAnimateAddImpl(RecyclerView.ViewHolder holder) { + resetAnimation(holder); + final View itemView = holder.itemView; + itemView.setTranslationX(itemView.getWidth()); + itemView.setAlpha(0); + return true; + } + + @Override + protected void animateAddImpl(final RecyclerView.ViewHolder holder) { + final View itemView = holder.itemView; + ViewCompat.animate(itemView) + .setDuration(getAddDuration()) + .translationX(0) + .alpha(1) + .setListener(new DefaultAddVpaListener(holder)) + .start(); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanel.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanel.java new file mode 100644 index 000000000..2be127010 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanel.java @@ -0,0 +1,456 @@ +/* -*- 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.tabs; + +import android.support.v4.content.ContextCompat; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.GeckoApp; +import org.mozilla.gecko.GeckoApplication; +import org.mozilla.gecko.R; +import org.mozilla.gecko.Telemetry; +import org.mozilla.gecko.TelemetryContract; +import org.mozilla.gecko.animation.PropertyAnimator; +import org.mozilla.gecko.animation.ViewHelper; +import org.mozilla.gecko.lwt.LightweightTheme; +import org.mozilla.gecko.lwt.LightweightThemeDrawable; +import org.mozilla.gecko.restrictions.Restrictable; +import org.mozilla.gecko.restrictions.Restrictions; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.widget.GeckoPopupMenu; +import org.mozilla.gecko.widget.IconTabWidget; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.util.AttributeSet; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import org.mozilla.gecko.widget.themed.ThemedImageButton; + +public class TabsPanel extends LinearLayout + implements GeckoPopupMenu.OnMenuItemClickListener, + LightweightTheme.OnChangeListener, + IconTabWidget.OnTabChangedListener { + private static final String LOGTAG = "Gecko" + TabsPanel.class.getSimpleName(); + + public enum Panel { + NORMAL_TABS, + PRIVATE_TABS, + } + + public interface PanelView { + void setTabsPanel(TabsPanel panel); + void show(); + void hide(); + boolean shouldExpand(); + } + + public interface CloseAllPanelView extends PanelView { + void closeAll(); + } + + public interface TabsLayout extends CloseAllPanelView { + void setEmptyView(View view); + } + + public interface TabsLayoutChangeListener { + void onTabsLayoutChange(int width, int height); + } + + public static View createTabsLayout(final Context context, final AttributeSet attrs) { + final boolean isLandscape = context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + + if (HardwareUtils.isTablet() || isLandscape) { + return new TabsGridLayout(context, attrs); + } else { + return new TabsListLayout(context, attrs); + } + } + + private final Context mContext; + private final GeckoApp mActivity; + private final LightweightTheme mTheme; + private RelativeLayout mHeader; + private FrameLayout mTabsContainer; + private PanelView mPanel; + private PanelView mPanelNormal; + private PanelView mPanelPrivate; + private TabsLayoutChangeListener mLayoutChangeListener; + + private IconTabWidget mTabWidget; + private View mMenuButton; + private ImageButton mAddTab; + private ImageButton mNavBackButton; + + private Panel mCurrentPanel; + private boolean mVisible; + private boolean mHeaderVisible; + + private final GeckoPopupMenu mPopupMenu; + + public TabsPanel(Context context, AttributeSet attrs) { + super(context, attrs); + mContext = context; + mActivity = (GeckoApp) context; + mTheme = ((GeckoApplication) context.getApplicationContext()).getLightweightTheme(); + + mCurrentPanel = Panel.NORMAL_TABS; + + mPopupMenu = new GeckoPopupMenu(context); + mPopupMenu.inflate(R.menu.tabs_menu); + mPopupMenu.setOnMenuItemClickListener(this); + + inflateLayout(context); + initialize(); + } + + private void inflateLayout(Context context) { + LayoutInflater.from(context).inflate(R.layout.tabs_panel_default, this); + } + + private void initialize() { + mHeader = (RelativeLayout) findViewById(R.id.tabs_panel_header); + mTabsContainer = (FrameLayout) findViewById(R.id.tabs_container); + + mPanelNormal = (PanelView) findViewById(R.id.normal_tabs); + mPanelNormal.setTabsPanel(this); + + mPanelPrivate = (PanelView) findViewById(R.id.private_tabs_panel); + mPanelPrivate.setTabsPanel(this); + + mAddTab = (ImageButton) findViewById(R.id.add_tab); + mAddTab.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View v) { + TabsPanel.this.addTab(); + } + }); + + mTabWidget = (IconTabWidget) findViewById(R.id.tab_widget); + + mTabWidget.addTab(R.drawable.tabs_normal, R.string.tabs_normal); + final ThemedImageButton privateTabsPanel = + (ThemedImageButton) mTabWidget.addTab(R.drawable.tabs_private, R.string.tabs_private); + privateTabsPanel.setPrivateMode(true); + + if (!Restrictions.isAllowed(mContext, Restrictable.PRIVATE_BROWSING)) { + mTabWidget.setVisibility(View.GONE); + } + + mTabWidget.setTabSelectionListener(this); + + mMenuButton = findViewById(R.id.menu); + mMenuButton.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View view) { + showMenu(); + } + }); + + mNavBackButton = (ImageButton) findViewById(R.id.nav_back); + mNavBackButton.setOnClickListener(new Button.OnClickListener() { + @Override + public void onClick(View view) { + mActivity.onBackPressed(); + } + }); + } + + public void showMenu() { + final Menu menu = mPopupMenu.getMenu(); + + // Each panel has a "+" shortcut button, so don't show it for that panel. + menu.findItem(R.id.new_tab).setVisible(mCurrentPanel != Panel.NORMAL_TABS); + menu.findItem(R.id.new_private_tab).setVisible(mCurrentPanel != Panel.PRIVATE_TABS + && Restrictions.isAllowed(mContext, Restrictable.PRIVATE_BROWSING)); + + // Only show "Clear * tabs" for current panel. + menu.findItem(R.id.close_all_tabs).setVisible(mCurrentPanel == Panel.NORMAL_TABS); + menu.findItem(R.id.close_private_tabs).setVisible(mCurrentPanel == Panel.PRIVATE_TABS); + + mPopupMenu.show(); + } + + private void addTab() { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.ACTIONBAR, "new_tab"); + + if (mCurrentPanel == Panel.NORMAL_TABS) { + mActivity.addTab(); + } else { + mActivity.addPrivateTab(); + } + + mActivity.autoHideTabs(); + } + + @Override + public void onTabChanged(int index) { + if (index == 0) { + show(Panel.NORMAL_TABS); + } else { + show(Panel.PRIVATE_TABS); + } + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + final int itemId = item.getItemId(); + + if (itemId == R.id.close_all_tabs) { + if (mCurrentPanel == Panel.NORMAL_TABS) { + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.MENU, "close_all_tabs"); + + // Disable the menu button so that the menu won't interfere with the tab close animation. + mMenuButton.setEnabled(false); + ((CloseAllPanelView) mPanelNormal).closeAll(); + } else { + Log.e(LOGTAG, "Close all tabs menu item should only be visible for normal tabs panel"); + } + return true; + } + + if (itemId == R.id.close_private_tabs) { + if (mCurrentPanel == Panel.PRIVATE_TABS) { + // Mask private browsing + Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.MENU, "close_all_tabs"); + + ((CloseAllPanelView) mPanelPrivate).closeAll(); + } else { + Log.e(LOGTAG, "Close private tabs menu item should only be visible for private tabs panel"); + } + return true; + } + + if (itemId == R.id.new_tab || itemId == R.id.new_private_tab) { + hide(); + } + + return mActivity.onOptionsItemSelected(item); + } + + private static int getTabContainerHeight(FrameLayout tabsContainer) { + final Resources resources = tabsContainer.getContext().getResources(); + + final int screenHeight = resources.getDisplayMetrics().heightPixels; + final int actionBarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height); + + return screenHeight - actionBarHeight; + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + mTheme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mTheme.removeListener(this); + } + + @Override + @SuppressWarnings("deprecation") // setBackgroundDrawable deprecated by API level 16 + public void onLightweightThemeChanged() { + final int background = ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey); + final LightweightThemeDrawable drawable = mTheme.getColorDrawable(this, background, true); + if (drawable == null) + return; + + drawable.setAlpha(34, 0); + setBackgroundDrawable(drawable); + } + + @Override + public void onLightweightThemeReset() { + setBackgroundColor(ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey)); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + + // Tabs Panel Toolbar contains the Buttons + static class TabsPanelToolbar extends LinearLayout + implements LightweightTheme.OnChangeListener { + private final LightweightTheme mTheme; + + public TabsPanelToolbar(Context context, AttributeSet attrs) { + super(context, attrs); + mTheme = ((GeckoApplication) context.getApplicationContext()).getLightweightTheme(); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + mTheme.addListener(this); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mTheme.removeListener(this); + } + + @Override + @SuppressWarnings("deprecation") // setBackgroundDrawable deprecated by API level 16 + public void onLightweightThemeChanged() { + final int background = ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey); + final LightweightThemeDrawable drawable = mTheme.getColorDrawable(this, background); + if (drawable == null) + return; + + drawable.setAlpha(34, 34); + setBackgroundDrawable(drawable); + } + + @Override + public void onLightweightThemeReset() { + setBackgroundColor(ContextCompat.getColor(getContext(), R.color.text_and_tabs_tray_grey)); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + onLightweightThemeChanged(); + } + } + + public void show(Panel panelToShow) { + prepareToShow(panelToShow); + int height = getVerticalPanelHeight(); + dispatchLayoutChange(getWidth(), height); + mHeaderVisible = true; + } + + public void prepareToShow(Panel panelToShow) { + if (!isShown()) { + setVisibility(View.VISIBLE); + } + + if (mPanel != null) { + // Hide the old panel. + mPanel.hide(); + } + + mVisible = true; + mCurrentPanel = panelToShow; + + int index = panelToShow.ordinal(); + mTabWidget.setCurrentTab(index); + + switch (panelToShow) { + case NORMAL_TABS: + mPanel = mPanelNormal; + break; + case PRIVATE_TABS: + mPanel = mPanelPrivate; + break; + + default: + throw new IllegalArgumentException("Unknown panel type " + panelToShow); + } + mPanel.show(); + + mAddTab.setVisibility(View.VISIBLE); + + mMenuButton.setEnabled(true); + mPopupMenu.setAnchor(mMenuButton); + } + + public int getVerticalPanelHeight() { + final int actionBarHeight = mContext.getResources().getDimensionPixelSize(R.dimen.browser_toolbar_height); + final int height = actionBarHeight + getTabContainerHeight(mTabsContainer); + return height; + } + + public void hide() { + mHeaderVisible = false; + + if (mVisible) { + mVisible = false; + mPopupMenu.dismiss(); + dispatchLayoutChange(0, 0); + } + } + + public void refresh() { + removeAllViews(); + + inflateLayout(mContext); + initialize(); + + if (mVisible) + show(mCurrentPanel); + } + + public void autoHidePanel() { + mActivity.autoHideTabs(); + } + + @Override + public boolean isShown() { + return mVisible; + } + + public void setHWLayerEnabled(boolean enabled) { + if (enabled) { + mHeader.setLayerType(View.LAYER_TYPE_HARDWARE, null); + mTabsContainer.setLayerType(View.LAYER_TYPE_HARDWARE, null); + } else { + mHeader.setLayerType(View.LAYER_TYPE_NONE, null); + mTabsContainer.setLayerType(View.LAYER_TYPE_NONE, null); + } + } + + public void prepareTabsAnimation(PropertyAnimator animator) { + if (!mHeaderVisible) { + final Resources resources = getContext().getResources(); + final int toolbarHeight = resources.getDimensionPixelSize(R.dimen.browser_toolbar_height); + final int translationY = (mVisible ? 0 : -toolbarHeight); + if (mVisible) { + ViewHelper.setTranslationY(mHeader, -toolbarHeight); + ViewHelper.setTranslationY(mTabsContainer, -toolbarHeight); + ViewHelper.setAlpha(mTabsContainer, 0.0f); + } + animator.attach(mTabsContainer, PropertyAnimator.Property.ALPHA, mVisible ? 1.0f : 0.0f); + animator.attach(mTabsContainer, PropertyAnimator.Property.TRANSLATION_Y, translationY); + animator.attach(mHeader, PropertyAnimator.Property.TRANSLATION_Y, translationY); + } + + setHWLayerEnabled(true); + } + + public void finishTabsAnimation() { + setHWLayerEnabled(false); + + // If the tray is now hidden, call hide() on current panel and unset it as the current panel + // to avoid hide() being called again when the layout is opened next. + if (!mVisible && mPanel != null) { + mPanel.hide(); + mPanel = null; + } + } + + public void setTabsLayoutChangeListener(TabsLayoutChangeListener listener) { + mLayoutChangeListener = listener; + } + + private void dispatchLayoutChange(int width, int height) { + if (mLayoutChangeListener != null) + mLayoutChangeListener.onTabsLayoutChange(width, height); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanelThumbnailView.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanelThumbnailView.java new file mode 100644 index 000000000..09254bf76 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsPanelThumbnailView.java @@ -0,0 +1,52 @@ +/* -*- 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.tabs; + +import org.mozilla.gecko.R; +import org.mozilla.gecko.ThumbnailHelper; +import org.mozilla.gecko.widget.CropImageView; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +/** + * A width constrained ImageView to show thumbnails of open tabs in the tabs panel. + */ +public class TabsPanelThumbnailView extends CropImageView { + public static final String LOGTAG = "Gecko" + TabsPanelThumbnailView.class.getSimpleName(); + + + public TabsPanelThumbnailView(final Context context) { + this(context, null); + } + + public TabsPanelThumbnailView(final Context context, final AttributeSet attrs) { + this(context, attrs, 0); + } + + public TabsPanelThumbnailView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected float getAspectRatio() { + return ThumbnailHelper.TABS_PANEL_THUMBNAIL_ASPECT_RATIO; + } + + @Override + public void setImageDrawable(Drawable drawable) { + boolean resize = true; + + if (drawable == null) { + drawable = getResources().getDrawable(R.drawable.tab_panel_tab_background); + resize = false; + setScaleType(ScaleType.FIT_XY); + } + + super.setImageDrawable(drawable, resize); + } +} diff --git a/mobile/android/base/java/org/mozilla/gecko/tabs/TabsTouchHelperCallback.java b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsTouchHelperCallback.java new file mode 100644 index 000000000..36e9e4739 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/tabs/TabsTouchHelperCallback.java @@ -0,0 +1,69 @@ +/* -*- 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.tabs; + +import android.graphics.Canvas; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.helper.ItemTouchHelper; +import android.view.View; + +class TabsTouchHelperCallback extends ItemTouchHelper.Callback { + private final DismissListener dismissListener; + + interface DismissListener { + void onItemDismiss(View view); + } + + public TabsTouchHelperCallback(DismissListener dismissListener) { + this.dismissListener = dismissListener; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return true; + } + + @Override + public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { + return makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT); + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int i) { + dismissListener.onItemDismiss(viewHolder.itemView); + } + + @Override + public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, + RecyclerView.ViewHolder target) { + return false; + } + + // Alpha on an itemView being swiped should decrease to a min over a distance equal to the + // width of the item being swiped. + @Override + public void onChildDraw(Canvas c, + RecyclerView recyclerView, + RecyclerView.ViewHolder viewHolder, + float dX, + float dY, + int actionState, + boolean isCurrentlyActive) { + if (actionState != ItemTouchHelper.ACTION_STATE_SWIPE) { + return; + } + + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + + viewHolder.itemView.setAlpha(Math.max(0.1f, + Math.min(1f, 1f - 2f * Math.abs(dX) / viewHolder.itemView.getWidth()))); + } + + public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { + super.clearView(recyclerView, viewHolder); + viewHolder.itemView.setAlpha(1); + } +} |