blob: 893f4646a92c6aa548d58a095108703a7663ec30 [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.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import androidx.annotation.IntDef;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.compositor.CompositorViewHolder.TouchEventObserver;
import org.chromium.chrome.browser.gesturenav.NavigationBubble.CloseTarget;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Handles history overscroll navigation controlling the underlying UI widget.
*/
public class NavigationHandler implements TouchEventObserver {
// Width of a rectangluar area in dp on the left/right edge used for navigation.
// Swipe beginning from a point within these rects triggers the operation.
@VisibleForTesting
static final float EDGE_WIDTH_DP = 48;
// Weighted value to determine when to trigger an edge swipe. Initial scroll
// vector should form 30 deg or below to initiate swipe action.
private static final float WEIGTHED_TRIGGER_THRESHOLD = 1.73f;
// |EDGE_WIDTH_DP| in physical pixel.
private final float mEdgeWidthPx;
@IntDef({GestureState.NONE, GestureState.STARTED, GestureState.DRAGGED})
@Retention(RetentionPolicy.SOURCE)
private @interface GestureState {
int NONE = 0;
int STARTED = 1;
int DRAGGED = 2;
int GLOW = 3;
}
private final ViewGroup mParentView;
private final Context mContext;
private HistoryNavigationDelegate mDelegate;
private ActionDelegate mActionDelegate;
private Supplier<NavigationGlow> mGlowEffectSupplier;
private @GestureState int mState = GestureState.NONE;
// Frame layout where the main logic turning the gesture into corresponding UI resides.
private SideSlideLayout mSideSlideLayout;
private NavigationSheet mNavigationSheet;
// Async runnable for ending the refresh animation after the page first
// loads a frame. This is used to provide a reasonable minimum animation time.
private Runnable mStopNavigatingRunnable;
// Handles removing the layout from the view hierarchy. This is posted to ensure
// it does not conflict with pending Android draws.
private Runnable mDetachLayoutRunnable;
private GestureDetector mDetector;
private View.OnAttachStateChangeListener mAttachStateListener;
private final Supplier<Boolean> mIsNativePage;
/**
* Interface to perform actions for navigating.
*/
public interface ActionDelegate {
/**
* @param forward Direction to navigate. {@code true} if forward.
* @return {@code true} if navigation toward the given direction is possible.
*/
boolean canNavigate(boolean forward);
/**
* Execute navigation toward the given direction.
* @param forward Direction to navigate. {@code true} if forward.
*/
void navigate(boolean forward);
/**
* @return {@code true} if back action will close the current tab.
*/
boolean willBackCloseTab();
/**
* @return {@code true} if back action will cause the app to exit.
*/
boolean willBackExitApp();
}
private class SideNavGestureListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDown(MotionEvent event) {
return NavigationHandler.this.onDown();
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
// |onScroll| needs handling only after the state moved away from |NONE|.
if (isStopped()) return true;
return NavigationHandler.this.onScroll(
e1.getX(), distanceX, distanceY, e2.getX(), e2.getY());
}
}
public NavigationHandler(ViewGroup parentView, Supplier<NavigationGlow> glowEffect,
Supplier<Boolean> isNativePage) {
mParentView = parentView;
mContext = parentView.getContext();
mGlowEffectSupplier = glowEffect;
mIsNativePage = isNativePage;
mEdgeWidthPx = EDGE_WIDTH_DP * parentView.getResources().getDisplayMetrics().density;
mDetector = new GestureDetector(mContext, new SideNavGestureListener());
mAttachStateListener = new View.OnAttachStateChangeListener() {
@Override
public void onViewAttachedToWindow(View v) {}
@Override
public void onViewDetachedFromWindow(View v) {
reset();
}
};
parentView.addOnAttachStateChangeListener(mAttachStateListener);
}
private void createLayout() {
mSideSlideLayout = new SideSlideLayout(mContext);
mSideSlideLayout.setLayoutParams(
new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
mSideSlideLayout.setOnNavigationListener((forward) -> {
mActionDelegate.navigate(forward);
cancelStopNavigatingRunnable();
mSideSlideLayout.post(getStopNavigatingRunnable());
});
mSideSlideLayout.setOnResetListener(() -> {
if (mDetachLayoutRunnable != null) return;
mDetachLayoutRunnable = () -> {
mDetachLayoutRunnable = null;
detachLayoutIfNecessary();
};
mSideSlideLayout.post(mDetachLayoutRunnable);
});
mNavigationSheet = NavigationSheet.isEnabled() ? NavigationSheet.create(
mParentView, mContext, mDelegate.getBottomSheetController())
: NavigationSheet.DUMMY;
mNavigationSheet.setDelegate(mDelegate.createSheetDelegate());
}
/**
* Sets {@link HistoryNavigationDelegate} object.
* Also creates new delegates, for horizontal gesture and bottom sheet processing.
* @param {@link HistoryNavigationDelegate} object.
*/
void setDelegate(HistoryNavigationDelegate delegate) {
mDelegate = delegate;
mActionDelegate = delegate.createActionDelegate();
if (mNavigationSheet != null) mNavigationSheet.setDelegate(delegate.createSheetDelegate());
}
@Override
public boolean shouldInterceptTouchEvent(MotionEvent e) {
// Forward gesture events only for native pages. Rendered pages receive events
// from SwipeRefreshHandler.
if (!mIsNativePage.get()) return false;
return isActive();
}
@Override
public void handleTouchEvent(MotionEvent e) {
if (!mIsNativePage.get()) return;
mDetector.onTouchEvent(e);
if (e.getAction() == MotionEvent.ACTION_UP) {
if (mState == GestureState.DRAGGED && mSideSlideLayout != null) {
mSideSlideLayout.release(mNavigationSheet.isHidden());
mNavigationSheet.release();
} else if (mState == GestureState.GLOW && mGlowEffectSupplier.get() != null) {
mGlowEffectSupplier.get().release();
}
}
}
/**
* @see GestureDetector#SimpleOnGestureListener#onDown(MotionEvent)
*/
public boolean onDown() {
mState = GestureState.STARTED;
return true;
}
/**
* Processes scroll event from {@link SimpleOnGestureListener#onScroll()}.
* @param startX X coordinate of the position where gesture swipes from.
* @param distanceX X delta between previous and current motion event.
* @param distanceX Y delta between previous and current motion event.
* @param endX X coordinate of the current motion event.
* @param endY Y coordinate of the current motion event.
*/
@VisibleForTesting
boolean onScroll(float startX, float distanceX, float distanceY, float endX, float endY) {
// onScroll needs handling only after the state moves away from |NONE|.
if (mState == GestureState.NONE || mActionDelegate == null) return true;
if (mState == GestureState.STARTED) {
if (shouldTriggerUi(startX, distanceX, distanceY)) {
navigate(distanceX > 0, endX, endY);
}
if (!isActive()) mState = GestureState.NONE;
}
pull(-distanceX);
return true;
}
private boolean shouldTriggerUi(float sX, float dX, float dY) {
return Math.abs(dX) > Math.abs(dY) * WEIGTHED_TRIGGER_THRESHOLD
&& (sX < mEdgeWidthPx || (mParentView.getWidth() - mEdgeWidthPx) < sX);
}
/**
* Shows UI in response to gesture events.
* @param forward Direction of the swipe gesture. {@code true} if forward; else back.
* @param x The X position of the event.
* @param y The Y position of the event.
* @return {@code true} if the navigation can be triggered.
*/
public boolean navigate(boolean forward, float x, float y) {
assert mActionDelegate != null;
boolean navigable = mActionDelegate.canNavigate(forward);
if (navigable) {
showArrowWidget(forward);
} else {
showGlow(x, y);
}
return navigable;
}
/**
* Start showing arrow widget for navigation back/forward.
* @param forward {@code true} if navigating forward.
*/
private void showArrowWidget(boolean forward) {
if (mState != GestureState.STARTED) reset();
if (mSideSlideLayout == null) createLayout();
mSideSlideLayout.setEnabled(true);
mSideSlideLayout.setDirection(forward);
@CloseTarget
int closeIndicator = getCloseIndicator(forward);
mSideSlideLayout.setCloseIndicator(closeIndicator);
attachLayoutIfNecessary();
mSideSlideLayout.start();
mNavigationSheet.start(forward, closeIndicator != CloseTarget.NONE);
mState = GestureState.DRAGGED;
}
/**
* Start showing edge glow effect.
* @param startX X coordinate of the touch event at the beginning.
* @param startY Y coordinate of the touch event at the beginning.
*/
private void showGlow(float startX, float startY) {
if (mState != GestureState.STARTED) reset();
if (mGlowEffectSupplier.get() != null) mGlowEffectSupplier.get().prepare(startX, startY);
mState = GestureState.GLOW;
}
private boolean shouldShowCloseIndicator(boolean forward) {
// Some tabs, upon back at the beginning of the history stack, should be just closed
// than closing the entire app. In such case we do not show the close indicator.
return !forward && mActionDelegate.willBackExitApp();
}
private @CloseTarget int getCloseIndicator(boolean forward) {
// Some tabs, upon back at the beginning of the history stack, should be just closed
// than closing the entire app.
if (!forward && mActionDelegate.willBackCloseTab()) {
return CloseTarget.TAB;
} else if (!forward && mActionDelegate.willBackExitApp()) {
return CloseTarget.APP;
} else {
return CloseTarget.NONE;
}
}
/**
* Signals a pull update.
* @param delta The change in horizontal pull distance (positive if toward right,
* negative if left).
*/
public void pull(float delta) {
if (mState == GestureState.DRAGGED && mSideSlideLayout != null) {
mSideSlideLayout.pull(delta);
mNavigationSheet.onScroll(
delta, mSideSlideLayout.getOverscroll(), mSideSlideLayout.willNavigate());
mSideSlideLayout.fadeArrow(!mNavigationSheet.isHidden(), /* animate= */ true);
if (mNavigationSheet.isExpanded()) {
mSideSlideLayout.hideArrow();
mState = GestureState.NONE;
}
} else if (mState == GestureState.GLOW) {
if (mGlowEffectSupplier.get() != null) mGlowEffectSupplier.get().onScroll(-delta);
}
}
/**
* @return {@code true} if navigation was triggered and its UI is in action, or
* edge glow effect is visible.
*/
private boolean isActive() {
return mState == GestureState.DRAGGED || mState == GestureState.GLOW;
}
/**
* @return {@code true} if navigation is not in operation.
*/
private boolean isStopped() {
return mState == GestureState.NONE;
}
/**
* Release the active pull. If no pull has started, the release will be ignored.
* If the pull was sufficiently large, the navigation sequence will be initiated.
* @param allowNav Whether to allow a sufficiently large pull to trigger
* the navigation action and animation sequence.
*/
public void release(boolean allowNav) {
if (mState == GestureState.DRAGGED && mSideSlideLayout != null) {
cancelStopNavigatingRunnable();
mSideSlideLayout.release(allowNav && mNavigationSheet.isHidden());
mNavigationSheet.release();
} else if (mState == GestureState.GLOW) {
if (mGlowEffectSupplier.get() != null) mGlowEffectSupplier.get().release();
}
}
/**
* Reset navigation UI in action.
*/
public void reset() {
if (mState == GestureState.DRAGGED && mSideSlideLayout != null) {
cancelStopNavigatingRunnable();
mSideSlideLayout.reset();
} else if (mState == GestureState.GLOW) {
if (mGlowEffectSupplier.get() != null) mGlowEffectSupplier.get().reset();
}
mState = GestureState.NONE;
}
private void cancelStopNavigatingRunnable() {
if (mStopNavigatingRunnable != null) {
mSideSlideLayout.removeCallbacks(mStopNavigatingRunnable);
mStopNavigatingRunnable = null;
}
}
private void cancelDetachLayoutRunnable() {
if (mDetachLayoutRunnable != null) {
mSideSlideLayout.removeCallbacks(mDetachLayoutRunnable);
mDetachLayoutRunnable = null;
}
}
private Runnable getStopNavigatingRunnable() {
if (mStopNavigatingRunnable == null) {
mStopNavigatingRunnable = () -> mSideSlideLayout.stopNavigating();
}
return mStopNavigatingRunnable;
}
private void attachLayoutIfNecessary() {
// The animation view is attached/detached on-demand to minimize overlap
// with composited SurfaceView content.
cancelDetachLayoutRunnable();
if (isLayoutDetached()) mParentView.addView(mSideSlideLayout);
}
private void detachLayoutIfNecessary() {
if (mSideSlideLayout == null) return;
cancelDetachLayoutRunnable();
if (!isLayoutDetached()) mParentView.removeView(mSideSlideLayout);
}
@VisibleForTesting
boolean isLayoutDetached() {
return mSideSlideLayout == null || mSideSlideLayout.getParent() == null;
}
/**
* Performs cleanup upon destruction.
*/
void destroy() {
mParentView.removeOnAttachStateChangeListener(mAttachStateListener);
mDetector = null;
}
}