blob: 989047ecf69669b6bdc50ea9f7baa14ff573c672 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.gesturenav;
import android.content.Context;
import android.os.Handler;
import android.support.annotation.IdRes;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.TextView;
import org.chromium.base.Supplier;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.gesturenav.NavigationSheetMediator.ItemProperties;
import org.chromium.chrome.browser.widget.bottomsheet.BottomSheet;
import org.chromium.chrome.browser.widget.bottomsheet.BottomSheet.BottomSheetContent;
import org.chromium.chrome.browser.widget.bottomsheet.BottomSheet.ContentPriority;
import org.chromium.chrome.browser.widget.bottomsheet.BottomSheet.SheetState;
import org.chromium.chrome.browser.widget.bottomsheet.BottomSheet.StateChangeReason;
import org.chromium.chrome.browser.widget.bottomsheet.BottomSheetController;
import org.chromium.chrome.browser.widget.bottomsheet.BottomSheetObserver;
import org.chromium.chrome.browser.widget.bottomsheet.EmptyBottomSheetObserver;
import org.chromium.content_public.browser.NavigationHistory;
import org.chromium.ui.modelutil.MVCListAdapter.ModelList;
import org.chromium.ui.modelutil.ModelListAdapter;
import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
/**
* Coordinator class for navigation sheet.
* TODO(jinsukkim): Write tests.
*/
class NavigationSheetCoordinator implements BottomSheetContent, NavigationSheet {
// Type of the navigation list item. We have only single type.
static final int NAVIGATION_LIST_ITEM_TYPE_ID = 0;
// Amount of time to hold the finger still to trigger navigation bottom sheet.
// with a long swipe. This ensures fling gestures from edge won't invoke the sheet.
private static final int LONG_SWIPE_HOLD_DELAY_MS = 50;
// Amount of time to hold the finger still to trigger navigation bottom sheet
// with a short swipe.
private static final int SHORT_SWIPE_HOLD_DELAY_MS = 400;
// Amount of distance to trigger navigation sheet with a long swipe.
// Actual amount is capped so it is at most half the screen width.
private static final int LONG_SWIPE_PEEK_THRESHOLD_DP = 224;
// The history item count in the navigation sheet. If the count is equal or smaller,
// the sheet skips peek state and fully expands right away.
private static final int SKIP_PEEK_COUNT = 3;
// Delta for touch events that can happen even when users doesn't intend to move
// his finger. Any delta smaller than (or equal to) than this are ignored.
private static final float DELTA_IGNORE = 2.f;
private final View mToolbarView;
private final LayoutInflater mLayoutInflater;
private final Supplier<BottomSheetController> mBottomSheetController;
private final NavigationSheet.Delegate mDelegate;
private final NavigationSheetMediator mMediator;
private final BottomSheetObserver mSheetObserver = new EmptyBottomSheetObserver() {
@Override
public void onSheetClosed(@StateChangeReason int reason) {
close(false);
}
};
private final Handler mHandler = new Handler();
private final Runnable mOpenSheetRunnable;
private final float mLongSwipePeekThreshold;
private final ModelList mModelList = new ModelList();
private final ModelListAdapter mModelAdapter = new ModelListAdapter(mModelList);
private final int mItemHeight;
private final int mContentPadding;
private final View mParentView;
private final Runnable mCloseRunnable = () -> close(true);
private static class NavigationItemViewBinder {
public static void bind(PropertyModel model, View view, PropertyKey propertyKey) {
if (ItemProperties.ICON == propertyKey) {
((ImageView) view.findViewById(R.id.favicon_img))
.setImageDrawable(model.get(ItemProperties.ICON));
} else if (ItemProperties.LABEL == propertyKey) {
((TextView) view.findViewById(R.id.entry_title))
.setText(model.get(ItemProperties.LABEL));
} else if (ItemProperties.CLICK_LISTENER == propertyKey) {
view.setOnClickListener(model.get(ItemProperties.CLICK_LISTENER));
}
}
}
private NavigationSheetView mContentView;
private boolean mForward;
private boolean mShowCloseIndicator;
// Metrics. True if sheet has ever been triggered (in peeked state) for an edge swipe.
private boolean mSheetTriggered;
// Set to {@code true} for each trigger when the sheet should fully expand with
// no peek/half state.
private boolean mFullyExpand;
/**
* Construct a new NavigationSheet.
*/
NavigationSheetCoordinator(View parent, Context context,
Supplier<BottomSheetController> bottomSheetController, Delegate delegate) {
mParentView = parent;
mBottomSheetController = bottomSheetController;
mDelegate = delegate;
mLayoutInflater = LayoutInflater.from(context);
mToolbarView = mLayoutInflater.inflate(R.layout.navigation_sheet_toolbar, null);
mMediator = new NavigationSheetMediator(context, mModelList, (position, index) -> {
mDelegate.navigateToIndex(index);
close(false);
GestureNavMetrics.recordHistogram("GestureNavigation.Sheet.Used", mForward);
// Logs position of the clicked item. Back navigation has negative value,
// while forward positive. Show full history is zero.
RecordHistogram.recordSparseHistogram("GestureNavigation.Sheet.Selected",
index == -1 ? 0 : (mForward ? position + 1 : -position - 1));
});
mModelAdapter.registerType(NAVIGATION_LIST_ITEM_TYPE_ID, () -> {
return mLayoutInflater.inflate(R.layout.navigation_popup_item, null);
}, NavigationItemViewBinder::bind);
mOpenSheetRunnable = () -> {
if (isHidden()) openSheet(false, true);
};
mLongSwipePeekThreshold = Math.min(
context.getResources().getDisplayMetrics().density * LONG_SWIPE_PEEK_THRESHOLD_DP,
parent.getWidth() / 2);
mItemHeight = getSizePx(context, R.dimen.navigation_popup_item_height);
mContentPadding = getSizePx(context, R.dimen.navigation_sheet_content_top_padding)
+ getSizePx(context, R.dimen.navigation_sheet_content_bottom_padding)
+ getSizePx(context, R.dimen.navigation_sheet_content_wrap_padding);
}
private static int getSizePx(Context context, @IdRes int id) {
return context.getResources().getDimensionPixelSize(id);
}
// Transition to either peeked or expanded state.
private void openSheet(boolean fullyExpand, boolean animate) {
mContentView =
(NavigationSheetView) mLayoutInflater.inflate(R.layout.navigation_sheet, null);
ListView listview = (ListView) mContentView.findViewById(R.id.navigation_entries);
listview.setAdapter(mModelAdapter);
NavigationHistory history = mDelegate.getHistory(mForward);
mMediator.populateEntries(history);
mBottomSheetController.get().requestShowContent(this, true);
mBottomSheetController.get().getBottomSheet().addObserver(mSheetObserver);
mSheetTriggered = true;
mFullyExpand = fullyExpand;
if (fullyExpand || history.getEntryCount() <= SKIP_PEEK_COUNT) expandSheet();
}
private void expandSheet() {
mBottomSheetController.get().expandSheet();
mDelegate.setTabCloseRunnable(mCloseRunnable);
GestureNavMetrics.recordHistogram("GestureNavigation.Sheet.Viewed", mForward);
}
// NavigationSheet
@Override
public void start(boolean forward, boolean showCloseIndicator) {
if (mBottomSheetController.get() == null) return;
mForward = forward;
mShowCloseIndicator = showCloseIndicator;
mSheetTriggered = false;
}
@Override
public void startAndExpand(boolean forward, boolean animate) {
start(forward, /* showCloseIndicator= */ false);
openSheet(/* fullyExpand= */ true, animate);
}
@Override
public void onScroll(float delta, float overscroll, boolean willNavigate) {
if (mBottomSheetController.get() == null) return;
if (mShowCloseIndicator) return;
if (overscroll > mLongSwipePeekThreshold) {
triggerSheetWithSwipeAndHold(delta, LONG_SWIPE_HOLD_DELAY_MS);
} else if (willNavigate) {
triggerSheetWithSwipeAndHold(delta, SHORT_SWIPE_HOLD_DELAY_MS);
} else if (isPeeked()) {
close(true);
} else {
mHandler.removeCallbacks(mOpenSheetRunnable);
}
}
private void triggerSheetWithSwipeAndHold(float delta, long delay) {
if (isHidden() && Math.abs(delta) > DELTA_IGNORE) {
mHandler.removeCallbacks(mOpenSheetRunnable);
mHandler.postDelayed(mOpenSheetRunnable, delay);
}
}
@Override
public void release() {
if (mBottomSheetController.get() == null) return;
mHandler.removeCallbacks(mOpenSheetRunnable);
if (mSheetTriggered) {
GestureNavMetrics.recordHistogram("GestureNavigation.Sheet.Peeked", mForward);
}
// Show navigation sheet if released at peek state.
if (isPeeked()) expandSheet();
}
/**
* Hide the sheet and reset its model.
* @param animation {@code true} if animation effect is to be made.
*/
private void close(boolean animate) {
if (!isHidden()) mBottomSheetController.get().hideContent(this, animate);
mBottomSheetController.get().getBottomSheet().removeObserver(mSheetObserver);
mDelegate.setTabCloseRunnable(null);
mMediator.clear();
}
@Override
public boolean isHidden() {
if (mBottomSheetController.get() == null) return true;
return getTargetOrCurrentState() == SheetState.HIDDEN;
}
/**
* @return {@code true} if the sheet is in peeked state.
*/
private boolean isPeeked() {
if (mBottomSheetController.get() == null) return false;
return getTargetOrCurrentState() == SheetState.PEEK;
}
private @SheetState int getTargetOrCurrentState() {
BottomSheet sheet = mBottomSheetController.get().getBottomSheet();
if (sheet == null) return SheetState.NONE;
@SheetState
int state = sheet.getTargetSheetState();
return state != SheetState.NONE ? state : sheet.getSheetState();
}
@Override
public boolean isExpanded() {
if (mBottomSheetController.get() == null) return false;
int state = getTargetOrCurrentState();
return state == SheetState.HALF || state == SheetState.FULL;
}
// BottomSheetContent
@Override
public View getContentView() {
return mContentView;
}
@Override
public View getToolbarView() {
return mToolbarView;
}
@Override
public int getVerticalScrollOffset() {
return mContentView.getVerticalScrollOffset();
}
@Override
public void destroy() {}
@Override
public @ContentPriority int getPriority() {
return ContentPriority.LOW;
}
@Override
public boolean swipeToDismissEnabled() {
return true;
}
@Override
public int getPeekHeight() {
// Makes peek state as 'not present' when bottom sheet is in expanded state (i.e. animating
// from expanded to close state). It avoids the sheet animating in two distinct steps, which
// looks awkward.
return !mBottomSheetController.get().getBottomSheet().isSheetOpen()
? getSizePx(mParentView.getContext(), R.dimen.navigation_sheet_peek_height)
: BottomSheet.HeightMode.DISABLED;
}
@Override
public boolean wrapContentEnabled() {
return false;
}
@Override
public float getCustomHalfRatio() {
return mFullyExpand ? getCustomFullRatio()
: getCappedHeightRatio(mParentView.getHeight() / 2 + mItemHeight / 2);
}
@Override
public float getCustomFullRatio() {
return getCappedHeightRatio(mParentView.getHeight());
}
private float getCappedHeightRatio(float maxHeight) {
int entryCount = mModelAdapter.getCount();
return Math.min(maxHeight, entryCount * mItemHeight + mContentPadding)
/ mParentView.getHeight();
}
@Override
public boolean hasCustomLifecycle() {
return true;
}
@Override
public boolean hideOnScroll() {
return false;
}
@Override
public int getSheetContentDescriptionStringId() {
return R.string.overscroll_navigation_sheet_description;
}
@Override
public int getSheetHalfHeightAccessibilityStringId() {
return R.string.overscroll_navigation_sheet_opened_half;
}
@Override
public int getSheetFullHeightAccessibilityStringId() {
return R.string.overscroll_navigation_sheet_opened_full;
}
@Override
public int getSheetClosedAccessibilityStringId() {
return R.string.overscroll_navigation_sheet_closed;
}
}